Custom topology, orientation, and layout
Use case
Use these contracts when the game needs a coordinate family or spatial mapping that the built-in square, hex, and triangle implementations cannot represent.
Contract and ownership
An IGridTopology defines logical coordinates, orientations, neighbors, and footprint behavior. Keep that logical model separate from where the board appears in Unity.
Choose one spatial path:
- Recommended: also implement IGridTopologyGeometry, then reuse GridTopologyLayout for cell shapes, structure poses, and hit conversion.
- Advanced: implement IGridLayout only when the topology's normal geometry cannot express the presentation you need.
An IGridLayoutProvider creates the chosen layout for a specific state and cell size.
Coordinates and orientations are immutable logical values with stable system IDs. A layout may keep a private cache for one binding, but it must not mutate board state.
Implementation
Recommended: topology geometry with the reusable layout
The main example implements a one-dimensional line as one coherent logical family:
LineGridCoordinatestores one immutable integer component;LineGridCoordinateSystemnormalizes and creates those values;LineGridTopologyexposes forward/backward neighbors and a two-step orientation system;- topology geometry projects each index onto local X;
LineGridLayoutProvidercreatesGridTopologyLayoutfor matching boards.
Excerpt — define one immutable coordinate value. Tested using public APIs.
[Serializable]
public sealed class LineGridCoordinate : GridCoordinate
{
public const string CoordinateSystemIdValue = "game.line.coordinates";
public LineGridCoordinate(int index)
{
Index = index;
}
public int Index { get; }
public override string CoordinateSystemId => CoordinateSystemIdValue;
public override int ComponentCount => 1;
public override int GetComponent(int index) => index == 0
? Index
: throw new ArgumentOutOfRangeException(nameof(index));
}
Excerpt — normalize and construct that coordinate family. Tested using public APIs.
public sealed class LineGridCoordinateSystem : GridCoordinateSystem
{
public LineGridCoordinateSystem()
: base(LineGridCoordinate.CoordinateSystemIdValue)
{
}
public override GridCoordinate Origin { get; } = new LineGridCoordinate(0);
public override GridCoordinate Normalize(GridCoordinate coordinate)
{
if (coordinate is LineGridCoordinate)
return coordinate;
GridCoordinate normalized = base.Normalize(coordinate);
return CreateCoordinate(normalized.ToArray());
}
public override GridCoordinate CreateCoordinate(params int[] components)
{
if (components == null || components.Length != 1)
throw new ArgumentException("Line coordinates require one component.", nameof(components));
return new LineGridCoordinate(components[0]);
}
}
Excerpt — define the logical directions. Tested using public APIs.
public sealed class LineGridDirection : GridDirection
{
public static LineGridDirection Forward { get; } =
new LineGridDirection("forward", "Forward", new LineGridCoordinate(1));
public static LineGridDirection Backward { get; } =
new LineGridDirection("backward", "Backward", new LineGridCoordinate(-1));
private LineGridDirection(string id, string name, LineGridCoordinate offset)
: base(id, name, offset)
{
}
}
Excerpt — connect identity and orientation. Tested using public APIs.
private LineGridTopology()
{
OrientationSystem = new StepGridOrientationSystem(
"game.line.orientation",
2,
(offset, step) =>
{
LineGridCoordinate line =
(LineGridCoordinate)Coordinates.Normalize(offset);
return step == 0
? line
: new LineGridCoordinate(-line.Index);
});
}
public string CellShapeId => "line-cell";
public GridCoordinateSystem CoordinateSystem => Coordinates;
public string TopologyId => "game.line";
public IGridOrientationSystem OrientationSystem { get; }
public IReadOnlyList<GridDirection> Directions => DirectionList;
Excerpt — provide neighbors and cell geometry. Tested using public APIs.
public IEnumerable<GridCoordinate> GetNeighbors(GridCoordinate coordinate)
{
GridCoordinate normalized = Coordinates.Normalize(coordinate);
yield return Coordinates.Add(normalized, LineGridDirection.Forward.Offset);
yield return Coordinates.Add(normalized, LineGridDirection.Backward.Offset);
}
public Vector2 GetLocalPosition(GridCoordinate coordinate, float cellSize)
{
LineGridCoordinate line =
(LineGridCoordinate)Coordinates.Normalize(coordinate);
return new Vector2(line.Index * cellSize, 0f);
}
public IReadOnlyList<Vector2> GetCellCorners(
GridCoordinate coordinate,
float cellSize)
{
float half = cellSize * 0.5f;
return Array.AsReadOnly(new[]
{
new Vector2(-half, -half),
new Vector2(half, -half),
new Vector2(half, half),
new Vector2(-half, half)
});
}
Excerpt — reuse the topology layout. Tested using public APIs.
public sealed class LineGridLayoutProvider : IGridLayoutProvider
{
public bool TryCreateLayout(
GridBoardState state,
float cellSize,
out IGridLayout layout)
{
if (state == null || state.Definition.Topology is not LineGridTopology)
{
layout = null;
return false;
}
layout = new GridTopologyLayout(state.Definition.Topology, cellSize);
return true;
}
}
Build in that order for larger families: coordinate identity first, then orientation and logical
neighbors, then footprint resolution and spatial geometry. GridTopologyLayout derives cell
geometry, structure poses, and hits from that public geometry contract. Do not encode world
positions into coordinate equality.
Advanced: a custom layout
Implement IGridLayout when spatial presentation deliberately differs from the topology's normal
geometry. This example maps the same line coordinates along local Z, supplies its own structure
poses, and converts local hit points back to coordinates:
Excerpt — map cells to nonstandard geometry. Tested using public APIs.
public OffsetLineGridLayout(float cellSize)
{
if (cellSize <= 0f || float.IsNaN(cellSize) || float.IsInfinity(cellSize))
throw new ArgumentOutOfRangeException(nameof(cellSize));
this.cellSize = cellSize;
}
public bool TryGetCellGeometry(
GridCoordinate coordinate,
out GridCellGeometry geometry)
{
if (coordinate is not LineGridCoordinate line)
{
geometry = default;
return false;
}
float half = cellSize * 0.5f;
Vector3 center = new(0f, 0f, line.Index * cellSize);
geometry = new GridCellGeometry(line, center, new[]
{
center + new Vector3(-half, 0f, -half),
center + new Vector3(half, 0f, -half),
center + new Vector3(half, 0f, half),
center + new Vector3(-half, 0f, half)
});
return true;
}
Excerpt — derive a structure pose from occupied cells. Tested using public APIs.
public GridPose GetStructurePose(
IGridStructureView structure,
GridStructureVisualAnchorMode anchorMode)
{
if (structure == null)
throw new ArgumentNullException(nameof(structure));
GridCoordinate[] coordinates = anchorMode == GridStructureVisualAnchorMode.Anchor
? new[] { structure.Anchor }
: structure.OccupiedCells.ToArray();
if (coordinates.Length == 0 ||
coordinates.Any(coordinate => coordinate is not LineGridCoordinate))
{
throw new InvalidOperationException(
"The structure must occupy line coordinates.");
}
float averageIndex = (float)coordinates
.Cast<LineGridCoordinate>()
.Average(coordinate => coordinate.Index);
Quaternion rotation = Quaternion.Euler(
0f,
structure.Orientation.PrimaryStep % 2 == 0 ? 0f : 180f,
0f);
return new GridPose(
new Vector3(0f, 0f, averageIndex * cellSize),
rotation);
}
Excerpt — map a local point back to a cell. Tested using public APIs.
public bool TryHitCell(
GridBoardState state,
Vector3 localPoint,
out GridHit hit)
{
hit = GridHit.None(localPoint);
if (state == null || Mathf.Abs(localPoint.x) > cellSize * 0.5f)
return false;
LineGridCoordinate coordinate = new(
Mathf.RoundToInt(localPoint.z / cellSize));
if (!state.ContainsCell(coordinate) ||
!TryGetCellGeometry(coordinate, out GridCellGeometry geometry) ||
Mathf.Abs(localPoint.z - geometry.LocalCenter.z) > cellSize * 0.5f)
{
return false;
}
hit = new GridHit(GridHitKind.Cell, coordinate, string.Empty, localPoint);
return true;
}
Return this layout from an IGridLayoutProvider registered or assigned by the project. Keep the
logical topology unchanged so actions, selections, snapshots, and occupancy do not depend on the
chosen presentation.
Registration
Code-owned boards pass the topology to GridBoardDefinitionBuilder. Authored topologies additionally need a board-data asset, coordinate authoring data, Editor adapter, and preview layout provider in Editor-only assemblies. The runtime topology must not depend on those Editor integrations.
Verification
Reject foreign coordinates, invalid orientations, duplicate footprint results, unsupported geometry, and ambiguous hits with the topology/system IDs in the message.
Tests
Test coordinate equality/hash/normalization, direction inverses, orientation closure, footprint rotation, snapshot round trips, geometry winding, and hit/pose consistency. The line example also checks that flipping offsets reverses their sign and that a hit at a rendered center returns the same logical coordinate.
Related
- Coordinates, topology, orientation, and layout
- Authoring and editor adapters
- Cubic Grid Construction sample
- IGridTopology
- GridCoordinateSystem
- IGridOrientationSystem
- IGridTopologyGeometry
- IGridLayout
- IGridLayoutProvider
Done when
Coordinates normalize deterministically, neighbor and orientation behavior is consistent, footprints resolve under every supported orientation, and a renderer resolves poses and hits through the registered layout.