Catlike Coding

Hex Map 3.4.0

Cell Struct

Made without cell objects.

This tutorial is made with Unity 2022.3.38f1 and follows Hex Map 3.3.0.

Moving Responsibilities

We continue our quest to get rid of the cell objects. This time we will achieve that goal, although we don't completely remove the HexCell type.

Flags and Values

To reduce the code size of HexCell we will get rid of all the properties that now simply forward to the flags and values. Other code should just access those directly. To allow this the Flags€ and Values properties of HexCell must become public.

	public HexFlags Flags€ { … }
	
	public HexValues Values { … }

To make this more convenient an HasRiver method to HexFlags that indicates whether a river exists in a given direction.

	public static bool HasRiver(
		this HexFlags flags, HexDirection direction) =>
		flags.HasRiverIn(direction) || flags.HasRiverOut(direction);

Also add ViewElevation and IsUnderwater properties to HexValues. We'll simply use Mathf.Max to determine the view elevation.

using UnityEngine;public readonly int ViewElevation => Mathf.Max(Elevation, WaterLevel);

	public readonly bool IsUnderwater => WaterLevel > Elevation;

Moving Remaining Data to Grid

We're going to remove nearly all remaining data from the cell. Like we did earlier with other cell data we'll store it in arrays in HexGrid instead.

Add a publicly-accessible CellUnits array to the grid.

	public HexUnit[] CellUnits
	{ get; private set; }

Also give it arrays for the cell references to their chunk and their UI rect. But we keep these private because we'll make sure that nothing besides the grid needs to access these.

	HexGridChunk[] cellGridChunks;

	RectTransform[] cellUIRects;

Create these arrays in CreateCells along with the other arrays. Let's also use the length of CellData as a reference for all lengths instead of the cells array length, because we're going to remove the latter later.

		CellData = new HexCellData[CellCountZ * CellCountX];
		CellPositions = new Vector3[CellData.Length];
		cellUIRects = new RectTransform[CellData.Length];
		cellGridChunks = new HexGridChunk[CellData.Length];
		CellUnits = new HexUnit[CellData.Length];
		searchData = new HexCellSearchData[CellData.Length];
		cellVisibility = new int[CellData.Length];

Now we're going to modify CreateCell so it directly set the cell's explorable flag and elevation, and stores the UI rect and chunk reference in the array instead of the cell. We also merge the method with AddCellToChunk directly below it.

	void CreateCell(int x, int z, int i)
	{
		…
		// if (Wrapping) { … } else { … }

		bool explorable = Wrapping ?
			z > 0 && z < CellCountZ - 1 :
			x > 0 && z > 0 && x < CellCountX - 1 && z < CellCountZ - 1;
		cell.Flags€ = explorable ?
			cell.Flags€.With(HexFlags.Explorable) :
			cell.Flags€.Without(HexFlags.Explorable);

		Text label = Instantiate(cellLabelPrefab);
		label.rectTransform.anchoredPosition =
			new Vector2(position.x, position.z);
		RectTransform rect = cellUIRects[i] = label.rectTransform;
		
		//cell.Elevation = 0;
		cell.Values = cell.Values.WithElevation(0);

		//AddCellToChunk(x, z, cell);
	//}

	//void AddCellToChunk(int x, int z, HexCell cell)
	//{
		int chunkX = x / HexMetrics.chunkSizeX;
		int chunkZ = z / HexMetrics.chunkSizeZ;
		HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX];

		int localX = x - chunkX * HexMetrics.chunkSizeX;
		int localZ = z - chunkZ * HexMetrics.chunkSizeZ;
		cellGridChunks[i] = chunk;
		chunk.AddCell(localX + localZ * HexMetrics.chunkSizeX, i, rect);
	}

Refreshing

Setting the cell elevation should also refresh the cell position. We copy the method responsible for this from the cell to the grid, adapting it to directly access the arrays. So it only needs a cell index parameter. We make it public so others can trigger a refresh as well.

	public void RefreshCellPosition (int cellIndex)
	{
		Vector3 position = CellPositions[cellIndex];
		position.y = CellData[cellIndex].Elevation * HexMetrics.elevationStep;
		position.y +=
			(HexMetrics.SampleNoise(position).y * 2f - 1f) *
			HexMetrics.elevationPerturbStrength;
		CellPositions[cellIndex] = position;

		RectTransform rectTransform = cellUIRects[cellIndex];
		Vector3 uiPosition = rectTransform.localPosition;
		uiPosition.z = -position.y;
		rectTransform.localPosition = uiPosition;
	}

Invoke it in CreateCell after setting the elevation.

		cell.Values = cell.Values.WithElevation(0);
		RefreshCellPosition(i);

Let's also add public methods to trigger a refresh for a whole cell a for a cell with its dependents, with are its neighbors and its unit.

	public void RefreshCell(int cellIndex) =>
		cellGridChunks[cellIndex].Refresh();

	public void RefreshCellWithDependents (int cellIndex)
	{
		HexGridChunk chunk = cellGridChunks[cellIndex];
		chunk.Refresh();
		HexCoordinates coordinates = CellData[cellIndex].coordinates;
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++)
		{
			if (TryGetCellIndex(coordinates.Step(d), out int neighborIndex))
			{
				HexGridChunk neighborChunk = cellGridChunks[neighborIndex];
				if (chunk != neighborChunk)
				{
					neighborChunk.Refresh();
				}
			}
		}
		HexUnit unit = CellUnits[cellIndex];
		if (unit)
		{
			unit.ValidateLocation();
		}
	}

And finally also a new RefreshAllCells methods that performs a complete refresh of all cells.

	public void RefreshAllCells()
	{
		for (int i = 0; i < CellData.Length; i++)
		{
			SearchData[i].searchPhase = 0;
			RefreshCellPosition(i);
			ShaderData.RefreshTerrain(i);
			ShaderData.RefreshVisibility(i);
		}
	}

This methods is for HexMapGenerator.GenerateMap, which has to refresh all cells and can now do so by simply invoking the new method.

		//for (int i = 0; i < cellCount; i++)
		//{
			//grid.SearchData[i].searchPhase = 0;
			//grid.GetCell(i).RefreshAll();
		//}
		grid.RefreshAllCells();

Saving and Loading

We also move the responsibility for saving and loading cells to the grid. In Save we simply write the values and flags of each cell. We now loop based on the length of CellData instead of the length of cells.

		for (int i = 0; i < CellData.Length; i++)
		{
			//cells[i].Save(writer);
			HexCellData data = CellData[i];
			data.values.Save(writer);
			data.flags.Save(writer);
		}

Adjust Load in the same way. Here we also have to refresh the position, terrain, and visibility.

		for (int i = 0; i < CellData.Length; i++)
		{
			//cells[i].Load(reader, header);
			HexCellData data = CellData[i];
			data.values = HexValues.Load(reader, header);
			data.flags = data.flags.Load(reader, header);
			CellData[i] = data;
			RefreshCellPosition(i);
			ShaderData.RefreshTerrain(i);
			ShaderData.RefreshVisibility(i);
		}

Label and Highlight

The cell will become oblivious to its label and highlight, because they're part of the separate UI. Copy the SetLabel, DisableHighlight, and EnableHighlight methods to HexGrid and adapt them to work with a cell index parameter and the UI rect array. These methods can remain private as only the grid needs to use them.

	void SetLabel(int cellIndex, string text) =>
		cellUIRects[cellIndex].GetComponent<Text>().text = text;

	void DisableHighlight(int cellIndex) =>
		cellUIRects[cellIndex].GetChild(0).GetComponent<Image>().enabled =
			false;

	void EnableHighlight(int cellIndex, Color color)
	{
		Image highlight =
			cellUIRects[cellIndex].GetChild(0).GetComponent<Image>();
		highlight.color = color;
		highlight.enabled = true;
	}

Next, adjust ClearPath and ShowPath so they use the methods of the grid itself. This also means that they no longer need to retrieve cells and can work with cell indices exclusively, no longer needing to retrieve cells.

	public void ClearPath()
	{
		if (currentPathExists)
		{
			//HexCell current = cells[currentPathToIndex];
			int currentIndex = currentPathToIndex;
			while (currentIndex != currentPathFromIndex)
			{
				current.SetLabel(null);
				current.DisableHighlight();
				current = cells[searchData[current.Index].pathFrom];
				SetLabel(currentIndex, null);
				DisableHighlight(currentIndex);
				currentIndex = searchData[currentIndex].pathFrom;
			}
			//current.DisableHighlight();
			DisableHighlight(currentIndex);
			currentPathExists = false;
		}
		else if (currentPathFromIndex >= 0)
		{
			//cells[currentPathFromIndex].DisableHighlight();
			//cells[currentPathToIndex].DisableHighlight();
			DisableHighlight(currentPathFromIndex);
			DisableHighlight(currentPathToIndex);
		}
		currentPathFromIndex = currentPathToIndex = -1;
	}

	void ShowPath(int speed)
	{
		if (currentPathExists)
		{
			//HexCell current = cells[currentPathToIndex];
			int currentIndex = currentPathToIndex;
			while (currentIndex != currentPathFromIndex)
			{
				int turn = (searchData[currentIndex].distance - 1) / speed;
				//current.SetLabel(turn.ToString());
				//current.EnableHighlight(Color.white);
				SetLabel(currentIndex, turn.ToString());
				EnableHighlight(currentIndex, Color.white);
				currentIndex = searchData[currentIndex].pathFrom;
			}
		}
		EnableHighlight(currentPathFromIndex, Color.blue);
		EnableHighlight(currentPathToIndex, Color.red);
	}

Visibility

We're also going to make a few slight changes to the visibility code. First, IncreaseVisibility will set the explored flag directly.

				//cells[i].MarkAsExplored();
				HexCell c = cells[i];
				c.Flags€ = c.Flags€.With(HexFlags.Explored);
				cellShaderData.RefreshVisibility(cellIndex);

Second, ResetVisibility will loop based on the length of cellVisibility.

		for (int i = 0; i < cellVisibility.Length; i++) { … }

Third, GetVisibibleCells will now also use access values and flags directly.

		range += fromCell.Values.ViewElevation;
		…
				if (currentData.searchPhase > searchFrontierPhase ||
					//!neighbor.Explorable
					neighbor.Flags€.HasNone(HexFlags.Explorable))
				…
				if (distance + neighbor.Values.ViewElevation > range ||
					distance > fromCoordinates.DistanceTo(neighbor.Coordinates))

Unit

Moving on to HexUnit, thanks to a recent change that we made, we can get the column index from the cell's coordinates in the Location setter, instead of from the cell itself.

			Grid.MakeChildOfColumn(transform, value.Coordinates.ColumnIndex);

This is also the case for TravelPath.

		int currentColumn = currentTravelLocation.Coordinates.ColumnIndex;
		…
			int nextColumn = currentTravelLocation.Coordinates.ColumnIndex;

Adjust IsValidDestination so it accesses the cell flags and values.

	public bool IsValidDestination(HexCell cell) =>
		cell.Flags€.HasAll(HexFlags.Explored | HexFlags.Explorable) &&
		!cell.Values.IsUnderwater && !cell.Unit;

And do the same for GetMoveCost. We'll also directly go to HexMetrics to get the edge type, instead of asking a cell for it.

		HexEdgeType edgeType = HexMetrics.GetEdgeType(
			fromCell.Values.Elevation, toCell.Values.Elevation);
		if (edgeType == HexEdgeType.Cliff)
		{
			return -1;
		}
		int moveCost;
		if (fromCell.Flags€.HasRoad(direction))
		{
			moveCost = 1;
		}
		else if (fromCell.Flags€.HasAny(HexFlags.Walled) !=
			toCell.Flags€.HasAny(HexFlags.Walled))
		{
			return -1;
		}
		else
		{
			moveCost = edgeType == HexEdgeType.Flat ? 5 : 10;
			HexValues v = toCell.Values;
			moveCost += v.UrbanLevel + v.FarmLevel + v.PlantLevel;
		}

Downsizing Cells

We have moved data out of the cells and eliminated responsibilities from it. Now it's time to clean up HexCell, getting rid of what is no longer needed and restructuring the rest a bit.

Cell Refactoring

First, we keep the Unit property but make it forward to the grid's array so the cell no longer contains a direct reference to its unit.

	public HexUnit Unit
	{
		get => Grid.CellUnits[index];
		set => Grid.CellUnits[index] = value;
	}

Keep the Grid, Index, Position, Unit, Flags€, and Values properties, but remove all other getters as they are no longer needed. We refactor all setters into methods with a value parameter. First is SetElevation, which replaces the Elevation setter.

	public void SetElevation (int elevation)
	{
		if (Values.Elevation != elevation)
		{
			Values = Values.WithElevation(elevation);
			Grid.ShaderData.ViewElevationChanged(index);
			Grid.RefreshCellPosition(index);
			ValidateRivers();
			HexFlags flags = Flags€;
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++)
			{
				if (flags.HasRoad(d))
				{
					HexCell neighbor = GetNeighbor(d);
					if (Mathf.Abs(elevation - neighbor.Values.Elevation) > 1)
					{
						RemoveRoad(d);
					}
				}
			}
			Grid.RefreshCellWithDependents(index);
		}
	}

Then comes SetWaterLevel.

	public void SetWaterLevel (int waterLevel)
	{
		if (Values.WaterLevel != waterLevel)
		{
			Values = Values.WithWaterLevel(waterLevel);
			Grid.ShaderData.ViewElevationChanged(index);
			ValidateRivers();
			Grid.RefreshCellWithDependents(index);
		}
	}

Followed by SetUrbanLevel.

	public void SetUrbanLevel (int urbanLevel)
	{
		if (Values.UrbanLevel != urbanLevel)
		{
			Values = Values.WithUrbanLevel(urbanLevel);
			Refresh();
		}
	}

Along with SetFarmLevel and SetPlantLevel, which look similar. After that comes SetSpecialIndex.

	public void SetSpecialIndex (int specialIndex)
	{
		if (Values.SpecialIndex != specialIndex &&
			Flags.HasNone(HexFlags.River))
		{
			Values = Values.WithSpecialIndex(specialIndex);
			RemoveRoads();
			Refresh();
		}
	}

Then SetWalled.

	public void SetWalled (bool walled)
	{
		HexFlags flags = Flags€;
		HexFlags newFlags = walled ?
			flags.With(HexFlags.Walled) : flags.Without(HexFlags.Walled);
		if (flags != newFlags)
		{
			Flags€ = newFlags;
			Grid.RefreshCellWithDependents(index);
		}
	}

And finally SetTerrainTypeIndex.

	public void SetTerrainTypeIndex (int terrainTypeIndex)
	{
		if (Values.TerrainTypeIndex != terrainTypeIndex)
		{
			Values = Values.WithTerrainTypeIndex(terrainTypeIndex);
			Grid.ShaderData.RefreshTerrain(index);
		}
	}

We keep these setting functionality in HexCell because they take care of inter-cell dependencies when changing something of a cell. Only the map editor does that, but potential gameplay code that changes cells could rely on it as well.

The MarkAsExplored, GetEdgeType, and HasRiverThroughEdge methods will no longer be used, so remove them.

Next, adjust RemoveIncomingRiver and RemoveOutgoingRiver to directly work with flags. Also, we no longer have a reference to the cell's chunk because refreshing will be done by the grid. We'll instead invoke the simple Refresh method on the cells, which we'll change later.

	void RemoveIncomingRiver()
	{
		//if (!HasIncomingRiver) { … }
		if (Flags€.HasAny(HexFlags.RiverIn))
		{
			HexCell neighbor = GetNeighbor(Flags€.RiverInDirection());
			Flags€ = Flags€.Without(HexFlags.RiverIn);
			neighbor.Flags€ = neighbor.Flags€.Without(HexFlags.RiverOut);
			neighbor.Refresh();
			Refresh();
		}
	}
	
	void RemoveOutgoingRiver()
	{
		//if (!HasOutgoingRiver) { … }
		if (Flags€.HasAny(HexFlags.RiverOut))
		{
			HexCell neighbor = GetNeighbor(Flags€.RiverOutDirection());
			Flags = Flags.Without(HexFlags.RiverOut);
			neighbor.Flags = neighbor.Flags.Without(HexFlags.RiverIn);
			neighbor.Refresh();
			Refresh();
		}
	}

We need to know whether a river can flow from and cell to another in multiple places, so let's add a private static CanRiverFlow method to check this, given the values of two cells.

	static bool CanRiverFlow (HexValues from, HexValues to) =>
		from.Elevation >= to.Elevation || from.WaterLevel == to.Elevation;

Adjust SetOutgoingRiver to use that method and also directly work with values.

	public void SetOutgoingRiver (HexDirection direction)
	{
		if (Flags.HasRiverOut(direction))
		{
			return;
		}

		HexCell neighbor = GetNeighbor(direction);
		if (!CanRiverFlow(Values, neighbor.Values))
		{
			return;
		}

		RemoveOutgoingRiver();
		if (Flags.HasRiverIn(direction))
		{
			RemoveIncomingRiver();
		}

		Flags€ = Flags€.WithRiverOut(direction);
		//SpecialIndex = 0;
		Values = Values.WithSpecialIndex(0);
		neighbor.RemoveIncomingRiver();
		neighbor.Flags = neighbor.Flags.WithRiverIn(direction.Opposite());
		//neighbor.SpecialIndex = 0;
		neighbor.Values = neighbor.Values.WithSpecialIndex(0);

		RemoveRoad(direction);
	}

Adapt AddRoad, RemoveRoads, and RemoveRoad similarly.

	public void AddRoad(HexDirection direction)
	{
		HexFlags flags = Flags€;
		HexCell neighbor = GetNeighbor(direction);
		if (
			!flags.HasRoad(direction) && !flags.HasRiver(direction) &&
			Values.SpecialIndex == 0 && neighbor.Values.SpecialIndex == 0 &&
			Mathf.Abs(Values.Elevation - neighbor.Values.Elevation) <= 1
		)
		{
			Flags€ = flags.WithRoad(direction);
			neighbor.Flags€ = neighbor.Flags.WithRoad(direction.Opposite());
			neighbor.Refresh();
			Refresh();
		}
	}
	
	public void RemoveRoads()
	{
		HexFlags flags = Flags€;
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++)
		{
			if (flags.HasRoad(d))
			{
				RemoveRoad(d);
			}
		}
	}

	void RemoveRoad(HexDirection direction)
	{
		Flags€ = Flags.WithoutRoad(direction);
		HexCell neighbor = GetNeighbor(direction);
		neighbor.Flags = neighbor.Flags.WithoutRoad(direction.Opposite());
		neighbor.Refresh();
		Refresh();
	}

Adjust ValidateRivers to work with flags as well.

	void ValidateRivers()
	{
		HexFlags flags = Flags€;
		if (flags.HasAny(HexFlags.RiverOut) &&
			!CanRiverFlow(Values, GetNeighbor(flags.RiverOutDirection()).Values)
		)
		{
			RemoveOutgoingRiver();
		}
		if (flags.HasAny(HexFlags.RiverIn) &&
			!CanRiverFlow(GetNeighbor(flags.RiverInDirection()).Values, Values))
		{
			RemoveIncomingRiver();
		}
	}

From now on Refresh will simply forward to the grid's RefreshCell method.

	void Refresh() => grid.RefreshCell(Index);

Finally, remove RefreshPosition, RefreshAll, Save, Load, SetLabel, DisableHighlight, and EnableHighlight as that functionality is now part of the grid.

Map Editor

We have to make a few changes to HexMapEditor. First, instead of setting the cell's TerrainTypeIndex property in EditCell we now have to invoke the cell's SetTerrainTypeIndex method, passing it the active terrain type index.

				//cell.TerrainTypeIndex = activeTerrainTypeIndex;
				cell.SetTerrainTypeIndex(activeTerrainTypeIndex);

We have to make similar changes for all other editing cases.

Besides that, in UpdateCellHighlightData we still compare the cell with null to see whether it is an invalid edit target. We should change this to check whether the cell evaluates as false, like we do everywhere else.

		//if (cell == null)
		if (!cell)

Conversion to Struct

We are now finally going to eliminate the HexCell object. However, we keep its type, changing it to a struct type.

public struct HexCell

This will keep the current code that still relies on HexCell functional, providing a convenient facade for the data stored in the grid. The only data that we still need to store in the cell is its index and a reference to its grid. We introduce fields for these to make it clear what exactly gets stored.

#pragma warning disable IDE0044 // Add readonly modifier
	int index;

	HexGrid grid;
#pragma warning restore IDE0044 // Add readonly modifier

Give it a constructor method to initialize both.

	public HexCell(int index, HexGrid grid)
	{
		this.index = index;
		this.grid = grid;
	}

All of the cell's code that references the Index property can now directly access the index field instead. We do keep the public getter though, so the index is still accessible by other code. It now becomes a readonly property.

	public readonly int Index => index;

No code outside the cell needs to access its grid, so remove the Grid property. All cell code should directly access the grid field instead.

Now that the cell is no longer an object the implicit conversion to boolean has to change. Instead of checking whether the cell is null we check its grid instead. The idea is that an invalid cell matches the default struct value, which has no grid.

	public static implicit operator bool(HexCell cell) => cell.grid != null;

Finally, mark everything besides the two fields as readonly. The fields themselves cannot be readonly because of Unity's hot reloading limitation. For the same reason the values field in HexValues isn't readonly either.

Cell Equality

Some code relies on checking whether cells are equal. To keep that code working with cell structs we have to include custom == and != operators. Cells are equal if both their index and their grid are the same.

	public static bool operator ==(HexCell a, HexCell b) =>
		a.index == b.index && a.grid == b.grid;
	
	public static bool operator !=(HexCell a, HexCell b) =>
		a.index != b.index || a.grid != b.grid;

If we overload these operators the compiler insists that we also override the Equals method that has an object parameter, in case an equality check is done with boxed values.

	public readonly override bool Equals(object obj) =>
		obj is HexCell cell && this == cell;

We are also asked to override the GetHashCode method. We simply take the hashes of the index and grid and binary XOR them.

	public readonly override int GetHashCode() =>
		grid != null ? index.GetHashCode() ^ grid.GetHashCode() : 0;

Grid

Now that cells are no longer objects the grid doesn't need to keep track of them anymore, so remove its cells array.

	//HexCell[] cells;
	
	…
	
	void CreateCells()
	{
		//cells = new HexCell[CellCountZ * CellCountX];
		…
	}

We can no longer return null when a cell isn't found in the GetCell method with a Ray parameter. So we instead return the default struct value, which implicitly evaluates as false because it has no grid.

	public HexCell GetCell(Ray ray)
	{
		…
		return default;
	}

The same goes for the GetCell method with an HexCoordinates parameter. Also, instead of returning a cell from the array we return a new struct value with the cell index and a reference to the grid.

	public HexCell GetCell(HexCoordinates coordinates)
	{
		…
		if (z < 0 || z >= CellCountZ || x < 0 || x >= CellCountX)
		{
			return default;
		}
		return new HexCell(x + z * CellCountX, this);
	}

Adjust TryGetCell with HexCoordinates in the same way.

	public bool TryGetCell(HexCoordinates coordinates, out HexCell cell)
	{
		…
		if (z < 0 || z >= CellCountZ || x < 0 || x >= CellCountX)
		{
			cell = default;
			return false;
		}
		cell = new HexCell(x + z * CellCountX, this);
		return true;
	}

And GetCell with a cell index too.

	public HexCell GetCell(int cellIndex) => new(cellIndex, this);

CreateCell also has to work with a new struct value now.

		//var cell = cells[i] = new HexCell();
		var cell = new HexCell(i, this);

And so do both Search and GetVisibleCells.

			//HexCell current = cells[currentIndex];
			var current = new HexCell(currentIndex, this);

The HexCell object is now finally gone. All we have left is a small HexCell struct that is used in a few places.

license repository PDF