XCOM 2 Tile Overlay Tutorial

From Nexus Mods Wiki
Jump to: navigation, search

This wiki article describes how to add new tile overlays similar to the concealment breaking or objective tiles indicators in the base game. A form of this has been implemented in the Evac All mod, and this article references the approach taken in that mod. The source code corresponding to this article can be found on the github repository, with commit 7c5438d. The technique described here isn't limited to tile overlays, but can be used for other graphical effects using static meshes as well.

The image below shows an example of what can be done with this technique. Here the three tiles blocked by the overhanging sign are not eligible tiles to evac from. The Evac All mod marks these tiles with overlays to indicate this.

Tile Overlay Example


Note that this page describes one way to accomplish this, but it is not necessarily the best or even the easiest way. Please update this page as necessary to add new techniques or to make any corrections.

Summary of Steps

Creating an effect of this kind takes several classes in the mod script, as well as custom graphical assets. The basic overview is:

Scripts:

  1. Create an actor class deriving from StaticMeshActor. This will represent your tile mesh in the game. In evac all, this is X2Actor_NoEvacTile. Note: XCOM2 supports Instanced mesh actors and this might be a more efficient mechanism for representing these.
  2. If these tiles will typically be grouped together, create an actor class deriving fom Actor to act as an owner of the various StaticMeshActors in the group. In Evac All, the evac zone may contain several blocked tiles, so a class is used to contain an array of all the SMActors and keep them grouped together. In Evac All, this is X2Actor_NoEvacTileGroup.
  3. Create an action class deriving from X2Action. This is used to control visualization. In Evac All this is X2Action_NoEvacTiles.
  4. Create a subclass of XComGameState to maintain a record of the placement of these actors in the game world so they can be reconstructed on game load and replicated to other players in multiplayer games. This game state will reference the actor group as its visualizer. In Evac All this is XComGameState_NoEvacTiles.
  5. When you wish to display your new tiles, construct your game state and spawn one or more of your SMActors. Assign them to the game state and link your action to the visualization of the game state context.

Assets:

  1. Create your texture(s) for your new asset in your favorite graphics program.
  2. Import a new static mesh into your package to represent the tile overlay. Since all tile overlays have the same shape, instead of importing you can simply copy an existing mesh from the base game. These can be found in the UI_3D package in the Tiles group. If you're using a different shape you may need to build and import your mesh from your 3D software.
  3. Create a new material from your texture and assign it to the mesh you created/copied.

Detailed Steps

The Static Mesh Actor

The static mesh actor is the representation of your asset in the game.

class X2Actor_NoEvacTile extends StaticMeshActor;

var protected string MeshPath;

simulated event PostBeginPlay()
{
	local StaticMesh TargetMesh;

	super.PostBeginPlay();

	TargetMesh = StaticMesh(`CONTENT.RequestGameArchetype(default.MeshPath));
	`assert(TargetMesh != none);
	StaticMeshComponent.SetStaticMesh(TargetMesh);
}

DefaultProperties
{
	Begin Object Name=StaticMeshComponent0
		bOwnerNoSee=FALSE
		CastShadow=FALSE
		CollideActors=FALSE
		BlockActors=FALSE
		BlockZeroExtent=FALSE
		BlockNonZeroExtent=FALSE
		BlockRigidBody=FALSE
		HiddenGame=FALSE
		HideDuringCinematicView=true
	End Object

	bStatic=FALSE
	bWorldGeometry=FALSE
	bMovable=TRUE

	MeshPath="UI_EvacAll.NoEvacTile"
}

The most important portions of this are in the DefaultProperties block, especially the MeshPath line. MeshPath sets the path to your StaticMesh asset in your package. The StaticMeshComponent object sets various properties for your asset. In this case the important ones are ensuring that this asset doesn't collide with anything or block other actors -- we want units to be able to walk on these.

Now Spawn()ing an instance of this class and setting its position (SetLocation()) and making it visible (SetHidden(false)) will immediately draw the asset in the game world. But, this bypasses a lot of the normal game pipeline managing game states and visualization tracks. But just getting to this point is enough to get something appearing in-game. The remainder of the script section involves making it more of a first-class part of the game. Without these additional steps your SMActors are basically "floating" in the game but are not attached to any game state so they aren't part of the official view of the game history - they won't necessarily appear in replays or appear after loading saved games, and they won't be debuggable using the tools that debug history or visualizers.

The Actor Group

If you are planning to use several of the static mesh actors in a group, it may be useful to contain them within a single actor.

class X2Actor_NoEvacTileGroup extends Actor 
	placeable;

var array<X2Actor_NoEvacTile> NoEvacTiles;
var int ObjectID;

Here we create the new actor type and it will contain an array of our static mesh actors.

function InitTiles(XComGameState_NoEvacTiles NoEvacTilesState)
{
	local X2Actor_NoEvacTile NoEvacTile;
	local vector TileLocation;
	local XComWorldData WorldData;
	local TTile Tile;
	local Object ThisObj; 

	if (NoEvacTilesState != none)
	{
		// Clear out any existing tiles
		DestroyTileActors();

		WorldData = `XWORLD;

		ObjectID = NoEvacTilesState.ObjectID;
		`XCOMHISTORY.SetVisualizer(ObjectID, self);

		// Create all the tile actors
		foreach NoEvacTilesState.NoEvacTiles (Tile)
		{
			NoEvacTile = `BATTLE.spawn(class'X2Actor_NoEvacTile');
			TileLocation = WorldData.GetPositionFromTileCoordinates(Tile);
			TileLocation.Z = WorldData.GetFloorZForPosition(TileLocation) + 4;
			NoEvacTile.SetLocation(TileLocation);
			NoEvacTile.SetHidden(false);
			NoEvacTiles.AddItem(NoEvacTile);
		}

		// And register ourselves to pay attention if the evac zone gets nuked
		ThisObj = self;
		`XEVENTMGR.RegisterForEvent(ThisObj, 'EvacZoneDestroyed', OnEvacZoneDestroyed, ELD_OnStateSubmitted);
	}
}

The InitTiles function is responsible for spawning the SMActors and positioning them appropriately. It gets the list of tiles from the state object that will persist in the game history telling us which tiles to mark. We also register a listener for the 'EvacZoneDestroyed' event so we can clean up the actors when they're not needed any longer.

function DestroyTileActors()
{
	local X2Actor_NoEvacTile NoEvacActor;
	
	foreach NoEvacTiles(NoEvacActor)
	{
		NoEvacActor.Destroy();
	}

	NoEvacTiles.Length = 0;	
}

// Evac zone has been destroyed! Destroy all our blocked tile actors and return.
function EventListenerReturn OnEvacZoneDestroyed(Object EventData, Object EventSource, XComGameState GameState, Name InEventID)
{
	local Object ThisObj;

	DestroyTileActors();
	ThisObj = self;
	`XEVENTMGR.UnregisterFromEvent(ThisObj, 'EvacZoneDestroyed');

	return ELR_NoInterrupt;
}

We also need to clean up the tiles when they are no longer needed - if the evac point is destroyed we can get rid of these actors.

Note: It may be more efficient to represent these as InstancedStaticMeshComponents or X2FadingInstancedStaticMeshComponents. These would then make the SMActor class above obsolete.

The Action Class

The action class is responsible for performing the visualization of the actor(s). The action is associated with the state context when it's added to the history.

class X2Action_NoEvacTiles extends X2Action;

var XComGameState_NoEvacTiles NoEvacTilesState;

function Init(const out VisualizationTrack InTrack)
{
	super.Init(InTrack);

	NoEvacTilesState = XComGameState_NoEvacTiles(InTrack.StateObject_NewState);
}

simulated state Executing
{
Begin:
	NoEvacTilesState.FindOrCreateVisualizer();
	NoEvacTilesState.SyncVisualizer();
	CompleteAction();
}

The visualization can be more complex than this, including additional states if needed.

The State Object

The game state object records the presence of these actors in the game history and associates their visualization with that game state. For an example as simple as this a game state is probably not strictly necessary, but is good practice.

class XComGameState_NoEvacTiles extends XComGameState_BaseObject;

var array<TTile> NoEvacTiles;

Our game state extends the base state object and maintains a list of the tiles we are going to paint with the overlay.

Note: In order to be correctly visualized, the state object should implement X2Visualized. Unfortunately we cannot implement this as the interface is native and only native classes can implement native interfaces. Since we can't make our sub-class native, we are stuck. This means that the state will not correctly be visualized during replays/loads. There is a workaround for this, shown below.

static function XComGameState_NoEvacTiles LookupNoEvacTilesState()
{
	local XComGameState_NoEvacTiles GameState;
	local XComGameStateHistory History;

	History = `XCOMHISTORY;
	foreach History.IterateByClassType(class'XComGameState_NoEvacTiles', GameState)
	{
		return GameState;
	}

	return none;
}

static function XComGameState_NoEvacTiles CreateNoEvacTilesState(XComGameState NewGameState, const out array<TTile> BlockedTiles)
{
	local XComGameState_NoEvacTiles NoEvacTilesState;

	// See if we already have one.
	NoEvacTilesState = LookupNoEvacTilesState();
	if (NoEvacTilesState == none)
	{
		// Don't already have one. Make a new one.
		NoEvacTilesState = XComGameState_NoEvacTiles(NewGameState.CreateStateObject(class'XComGameState_NoEvacTiles'));
	}
	else
	{
		// We already had one, we're going to be updating the existing one.
		NoEvacTilesState = XComGameState_NoEvacTiles(NewGameState.CreateStateObject(NoEvacTilesState.Class, NoEvacTilesState.ObjectID));
	}

	NoEvacTilesState.NoEvacTiles = BlockedTiles;
	NoEvacTilesState.FindOrCreateVisualizer();
	NewGameState.AddStateObject(NoEvacTilesState);

	return NoEvacTilesState;
}

Create or re-use an existing game state. Note: I'm unsure about this - it was copied from the code handling evac zone placement. Specifically I'm not sure how the updating of existing evac zones works. The definition of these functions is native so we can't easily see what they do internally.

function Actor FindOrCreateVisualizer(optional XComGameState GameState = none)
{
	local X2Actor_NoEvacTileGroup NoEvacTilesActor;

	NoEvacTilesActor = X2Actor_NoEvacTileGroup(GetVisualizer());
	
	if (NoEvacTilesActor != none)
	{
		NoEvacTilesActor.Destroy();
	}

	NoEvacTilesActor = `BATTLE.Spawn(class'X2Actor_NoEvacTileGroup');
	`XCOMHISTORY.SetVisualizer(ObjectID, NoEvacTilesActor);

	return NoEvacTilesActor;
}

function SyncVisualizer(optional XComGameState GameState = none)
{
	local X2Actor_NoEvacTileGroup NoEvacTilesActor;

	NoEvacTilesActor = X2Actor_NoEvacTileGroup(GetVisualizer());
	NoEvacTilesActor.InitTiles(self);
}

function AppendAdditionalSyncActions( out VisualizationTrack BuildTrack )
{
}

Visualization functions. These are used by the Action class to visualize this state. They would also be used by the replay/load functionality to restore the visualizations, if this state object implemented X2Visualized. FindOrCreateVisualizer will create a new group object (if one doesn't already exist) and set it as the visualizer for this state. SyncVisualizer actually performs visualization and calls the InitTiles() function on our group to build the meshes and display them.

The Glue

Now that all the pieces are in place, the last one is to trigger the construction of your new state when necessary. For Evac All this is done as a response to the evac zone being placed. The UI listener sets up an event handler for the 'EvacZonePlaced' event:

 	ThisObj = self;
	`XEVENTMGR.RegisterForEvent(ThisObj, 'EvacZonePlaced', OnEvacZonePlaced, ELD_OnVisualizationBlockCompleted, 50);
 
	// If we have a NoEvac state visualize it.
	NoEvacTilesState = class'XComGameState_NoEvacTiles'.static.LookupNoEvacTilesState();
	if (NoEvacTilesState != none)
	{
		NoEvacTilesState.FindOrCreateVisualizer();
		NoEvacTilesState.SyncVisualizer();
	}

This code also includes the workaround for the fact that we cannot make our state implement X2Visualized. Here we search for a NoEvacTiles state in the history. If we find one, trigger visualization.

The event handler is responsible for the initial set up of the state:

function EventListenerReturn OnEvacZonePlaced(Object EventData, Object EventSource, XComGameState GameState, Name InEventID)
{
	local XComGameState_EvacZone EvacState;
	local XComGameState NewGameState;
	local TTile Min, Max, TestTile;
	local array<TTile> NoEvacTiles;
	local int x, y;
	local int IsOnFloor;
	local XComWorldData WorldData;
	
	EvacState = XComGameState_EvacZone(EventSource);
	WorldData = `XWORLD;
	class'XComGameState_EvacZone'.static.GetEvacMinMax(EvacState.CenterLocation, Min, Max);

	TestTile.Z = EvacState.CenterLocation.Z;
	for (x = Min.X; x <= Max.X; ++x) 
	{
		TestTile.X = x;
		for (y = Min.Y; y <= Max.Y; ++y)
		{
			TestTile.Y = y;

			// If this tile is not a valid evac tile, add it to our list. But don't bother with tiles
			// that are not valid destinations.
			if (!class'X2TargetingMethod_EvacZone'.static.ValidateEvacTile(TestTile, IsOnFloor) && 
				WorldData.CanUnitsEnterTile(TestTile))
			{
				`Log("Invalid tile at " $ x $ ", " $ y);
				NoEvacTiles.AddItem(TestTile);
			}
		}
	}

	if (NoEvacTiles.Length > 0) 
	{
		// Create a new state for our no-evac tile placement.
		NewGameState = class'XComGameStateContext_ChangeContainer'.static.CreateChangeState("Set NoEvac Tiles");	

		// Create the state for our bad tiles and add it to NewGameState.
		class'XComGameState_NoEvacTiles'.static.CreateNoEvacTilesState(NewGameState, NoEvacTiles);

		// Create and sync the visualizer to create the blocked tile actors
		XComGameStateContext_ChangeContainer(NewGameState.GetContext()).BuildVisualizationFn = BuildVisualizationForNoEvacTiles;
		
		`TACTICALRULES.SubmitGameState(NewGameState);
	}

	return ELR_NoInterrupt;
}

This function checks each tile in the evac zone to see if it's a valid evac tile. If not, but that tile is a tile that units can enter (e.g. it's not obstructed by other actors like cover) add it to a list of blocked tiles. If we have any such tiles in the zone, construct a new state holding those tiles and assign a visualization function to the state context.

function BuildVisualizationForNoEvacTiles(XComGameState VisualizeGameState, out array<VisualizationTrack> OutVisualizationTracks)
{
	local VisualizationTrack Track;
	local XComGameState_NoEvacTiles NoEvacTilesState;

	foreach VisualizeGameState.IterateByClassType(class'XComGameState_NoEvacTiles', NoEvacTilesState)
	{
		break;
	}
	`assert(NoEvacTilesState != none);

	// Create a visualization track
	Track.StateObject_NewState = NoEvacTilesState;
	Track.StateObject_OldState = NoEvacTilesState;
	Track.TrackActor = NoEvacTilesState.GetVisualizer();
	class 'X2Action_NoEvacTiles'.static.AddToVisualizationTrack(Track, VisualizeGameState.GetContext());
	OutVisualizationTracks.AddItem(Track);
}

The visualization building function creates a visualization track and adds the action we created to that track. When this state is visualized, this will trigger the action's executing event, which will in turn create and show the actors.

Creating the Tile Static Mesh

The tile static mesh is set up in the Unreal Editor and consists of three main parts:

  1. The mesh itself
  2. A material attached to that mesh
  3. Texture(s) associated with that material

Importing the Mesh

If your mesh is a simple tile, it is easiest to just copy one of the existing tile meshes. These can be found in the UI_3D package in the Tiles group. Find a StaticMesh instance for a tile plane, right click, and chose Create a Copy. Input your desired package name, and rename the mesh to an appropriate name. Note that this package should have a different name from the package containing your scripts, or else Unreal will be unable to differentiate between them and you will not be able to load your assets.

If your mesh is more complex, you'll need to import it from your 3D package according to the instructions for your package.

Importing the Texture

Your texture can be created in your favorite image program. For tiles, they are typically 256x256 pixels and most standard tile overlays contain only two channels of data. If you do the same, draw on the red and green channels only. In the base tiles, the green channel is a radial gradiant with the corners chopped off, and the red channel shows the icon in the center of the tile. If you use this same scheme, use the TC_TwoChannel compression on your texture.

To import, click the "Import" button in the content browser and choose your file. The convention used in the base game is to prefix texutres with T_.

Texture Settings

Creating the Material

Now you need to create a new material using your texture and assign it to your mesh. Click "New" in the content browser and choose "Material". Give it an appropriate name and package (materials use the prefix M_ by convention). In the material editor, you can now set up the appropriate graph for your material. The base tile materials use a parameterizable base color, and the texture is used only to specify opacity. Included is an image of the graph used for the Evac All material to include a pulsing translucency on the icon.

Once you are happy with the material graph, double-click your mesh and assign the material to the LODInfo for the mesh. If you copied an existing mesh, you'll need to replace the original material here.

Material Settings