Tiles/BoardView.cs

The BoardView component renders a city match state onto a grid. It manages the visual presentation of tiles, including buildings, ownership frames, and highlighting.

using CitySim.Cards;
using CitySim.Match;

namespace CitySim;

/// <summary>
/// Additive scene projection of a match. Rules validate first; this view only renders results.
/// Rebinding rebuilds the view from model cells and detaches the previous match's notifications.
/// </summary>
public sealed class BoardView : Component
{
	MatchState match;
	CityGrid grid;
	readonly List<(Tile Tile,float From,float To,float Age)> pops = new();
	readonly List<GameObject> markers = new();
	readonly Dictionary<(int X,int Y),GameObject> ownershipFrames = new();
	static readonly Dictionary<int,Model> ownershipModels = new();
	Tag highlighted;
	Guid highlightOwner;
	Guid? playerHighlight;
	IReadOnlyList<InteractionTarget> interactionTargets;
	readonly List<BoardCell> highlightedCells = new();
	readonly Dictionary<(int X,int Y),(GameObject Object,float Age,bool Retiring)> outlines = new();
	static Model outlineModel;
	IReadOnlyList<PlacementInteraction> placementInteractions = Array.Empty<PlacementInteraction>();
	public IReadOnlyList<BoardCell> HighlightedCells => highlightedCells;
	public MatchState Match => match;

	public void Bind( MatchState state, CityGrid target )
	{
		if ( state == null || !target.IsValid() ) throw new System.ArgumentException("A match and grid are required.");
		Unbind();
		match = state;
		grid = target;
		grid.Build(state.Width,state.Height,state.Seed,generateStreets:false);
		foreach ( var cell in state.Cells().OrderBy(c => c.Order) ) Render(cell,false);
		match.Events.CardPlaced += OnPlaced;
		match.Events.BuildingReplaced += Replaced;
		match.Events.CellUpdated += Updated;
		match.Events.ParkScored += PopPark;
		match.Events.ClaimsChanged += Claims;
		Claims();
	}

	public void Unbind()
	{
		if ( match != null ) { match.Events.CardPlaced -= OnPlaced; match.Events.BuildingReplaced -= Replaced; match.Events.CellUpdated -= Updated; match.Events.ParkScored -= PopPark; match.Events.ClaimsChanged -= Claims; }
		pops.Clear(); ClearOutlines(); foreach (var frame in ownershipFrames.Values) if (frame.IsValid()) frame.Destroy(); ownershipFrames.Clear(); highlightedCells.Clear(); foreach (var marker in markers) if (marker.IsValid()) marker.Destroy(); markers.Clear();
		highlighted = Tag.None; interactionTargets = null; playerHighlight = null; placementInteractions = Array.Empty<PlacementInteraction>();
		match = null;
		grid = null;
	}
	protected override void OnDestroy() => Unbind();
	void OnPlaced( BoardCell cell ) { Render(cell,true); if (highlighted != Tag.None || interactionTargets != null || playerHighlight != null) RefreshHighlight(); }
	void Replaced(BoardReplacement change)
	{
		var tile=grid?.Get(change.Before.X,change.Before.Y);
		if (tile == null) return;
		pops.RemoveAll(p => p.Tile==tile); grid.ClearBuilding(tile.X,tile.Y);
		if (ownershipFrames.Remove((tile.X,tile.Y),out var frame) && frame.IsValid()) frame.Destroy();
		if (highlighted != Tag.None || interactionTargets != null || playerHighlight != null) RefreshHighlight();
	}
	static float Scale(BoardCell cell) => cell.CardId == "sapling" && !Growth.IsMature(cell) ? 0.55f : 1f;
	void Updated(BoardCell cell)
	{
		var tile = grid?.Get(cell.X,cell.Y);
		if (tile?.Building == null || !tile.Building.IsValid()) return;
		if (cell.CardId != "sapling") return;
		var target = Scale(cell);
		CardVisuals.Build(tile.Building,cell.CardId,Growth.IsMature(cell),CardVisualVariation.Seed(cell.CardId,cell.X,cell.Y)); Tint(cell);
		tile.Building.Name = $"{(cell.CardId == "sapling" && Growth.IsMature(cell) ? "Forest" : CardLibrary.Get(cell.CardId).Name)} {cell.X},{cell.Y}";
		if (MathF.Abs(tile.Building.WorldScale.z/CityGrid.ModelScale-target) > 0.01f) { pops.RemoveAll(p => p.Tile == tile); pops.Add((tile,tile.Building.WorldScale.z/CityGrid.ModelScale,target,0)); }
	}
	void PopPark(ParkScoreResult park)
	{
		foreach (var cell in park.Cells)
		{
			var tile = grid.Get(cell.X,cell.Y); if (tile?.Building == null) continue;
			var target = Scale(cell); pops.RemoveAll(p => p.Tile == tile);
			pops.Add((tile,cell.CardId == "sapling" && Growth.IsMature(cell) ? tile.Building.WorldScale.z/CityGrid.ModelScale : target*0.8f,target,0));
		}
	}
	void Claims()
	{
		foreach (var marker in markers) if (marker.IsValid()) marker.Destroy(); markers.Clear();
		foreach (var claim in match.Claims)
		{
			var marker = new GameObject(true,$"Reserved {claim.X},{claim.Y}") { Parent = GameObject };
			marker.WorldPosition = grid.CellToWorld(claim.X,claim.Y)+Vector3.Up*1; marker.WorldScale = CityGrid.ModelScale;
			var renderer = marker.Components.Create<ModelRenderer>(); renderer.Model = Model.Load(TileRules.Ghost);
			renderer.Tint = claim.Owner == CityGame.Me ? new Color(0.4f,0.8f,1) : new Color(1,0.6f,0.3f); markers.Add(marker);
		}
	}
	public void Highlight(Tag tag,Guid owner)
	{
		highlighted = tag; highlightOwner = owner; interactionTargets = null;
		if (match == null || !grid.IsValid()) return;
		RefreshHighlight();
	}
	public void HighlightInteractions(IReadOnlyList<InteractionTarget> targets,Guid viewer)
	{
		highlighted = Tag.None; highlightOwner = viewer; interactionTargets = targets;
		if (match == null || !grid.IsValid()) return;
		RefreshHighlight();
	}
	public void HighlightPlayer(Guid? owner)
	{
		playerHighlight = owner;
		if (match != null && grid.IsValid()) RefreshHighlight();
	}
	public void PreviewInteractions(IReadOnlyList<PlacementInteraction> interactions)
	{
		placementInteractions = interactions ?? Array.Empty<PlacementInteraction>();
		if (match != null && grid.IsValid()) RefreshHighlight();
	}
	void RefreshHighlight()
	{
		highlightedCells.Clear();
		for (var x = 0; x < grid.Width; x++)
		for (var y = 0; y < grid.Height; y++)
		{
			var cell=match.Get(x,y);
			if (Matches(cell) || placementInteractions.Any(i => i.Cell.X == x && i.Cell.Y == y)) highlightedCells.Add(cell);
		}
		for (var x = 0; x < grid.Width; x++)
		for (var y = 0; y < grid.Height; y++) TintTile(grid.Get(x,y),match.Get(x,y));
		var wanted=highlightedCells.Select(c => (c.X,c.Y)).ToHashSet();
		foreach (var key in outlines.Keys.Where(key => !wanted.Contains(key)).ToArray())
		{
			var item=outlines[key];
			if (!item.Retiring) outlines[key]=(item.Object,0,true);
		}
		foreach (var cell in highlightedCells)
		{
			var key=(cell.X,cell.Y);
			if (outlines.TryGetValue(key,out var existing)) { if (existing.Retiring) outlines[key]=(existing.Object,0,false); TintOutline(existing.Object,cell); continue; }
			var outline=new GameObject(true,$"Highlight {cell.X},{cell.Y}") { Parent=GameObject };
			outline.WorldPosition=grid.CellToWorld(cell.X,cell.Y)+Vector3.Up*(CityGrid.SurfaceHeight+1.5f);
			outline.WorldScale=new Vector3(0.8f,0.8f,1);
			var renderer=outline.Components.Create<ModelRenderer>(); renderer.Model=OutlineModel(); TintOutline(outline,cell);
			outlines[key]=(outline,0,false);
			// A small visual-only pop, without interrupting an existing placement or growth animation.
			var tile=grid.Get(cell.X,cell.Y);
			if (placementInteractions.Count == 0 && !PlayerPreferences.Current.ReduceShake && tile?.Building.IsValid() == true && tile.DropTime <= 0 && !pops.Any(p => p.Tile == tile))
			{
				var rest=Scale(cell); pops.Add((tile,rest*1.04f,rest,0));
			}
		}
	}

	void TintOutline(GameObject outline,BoardCell cell)
	{
		var interaction=placementInteractions.FirstOrDefault(i => i.Cell.X==cell.X && i.Cell.Y==cell.Y);
		var renderer=outline.Components.Get<ModelRenderer>();
		if (renderer != null) renderer.Tint=interaction == null ? Color.White : interaction.Positive && interaction.Negative ? new Color(1f,0.8f,0.45f) : interaction.Negative ? new Color(1f,0.35f,0.35f) : new Color(0.5f,1f,0.55f);
	}
	internal static Model OutlineModel()
	{
		if (outlineModel != null) return outlineModel;
		var v=new VertexBuffer(); v.Init(true);
		// A small printed cardboard frame: an ink outline beneath a mustard inlay.
		void Frame(float half,float width,float z,Color color)
		{
			var length=half*2+width;
			v.AddCube(new Vector3(half,0,z),new Vector3(width,length,0.65f),Rotation.Identity,color);
			v.AddCube(new Vector3(-half,0,z),new Vector3(width,length,0.65f),Rotation.Identity,color);
			v.AddCube(new Vector3(0,half,z),new Vector3(length,width,0.65f),Rotation.Identity,color);
			v.AddCube(new Vector3(0,-half,z),new Vector3(length,width,0.65f),Rotation.Identity,color);
		}
		Frame(CityGrid.CellSize*0.46f,3.2f,0,new Color(0.165f,0.129f,0.094f)); // ink #2a2118
		Frame(CityGrid.CellSize*0.46f,1.5f,0.7f,new Color(0.941f,0.722f,0.227f)); // accent #f0b83a
		var mesh=new Mesh(Material.Load("materials/simple/simple_grey_50.vmat")); mesh.CreateBuffers(v);
		return outlineModel=new ModelBuilder().AddMesh(mesh).Create();
	}
	void ClearOutlines() { foreach (var item in outlines.Values) if (item.Object.IsValid()) item.Object.Destroy(); outlines.Clear(); }
	bool Matches(BoardCell cell) => cell != null && (playerHighlight.HasValue ? cell.Owner == playerHighlight.Value && CardLibrary.Get(cell.CardId).Type != CardType.Road : interactionTargets != null
		? interactionTargets.Any(t => InteractionMatcher.Matches(t,cell,highlightOwner))
		: highlighted != Tag.None && cell.Owner == highlightOwner && CardLibrary.Get(cell.CardId).Tags.HasFlag(highlighted));
	static Color Darken(Color color) => new(color.r*0.78f,color.g*0.78f,color.b*0.78f,color.a);
	void Tint(BoardCell cell) => TintTile(grid.Get(cell.X,cell.Y),cell);
	void TintTile(Tile tile,BoardCell cell)
	{
		if (tile == null) return;
		tile.GroundBaseTint = CardVisualVariation.Ground(cell?.CardId,tile.X,tile.Y,cell != null && Growth.IsMature(cell));
		var dim = placementInteractions.Count == 0 && highlightedCells.Count > 0 && !Matches(cell);
		if (tile.GroundRenderer.IsValid()) tile.GroundRenderer.Tint = dim ? Darken(tile.GroundBaseTint) : tile.GroundBaseTint;
		if (!tile.BuildingRenderer.IsValid()) return;
		var tint = dim ? Darken(tile.BaseTint) : tile.BaseTint;
		tile.BuildingRenderer.Tint = tint;
		CardVisuals.Tint(tile.Building,tint);
	}
	protected override void OnUpdate()
	{
		foreach (var key in outlines.Keys.ToArray())
		{
			var item=outlines[key]; if (!item.Object.IsValid()) { outlines.Remove(key); continue; }
			var duration=item.Retiring ? 0.14f : 0.22f;
			var age=Math.Min(duration,item.Age+Time.Delta); var t=age/duration; var ease=1-MathF.Pow(1-t,3);
			if (item.Retiring && t >= 1) { item.Object.Destroy(); outlines.Remove(key); continue; }
			var scale=item.Retiring ? 1-0.2f*ease : 0.8f+0.2f*ease;
			item.Object.WorldScale=new Vector3(scale,scale,1); outlines[key]=(item.Object,age,item.Retiring);
		}
		for (var i = pops.Count-1; i >= 0; i--)
		{
			var p = pops[i]; if (!p.Tile.Building.IsValid()) { pops.RemoveAt(i); continue; }
			p.Age += Time.Delta; var t = Math.Clamp(p.Age/0.4f,0,1); var s = t-1; var ease = 1+2.70158f*s*s*s+1.70158f*s*s;
			var z = p.From+(p.To-p.From)*ease; var xy = p.To+(p.To-z)*0.4f;
			p.Tile.Building.WorldScale = new Vector3(xy,xy,z)*CityGrid.ModelScale;
			if (t >= 1) { p.Tile.Building.WorldScale = p.To*CityGrid.ModelScale; pops.RemoveAt(i); } else pops[i] = p;
		}
	}

	static Model OwnershipFrame(int seat, Color playerColour)
	{
		if (ownershipModels.TryGetValue(seat,out var cached)) return cached;
		var v=new VertexBuffer(); v.Init(true);
		// A dark keyline and a wide coloured inlay make the owner readable even
		// when a building uses its own textured material. Both sit on the board.
		void Frame(float width,float z,Color colour)
		{
			var half=CityGrid.CellSize*0.45f; var length=half*2+width;
			v.AddCube(new Vector3(half,0,z),new Vector3(width,length,0.35f),Rotation.Identity,colour);
			v.AddCube(new Vector3(-half,0,z),new Vector3(width,length,0.35f),Rotation.Identity,colour);
			v.AddCube(new Vector3(0,half,z),new Vector3(length,width,0.35f),Rotation.Identity,colour);
			v.AddCube(new Vector3(0,-half,z),new Vector3(length,width,0.35f),Rotation.Identity,colour);
		}
		Frame(2.4f,0,new Color(0.165f,0.129f,0.094f));
		Frame(1.3f,0.4f,playerColour);
		var mesh=new Mesh(Material.Load("materials/board/owner_ink.vmat")); mesh.CreateBuffers(v);
		return ownershipModels[seat]=new ModelBuilder().AddMesh(mesh).Create();
	}
	void MarkOwner(BoardCell cell)
	{
		var seat=PlayerColours.Seat(match,cell.Owner);
		if (CardLibrary.Get(cell.CardId).Type == CardType.Road || seat<0) return;
		var frame=new GameObject(true,$"Owner {cell.X},{cell.Y}") { Parent=GameObject };
		frame.WorldPosition=grid.CellToWorld(cell.X,cell.Y)+Vector3.Up*(CityGrid.SurfaceHeight+0.25f);
		frame.WorldRotation=Rotation.Identity;
		frame.WorldScale=Vector3.One;
		var renderer=frame.Components.Create<ModelRenderer>(); renderer.Model=OwnershipFrame(seat,PlayerColours.For(match,cell.Owner));
		ownershipFrames[(cell.X,cell.Y)]=frame;
	}

	void Render( BoardCell cell, bool animate )
	{
		if ( !grid.IsValid() ) return;
		var card = CardLibrary.Get(cell.CardId);
		var type = PlaceholderType(card);
		// Visual choices are derived from cell order, never from the gameplay RNG.
		var piece = new Piece { Type = type, BodyGroup = cell.Order % (type == TileType.House ? 5 : 3), MaterialIndex = cell.Order % 5 };
		if ( !grid.Place(cell.X,cell.Y,piece,animate,legacyRules:false) ) return;
		var tile = grid.Get(cell.X,cell.Y);
		tile.CardId = cell.CardId;
		tile.Owner = cell.Owner;
		tile.ByPlayer = cell.PlacedRound > 0;
		tile.Order = cell.Order;
		tile.Building.Name = $"{(cell.CardId == "sapling" && Growth.IsMature(cell) ? "Forest" : card.Name)} {cell.X},{cell.Y}";
		tile.Building.WorldScale = Scale(cell)*CityGrid.ModelScale;
		tile.BaseTint = Color.White;
		if (type == TileType.Road) grid.RefreshRoad(tile);
		else
		{
			tile.BuildingRenderer.Enabled = false;
			tile.Building.WorldRotation = Rotation.FromYaw(CardVisualVariation.Yaw(match,cell.CardId,cell.X,cell.Y,cell.Order));
			tile.Ground.Enabled = true;
			CardVisuals.Build(tile.Building,cell.CardId,cell.CardId == "sapling" && Growth.IsMature(cell),CardVisualVariation.Seed(cell.CardId,cell.X,cell.Y));
		}
		Tint(cell);
		MarkOwner(cell);
	}

	public static TileType PlaceholderType( CardDef card ) => card.Type switch
	{
		CardType.Road => TileType.Road,
		CardType.Residential => TileType.House,
		CardType.Green or CardType.Growth => TileType.Park,
		_ => TileType.Business
	};
}