XYZ

Devlog 1 - Fog Of War

One of the staples of any RTS is the Fog of War this was also the one thing I was dreading while building the game as it seemed like a complicated merging of the server keeping track of who can see what and then being able to make a material that would dynamically change over time, so I decided it'd be best to break it by each problem and slowly build up to having fog of war.

Challenges:

  1. Keeping track of what each actor can and cannot see
  2. Account for height differences in the terrain ( high ground vision)
  3. Displaying what Actors the client can and cannot see currently
  4. Having a fog that clears in the visible areas
  5. Having the minimap display this information

So there's a lot to work through...

MapManager

The first thing I'd need is a system that monitors what areas in the map are visible by the player. We could loop over all the actors and return what actors they see by comparing distances with each other, but even saying that it seems to brute force to even do.

My idea for a XYZMapManager was as follows our map size is 10k x 10k units long. We can subdivide that area into a grid of cells for example. A Map of size 2x2 would have the following cells [0,0][0,1][1,0][1,1].

TMap<FVector2D, FGridCell> and for now our GridCell struct can be the following.

struct FGridCell
	TSet<AXYZActor*> ActorsInCell;
	TArray<bool> TeamVision = {false, false};
	int32 Height = 0;

We'll want to know what Actors are currently in that Cell and a flag for both teams if they have vision or not, and a height we can later use for high ground vision.

At the beginning of the game we load up the starting units and buildings into the our new MapManager taking their actor location X,Y and adding them to our GridMap

Now every tick we can clear the vision of our GridMap and then go through our actors and go through our Actors and toggle what cells they can see.

ClearVision() and GenerateVision() are created

NOTE: I do think this can be improved upon in the future by removing vision from actors that have their location updated and then add their vision back to the grid, but from testing this seems fine for now.

void ClearVision()
{
	for (int32 X = 0; X < GRID_SIZE; X++) {
		for (int32 Y = 0; Y < GRID_SIZE; Y++) {
			FVector2D GridCoord(X, Y);
			Grid[GridCoord].TeamVision = {false, false};
		}
	}
}

void GenerateVision() {
	for (AXYZActor* Actor : Actors) {
		FVector2D ActorGridCoord = GetGridCoordinate(Actor->GetActorLocation());
		int32 CellsToCheck = FMath::CeilToInt(Actor->VisionRange / GridCellSize);

		for (int32 X = -CellsToCheck; X <= CellsToCheck; ++X) {
			for (int32 Y = -CellsToCheck; Y <= CellsToCheck; ++Y) {
				FVector2D AdjacentCoord(ActorGridCoord.X + X, ActorGridCoord.Y + Y);
				if (IsGridCoordValid(AdjacentCoord) &&
					Grid[ActorGridCoord].Height >= Grid[AdjacentCoord].Height) {
					Grid[AdjacentCoord].TeamVision[Actor->TeamId] = true;
				}
			}
		}
	}
}

Now we just need the grid to be updated when actors move or die in game.

TSet<AXYZActor*> ActorsToUpdate; We'll have a set of Actors that we know we have to update their location on the grid. We call this from when we process Actors in our engine tick only if they are DEAD or a moving state if they are IDLE then theres no need to update them.

and our Process() function for out MapManager ends up like so

ClearVision();
		for(AXYZActor* Actor : ActorsToUpdate)
		{
			RemoveActorFromGrid(Actor);
			AddActorToGrid(Actor);
		}
GenerateVision();

We keep track of the last FVector2D position in our Actor allowing us to easily remove and then add it back into the new grid position.

Our last step here is to just send our clients the data in short we keep track of the last FVector2D and AXYZActor we sent and only send the difference to not send unneeded information

void UXYZMapManager::SendDeltaVisibilityToClients()
{
	TArray<TSet<FVector2D>> VisibleCells = {{},{}};
	TArray<TSet<FVector2D>> NonVisibleCells = {{},{}};
	
	TArray<TSet<AXYZActor*>> VisibleActors = {{},{}};
	TArray<TSet<AXYZActor*>> NonVisibleActors = {{},{}};

	for (const TPair<FVector2D, FGridCell>& KVP : Grid)
	{
		FGridCell Cell = KVP.Value;
		FVector2d Coord = KVP.Key;
		for(AXYZPlayerController* PlayerController : GameMode->PlayerControllers)
		{
			int32 TeamId = PlayerController->TeamId;
			if(Cell.TeamVision[TeamId])
			{
				VisibleActors[TeamId] = Cell.ActorsInCell.Union(VisibleActors[TeamId]);
				VisibleCells[TeamId].Add(Coord);
			}else
			{
				NonVisibleActors[TeamId] = Cell.ActorsInCell.Union(NonVisibleActors[TeamId]);
				NonVisibleCells[TeamId].Add(Coord);
			}
		}
	}
	
	for(AXYZPlayerController* PlayerController : GameMode->PlayerControllers)
	{
		int32 TeamId = PlayerController->TeamId;

		TSet<AXYZActor*> VisibleActorsDifference = VisibleActors[TeamId].Difference(LastVisibleActorsSent[TeamId]);
		TSet<AXYZActor*> NonVisibleActorsDifference = NonVisibleActors[TeamId].Difference(LastNonVisibleActorsSent[TeamId]);

		TSet<FVector2D> VisibleCellsDifference = VisibleCells[TeamId].Difference(LastVisibleCellsSent[TeamId]);
		TSet<FVector2D> NonVisibleCellsDifference = NonVisibleCells[TeamId].Difference(LastNonVisibleCellsSent[TeamId]);

		bool bIsDifferent = !VisibleCellsDifference.IsEmpty() || !NonVisibleCellsDifference.IsEmpty() || !NonVisibleActorsDifference.IsEmpty() || !VisibleActorsDifference.IsEmpty();
		if(bIsDifferent)
		{
			if(bHasSentVison)
			{
				PlayerController->UpdateClientVisibility(ConvertSetToActorIds(VisibleActorsDifference),  ConvertSetToActorIds(NonVisibleActorsDifference), VisibleCellsDifference.Array(),  NonVisibleCellsDifference.Array());

			}else
			{
				PlayerController->UpdateClientVisibility(ConvertSetToActorIds(VisibleActorsDifference),  ConvertSetToActorIds(NonVisibleActorsDifference), VisibleCellsDifference.Array(),  {});
			}
		}
		
	}

	LastVisibleActorsSent = VisibleActors;
	LastNonVisibleActorsSent = NonVisibleActors;
	LastVisibleCellsSent = VisibleCells;
	LastNonVisibleCellsSent = NonVisibleCells;
}

Before I forget we also initialize each grid with a height, and that is done by shooting a ray during init of the grid from the sky until it hits the actual map in the game we take the Z position and added to the int32 Height of each cell. We use this height to check if a grid can have vision of another grid.

for (int32 X = 0; X < GRID_SIZE; X++) {
		for (int32 Y = 0; Y < GRID_SIZE; Y++) {
			FVector2D GridCoord(X, Y);
			FGridCell NewCell;

			FVector WorldCoord((GridCoord.X + 0.5f) * GridCellSize, (GridCoord.Y + 0.5f) * GridCellSize, 0);

			FVector Start = WorldCoord + FVector(0, 0, 1000);
			FVector End = WorldCoord;

			FHitResult HitResult;
			FCollisionQueryParams CollisionParams;
			CollisionParams.AddIgnoredActors(ActorsToIgnore);
			bool bHit = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECC_WorldStatic, CollisionParams);

			if (bHit) {
				NewCell.Height = HitResult.ImpactPoint.Z;
			}

			Grid.Add(GridCoord, NewCell);
		}
	}

Awesome we're sending the client what actors they can see and what grid cell locations they can see now what...

XYZFogOfWar.cpp

We need an actor on the client side to take this new information and correctly display the information changes to the client.

First step is simple enough we send what actors are visible so my simplest solution yet just hide the actors that we are told we cant see and show the actors we can see with SetActorHiddenInGame(bool)

Now the hard part at least it was for me. I searched online and found many different solutions that involved complex materials/masking/etc. And I'm not a materials person, so after hitting my head against the wall I thought of the simplest solution.

Let's imagine the grid as NxN grid of pixels we are told which pixels we can see and which ones we cannot. So lets hide the pixels we cant see? My game's art style is one of low graphics so I thought it was worth a shot.

void AXYZFogOfWar::Tick(float DeltaSeconds)
{
	Super::Tick(DeltaSeconds);
	if(CellsToUpdate.IsEmpty()) return;

	TArray<TArray<FVector2D>> UpdateCells;
	CellsToUpdate.Dequeue(UpdateCells);

	for(FVector2D Coord : UpdateCells[0])
	{
		OnRevealCell.Broadcast(Coord);
	}
	for(FVector2D Coord : UpdateCells[1])
	{
		OnConcealCell.Broadcast(Coord);
	}

}

We can broadcast what pixels we can and cannot see and then in our blueprints we can adjust our new NxN render target's pixels.

FoWBlueprint

We mark the pixels we can't see as black with .85 alpha and the ones we can see as fully transparent.

We then plop the render target onto a plane that covers the entire map making it's depth render over everything and TADA! Fog of War!

FoWDemo1

And here's our high ground vision! FoWDemo2

Last but not least our minimap is actually much like the fog plane where we draw red pixels for enemy units and green for ally units! XYZ

If you made it this far thank you for reading and if you have any questions or you're looking for a passionate game dev email me at jandro@xyzrts.com

View original