The CityGrid component manages the game's playfield, organizing tiles in a grid structure. It handles the procedural generation of the environment, including ground models, board visual layers, and road placement.
namespace CitySim;
/// <summary>
/// The playfield. Owns every tile, spawns the models for them and answers neighbour / path queries.
/// </summary>
public sealed class CityGrid : Component
{
/// <summary>World size of one cell. The old art is 200 units wide, scaled by <see cref="ModelScale"/>.</summary>
public const float CellSize = 50f;
public const float ModelScale = 0.25f;
public const float SurfaceHeight = 3.75f;
const float DropHeight = 25f;
const float DropDuration = 0.35f;
static readonly Color[] ShopTints =
{
new( 1f, 1f, 1f ), new( 1f, 0.9f, 0.75f ), new( 0.8f, 0.95f, 1f ), new( 1f, 0.85f, 0.9f ), new( 0.85f, 1f, 0.85f ),
};
// Up, Right, Down, Left - same ordering as the original game.
public static readonly Cell[] Directions =
{
new( 0, -1 ), new( 1, 0 ), new( 0, 1 ), new( -1, 0 ),
};
public int Width { get; private set; }
public int Height { get; private set; }
public Tile[,] Tiles { get; private set; }
public int EmptyCount { get; private set; }
public int TotalCount => Width * Height;
readonly List<Tile> dropping = new();
bool generating;
int placeCounter;
public bool IsBuilt => Tiles != null;
public int RoadRevision { get; private set; }
public void Build( int width, int height, int seed = 0, bool generateStreets = true )
{
Clear();
Width = width;
Height = height;
Tiles = new Tile[width, height];
BuildBoard();
var groundModel = GroundModel();
for ( int x = 0; x < width; x++ )
for ( int y = 0; y < height; y++ )
{
var tile = new Tile { X = x, Y = y, Center = CellToWorld( x, y ) };
// Quiet variation keeps the grid legible without a high-contrast checkerboard.
tile.GroundBaseTint = CardVisualVariation.Ground(null,x,y);
tile.Ground = new GameObject( true, $"Ground {x},{y}" );
tile.Ground.Parent = GameObject;
tile.Ground.WorldPosition = tile.Center;
tile.Ground.WorldScale = 1f;
var renderer = tile.Ground.Components.Create<ModelRenderer>();
renderer.Model = groundModel;
tile.GroundRenderer = renderer;
renderer.Tint = tile.GroundBaseTint;
Tiles[x, y] = tile;
}
EmptyCount = width * height;
generating = true;
placeCounter = 0;
if ( generateStreets )
GenerateStreets( new Random( seed ) );
generating = false;
}
/// <summary>A layered cardboard backing and printed dashes frame the playable cells.</summary>
void BuildBoard()
{
var width = Width * CellSize;
var height = Height * CellSize;
const float rim = 30f;
var layers = new Dictionary<Color, VertexBuffer>();
void Box( float x, float y, float z, float sx, float sy, float sz, Color color )
{
if ( !layers.TryGetValue( color, out var vertices ) )
{
vertices = new VertexBuffer(); vertices.Init( true ); layers[color] = vertices;
}
vertices.AddCube( new Vector3( x, y, z ), new Vector3( sx, sy, sz ), Rotation.Identity, Color.White );
}
var ink = new Color( 0.165f, 0.129f, 0.094f );
var core = new Color( 0.78f, 0.60f, 0.37f );
var paper = new Color( 0.95f, 0.90f, 0.77f );
// Each layer is a single rectangle, so the board also fills gaps between tiles.
Box( 0, 0, -9f, width + rim * 2 + 4, height + rim * 2 + 4, 2, ink );
Box( 0, 0, -5f, width + rim * 2, height + rim * 2, 6, core );
Box( 0, 0, -1.5f, width + rim * 2, height + rim * 2, 1, paper );
// Center each run independently so the corners stay tidy at any board size.
void Dashes( float length, float offset, bool horizontal )
{
var count = Math.Max( 1, (int)(length / 24f) );
var spacing = length / count;
for ( var i = 0; i < count; i++ )
{
var along = -length * 0.5f + (i + 0.5f) * spacing;
if ( horizontal ) Box( along, offset, -0.85f, spacing * 0.55f, 2.5f, 0.3f, ink );
else Box( offset, along, -0.85f, 2.5f, spacing * 0.55f, 0.3f, ink );
}
}
Dashes( width, height * 0.5f + rim * 0.5f, true );
Dashes( width, -height * 0.5f - rim * 0.5f, true );
Dashes( height, width * 0.5f + rim * 0.5f, false );
Dashes( height, -width * 0.5f - rim * 0.5f, false );
var board = new GameObject( true, "Cardboard board" ) { Parent = GameObject };
board.WorldPosition = WorldPosition;
foreach ( var layer in layers )
{
var mesh = new Mesh( Material.Load( "materials/board/board_flat.vmat" ) );
mesh.CreateBuffers( layer.Value );
var part = new GameObject( true, "Board layer" ) { Parent = board };
part.LocalPosition = Vector3.Zero;
var renderer = part.Components.Create<ModelRenderer>();
renderer.Model = new ModelBuilder().AddMesh( mesh ).Create();
renderer.Tint = layer.Key;
}
}
static Model groundTileModel;
static Model GroundModel()
{
if ( groundTileModel != null ) return groundTileModel;
var vertices = new VertexBuffer(); vertices.Init( true );
vertices.AddCube( Vector3.Up * (SurfaceHeight * 0.5f),
new Vector3( CellSize - 0.6f, CellSize - 0.6f, SurfaceHeight ), Rotation.Identity, Color.White );
var mesh = new Mesh( Material.Load( "materials/board/board_flat.vmat" ) );
mesh.CreateBuffers( vertices );
return groundTileModel = new ModelBuilder().AddMesh( mesh ).Create();
}
public void Clear()
{
RoadRevision++;
foreach ( var child in GameObject.Children.ToList() )
child.Destroy();
Tiles = null;
dropping.Clear();
EmptyCount = 0;
}
/// <summary>
/// Lay a couple of random roads across the map so there is always somewhere to start.
/// </summary>
void GenerateStreets( Random rng )
{
var rows = rng.Next( 1, 5 );
var cols = rng.Next( 1, 5 );
for ( int i = 0; i < rows; i++ )
{
var y = rng.Next( 0, Height );
for ( int x = 0; x < Width; x++ )
Place( x, y, new Piece { Type = TileType.Road }, animate: false );
}
for ( int i = 0; i < cols; i++ )
{
var x = rng.Next( 0, Width );
for ( int y = 0; y < Height; y++ )
Place( x, y, new Piece { Type = TileType.Road }, animate: false );
}
}
public Vector3 CellToWorld( int x, int y )
{
var origin = WorldPosition - new Vector3( (Width - 1) * 0.5f, (Height - 1) * 0.5f, 0 ) * CellSize;
return origin + new Vector3( x * CellSize, y * CellSize, 0 );
}
public Vector3 Size => new( Width * CellSize, Height * CellSize, 0 );
public Tile Get( int x, int y )
{
if ( Tiles == null || x < 0 || y < 0 || x >= Width || y >= Height )
return null;
return Tiles[x, y];
}
public Tile Neighbour( Tile tile, int direction )
{
var d = Directions[direction];
return Get( tile.X + d.X, tile.Y + d.Y );
}
/// <summary>
/// Find which tile a world position falls inside, or null if it is off the map.
/// </summary>
public Tile WorldToTile( Vector3 world )
{
var local = world - CellToWorld( 0, 0 ) + new Vector3( CellSize, CellSize, 0 ) * 0.5f;
return Get( (local.x / CellSize).FloorToInt(), (local.y / CellSize).FloorToInt() );
}
// ---------------------------------------------------------------- placement
public bool CanPlace( Tile tile, Piece piece )
{
if ( tile == null || piece.IsEmpty || tile.Type != TileType.Empty )
return false;
if ( TileRules.NeedsRoad( piece.Type ) )
return RoadNeighbourDirection( tile ) >= 0;
return true;
}
public bool Place( int x, int y, Piece piece, bool animate = true, bool legacyRules = true )
{
var tile = Get( x, y );
if ( legacyRules ? !CanPlace( tile, piece ) : tile == null || piece.IsEmpty || tile.Type != TileType.Empty )
return false;
tile.Type = piece.Type;
if (piece.Type == TileType.Road) RoadRevision++;
tile.Piece = piece;
tile.ByPlayer = !generating;
tile.Order = ++placeCounter;
EmptyCount--;
SpawnBuilding( tile, animate );
// Roads next to us need to re-shape themselves.
for ( int i = 0; i < 4; i++ )
{
var n = Neighbour( tile, i );
if ( n != null && n.IsRoad )
RefreshRoad( n );
}
return true;
}
internal bool ClearBuilding(int x,int y)
{
var tile=Get(x,y); if (tile == null || tile.Type==TileType.Empty) return false;
if (tile.IsRoad) RoadRevision++;
dropping.Remove(tile);
if (tile.Building.IsValid()) tile.Building.Destroy();
tile.Building=null; tile.BuildingRenderer=null; tile.Type=TileType.Empty; tile.Piece=default;
tile.CardId=null; tile.Owner=Guid.Empty; tile.Order=0; tile.ByPlayer=false; tile.BaseTint=Color.White; tile.DropTime=0;
tile.GroundBaseTint=CardVisualVariation.Ground(null,x,y);
if (tile.GroundRenderer.IsValid()) tile.GroundRenderer.Tint=tile.GroundBaseTint;
if (tile.Ground.IsValid()) tile.Ground.Enabled=true;
EmptyCount++; return true;
}
void SpawnBuilding( Tile tile, bool animate )
{
var piece = tile.Piece;
tile.Building = new GameObject( true, $"{piece.Type} {tile.X},{tile.Y}" );
tile.Building.Parent = GameObject;
tile.Building.WorldScale = ModelScale;
var renderer = tile.Building.Components.Create<ModelRenderer>();
tile.BuildingRenderer = renderer;
switch ( piece.Type )
{
case TileType.Road:
RefreshRoad( tile );
break;
case TileType.House:
renderer.Model = Model.Load( TileRules.House );
renderer.SetBodyGroup( "base", piece.BodyGroup );
ApplyMaterialGroup( renderer, piece.MaterialIndex );
FaceRoad( tile );
break;
case TileType.Business:
renderer.Model = Model.Load( TileRules.Shop );
renderer.SetBodyGroup( "base", piece.BodyGroup );
tile.BaseTint = ShopTints[piece.MaterialIndex % ShopTints.Length];
renderer.Tint = tile.BaseTint;
FaceRoad( tile );
break;
case TileType.Park:
renderer.Model = Model.Load( TileRules.Forest );
tile.Building.WorldRotation = Rotation.Identity;
break;
}
// Roads and parks replace the ground tile entirely.
if ( piece.Type == TileType.Road || piece.Type == TileType.Park )
tile.Ground.Enabled = false;
if ( animate )
{
tile.DropTime = DropDuration;
tile.Building.WorldPosition = tile.Center + Vector3.Up * DropHeight;
dropping.Add( tile );
}
else
{
tile.Building.WorldPosition = tile.Center;
}
}
public static void ApplyMaterialGroup( ModelRenderer renderer, int index )
{
var model = renderer.Model;
if ( model == null || model.MaterialGroupCount == 0 )
return;
renderer.MaterialGroup = model.GetMaterialGroupName( index % model.MaterialGroupCount );
}
protected override void OnUpdate()
{
for ( int i = dropping.Count - 1; i >= 0; i-- )
{
var tile = dropping[i];
if ( tile.Building == null || !tile.Building.IsValid() )
{
dropping.RemoveAt( i );
continue;
}
tile.DropTime -= Time.Delta;
var t = 1f - MathX.Clamp( tile.DropTime / DropDuration, 0f, 1f );
tile.Building.WorldPosition = tile.Center + Vector3.Up * (DropHeight * ((1f - t) * (1f - t)));
if ( tile.DropTime <= 0 )
{
tile.Building.WorldPosition = tile.Center;
dropping.RemoveAt( i );
}
}
}
// ---------------------------------------------------------------- neighbours
/// <summary>
/// Direction index (see <see cref="Directions"/>) of the first road next to this tile, or -1.
/// </summary>
public int RoadNeighbourDirection( Tile tile )
{
for ( int i = 0; i < 4; i++ )
{
if ( Neighbour( tile, i )?.IsRoad == true )
return i;
}
return -1;
}
public bool IsNextToRoad( Tile tile ) => RoadNeighbourDirection( tile ) >= 0;
public static float YawForDirection( int direction ) => direction switch
{
0 => 270f,
1 => 0f,
2 => 90f,
_ => 180f,
};
void FaceRoad( Tile tile )
{
var dir = RoadNeighbourDirection( tile );
if ( dir >= 0 )
tile.Building.WorldRotation = Rotation.FromYaw( YawForDirection( dir ) );
}
// ---------------------------------------------------------------- roads
/// <summary>
/// Works out which road model and rotation a tile at this position needs, based on its neighbours.
/// Off-map neighbours count as connected so streets run off the edge of the map.
/// </summary>
public (string model, float yaw) RoadShape( int x, int y )
{
bool Connected( int dx, int dy )
{
var n = Get( x + dx, y + dy );
return n == null || n.IsRoad;
}
var up = Connected( 0, -1 );
var right = Connected( 1, 0 );
var down = Connected( 0, 1 );
var left = Connected( -1, 0 );
var count = (up ? 1 : 0) + (right ? 1 : 0) + (down ? 1 : 0) + (left ? 1 : 0);
float yaw = 0;
switch ( count )
{
case 3:
if ( !up ) yaw = 180;
else if ( !right ) yaw = 270;
else if ( !down ) yaw = 0;
else yaw = 90;
return ("models/kenney/roads/road-intersection.vmdl", yaw - 90);
case 2:
if ( left && right )
return ("models/kenney/roads/road-straight.vmdl", 90);
if ( up && down )
return ("models/kenney/roads/road-straight.vmdl", 0);
if ( up ) yaw = left ? 180 : 270;
if ( down ) yaw = left ? 90 : 0;
return ("models/kenney/roads/road-bend.vmdl", yaw + 90);
case 1:
if ( up ) yaw = 0;
else if ( right ) yaw = 90;
else if ( down ) yaw = 180;
else yaw = 270;
return ("models/kenney/roads/road-end.vmdl", yaw + 180);
default:
return ("models/kenney/roads/road-crossroad.vmdl", 0);
}
}
internal void RefreshRoad( Tile tile )
{
if ( tile.BuildingRenderer == null )
return;
var (model, yaw) = RoadShape( tile.X, tile.Y );
if (tile.CardId == "avenue" && model.EndsWith("road-crossroad.vmdl")) model = "models/kenney/roads/road-crossroad-line.vmdl";
tile.BuildingRenderer.Enabled = false;
CardVisuals.BuildRoad(tile.Building,model,tile.CardId == "roundabout");
tile.BaseTint = tile.CardId == "avenue" ? new Color(0.8f,0.85f,1f) : Color.White;
CardVisuals.Tint(tile.Building,tile.BaseTint);
tile.Building.WorldRotation = Rotation.FromYaw( yaw );
}
// ---------------------------------------------------------------- pathfinding
/// <summary>
/// Breadth first search across roads from one tile to another. Returns the road tiles to drive over, or null.
/// </summary>
public List<Tile> FindRoadPath( Tile from, Tile to )
{
if ( from == null || to == null || !from.IsRoad || !to.IsRoad )
return null;
var previous = new Dictionary<Tile, Tile> { [from] = null };
var queue = new Queue<Tile>();
queue.Enqueue( from );
while ( queue.Count > 0 )
{
var current = queue.Dequeue();
if ( current == to )
break;
for ( int i = 0; i < 4; i++ )
{
var n = Neighbour( current, i );
if ( n == null || !n.IsRoad || previous.ContainsKey( n ) )
continue;
previous[n] = current;
queue.Enqueue( n );
}
}
if ( !previous.ContainsKey( to ) )
return null;
var path = new List<Tile>();
for ( var t = to; t != null; t = previous[t] )
path.Add( t );
path.Reverse();
return path;
}
/// <summary>
/// Road tiles touching the given tile.
/// </summary>
public IEnumerable<Tile> AdjacentRoads( Tile tile )
{
for ( int i = 0; i < 4; i++ )
{
var n = Neighbour( tile, i );
if ( n != null && n.IsRoad )
yield return n;
}
}
public IEnumerable<Tile> All()
{
if ( Tiles == null )
yield break;
for ( int x = 0; x < Width; x++ )
for ( int y = 0; y < Height; y++ )
yield return Tiles[x, y];
}
public IEnumerable<Tile> EdgeRoads()
{
return All().Where( t => t.IsRoad && (t.X == 0 || t.Y == 0 || t.X == Width - 1 || t.Y == Height - 1) );
}
}