Game/CityPlayer.cs

The CityPlayer component manages grid board input and visual feedback for card placement. It handles keyboard and mouse cursor interactions, determines if a tile is hovered, and updates a visual ghost model to show potential placement.

Native Interop
using CitySim.Cards;
using CitySim.Match;

namespace CitySim;

/// <summary>Board picking and keyboard cursor. Placement always goes through the same model validator.</summary>
public sealed class CityPlayer : Component
{
	public Tile Hovered { get; private set; }
	public bool HoveredValid { get; private set; }
	public int HoveredGain { get; private set; }
	public string HoveredReason { get; private set; }
	public Vector2? DebugMouse { get; set; }
	public event Action PreviewChanged;
	public IReadOnlyList<PlacementInteraction> Interactions { get; private set; } = Array.Empty<PlacementInteraction>();
	public PlacementOutlook Outlook { get; private set; }
	string interactionKey;
	MatchState interactionMatch;
	GameObject footprint;
	static Model footprintModel;
	GameObject ghost;
	public GameObject PlacementGhost => ghost;
	ModelRenderer ghostRenderer;
	string ghostModel;
	float denyTime;
	bool keyboardCursor;
	int cursorX = 5, cursorY = 5;
	Vector2 previousMouse;
	CameraComponent Camera => Components.Get<CameraComponent>();
	public CityCamera View => Components.GetOrCreate<CityCamera>();
	public void FocusOn( CityGrid grid ) { View.FocusOn(grid); cursorX = grid.Width/2; cursorY = grid.Height/2; }
	public void Deny() => denyTime = 0.2f;

	protected override void OnUpdate()
	{
		var game = CityGame.Instance;
		if (game == null || !game.Grid.IsValid() || !game.Grid.IsBuilt) return;
		var inspectingMarket = Hud.MarketPeeking && game.Match?.Phase == MatchPhase.Bid && game.Screens.Top == GameScreen.Match;
		View.Step((game.BoardInput || inspectingMarket || game.EndInspect) && !Hud.ControlsMode);
		denyTime = Math.Max(0,denyTime-Time.Delta);
		var old = (Hovered,HoveredValid,HoveredGain,HoveredReason);
		if ((game.BoardInput || inspectingMarket) && !Hud.ControlsMode)
		{
			HandleCursor(game);
			UpdateHover(game);
			if (game.BoardInput) HandleActions(game);
		}
		else { Hovered = null; HoveredValid = false; HoveredGain = 0; HoveredReason = ""; }
		UpdateInteractions(game);
		UpdateGhost(game);
		if (old != (Hovered,HoveredValid,HoveredGain,HoveredReason)) PreviewChanged?.Invoke();
	}

	void HandleCursor( CityGame game )
	{
		var mouse = DebugMouse ?? Mouse.Position;
		if ((mouse-previousMouse).Length > 2) keyboardCursor = false;
		previousMouse = mouse;
		var x = (Input.Pressed("CursorRight") ? 1 : 0)-(Input.Pressed("CursorLeft") ? 1 : 0);
		var y = (Input.Pressed("CursorDown") ? 1 : 0)-(Input.Pressed("CursorUp") ? 1 : 0);
		if (x != 0 || y != 0) { FocusBoard(game); keyboardCursor = true; cursorX = Math.Clamp(cursorX+x,0,game.Grid.Width-1); cursorY = Math.Clamp(cursorY+y,0,game.Grid.Height-1); }
	}
	void UpdateHover( CityGame game )
	{
		Tile tile = keyboardCursor ? game.Grid.Get(cursorX,cursorY) : null;
		if (!keyboardCursor && Camera != null && !Hud.PointerOverUi)
		{
			var ray = Camera.ScreenPixelToRay(DebugMouse ?? Mouse.Position);
			if (ray.Forward.z < -0.001f)
			{
				var t = (CityGrid.SurfaceHeight-ray.Position.z)/ray.Forward.z;
				if (t > 0) tile = game.Grid.WorldToTile(ray.Position+ray.Forward*t);
			}
		}
		Hovered = tile; HoveredValid = false; HoveredGain = 0;
		HoveredReason = GameText.Get("ui.city_player.select_a_card");
		if (!game.BoardInput || tile == null || game.SelectedCardId == null) return;
		if (game.RenovationArmed)
		{
			HoveredReason = game.RenovationReplacementSlot < 0 ? GameText.Get("ui.city_player.choose_a_replacement_building_from_your_hand") : game.Match.RenovationError(CityGame.Me,game.RenovationEventSlot,game.RenovationReplacementSlot,tile.X,tile.Y);
			HoveredValid = HoveredReason == null;
			if (HoveredValid) HoveredGain = game.Match.PreviewRenovation(CityGame.Me,game.RenovationEventSlot,game.RenovationReplacementSlot,tile.X,tile.Y) ?? 0;
			return;
		}
		HoveredReason = game.Match.CardPlayError(CityGame.Me,game.SelectedCardId,tile.X,tile.Y);
		HoveredValid = HoveredReason == null;
		if (HoveredValid) HoveredGain = PlacementScoring.Preview(game.Match,game.SelectedCardId,tile.X,tile.Y,CityGame.Me);
	}
	void UpdateInteractions(CityGame game)
	{
		var card = game.RenovationArmed ? game.RenovationReplacement?.Id : game.SelectedCardId;
		var key = game.BoardInput && game.Match != null && Hovered != null && card != null && card != "land-claim" && !game.Held.IsEmpty
			? $"{card}:{Hovered.X}:{Hovered.Y}:{game.RenovationArmed}:" + string.Join(",",game.Match.Cells().Select(c => $"{c.Order}:{c.Age}")) + ":" + game.Match.Round + ":" + string.Join(",",game.Match.Players.Values.Select(p => $"{p.Coins}:{p.Vp}")) : "";
		if (key == interactionKey && interactionMatch == game.Match) return;
		interactionMatch = game.Match; interactionKey = key;
		Outlook=key.Length==0 || !HoveredValid ? null:game.Match.PreviewOutlook(CityGame.Me,card,Hovered.X,Hovered.Y,game.RenovationArmed);
		Interactions = key.Length == 0 ? Array.Empty<PlacementInteraction>() : game.Match.PreviewInteractions(CityGame.Me,card,Hovered.X,Hovered.Y,game.RenovationArmed);
		game.PreviewPlacementInteractions(Interactions);
		PreviewChanged?.Invoke();
	}

	void HandleActions( CityGame game )
	{
		for (var i = 0; i < 5; i++) if (Input.Pressed($"Slot{i+1}")) { FocusBoard(game); game.SelectSlot(i); }
		if (Input.Pressed("HandPrevious")) game.SelectSlot(Math.Max(0,game.SelectedSlot-1));
		if (Input.Pressed("HandNext")) game.SelectSlot(Math.Min((game.LocalPlayer?.Hand.Count ?? 1)-1,game.SelectedSlot+1));
		if (Input.Pressed("Attack2") || Input.Pressed("CancelCard")) game.Deselect();
		if (Input.Pressed("DiscardCard")) game.DiscardSelected();
		if (Hud.PointerOverUi && !keyboardCursor) return;
		if (Input.Pressed("Attack1") || Input.Pressed("ConfirmCard") && !Hud.UiControlFocused)
		{
			if (game.SelectedImmediateEvent && !game.RenovationArmed) game.PlaySelectedEvent();
			else if (Hovered != null && game.SelectedCardId != null) game.RequestPlace(Hovered.X,Hovered.Y);
		}
	}
	void FocusBoard(CityGame game) => game.Scene.GetAllComponents<Hud>().FirstOrDefault()?.FocusBoard();
	void UpdateGhost( CityGame game )
	{
		if (!game.BoardInput || Hovered == null || game.Held.IsEmpty)
		{
			if (ghost.IsValid()) ghost.Enabled = false;
			if (footprint.IsValid()) footprint.Enabled = false;
			return;
		}
		var occupied = game.Match.Get(Hovered.X,Hovered.Y) != null;
		var showFootprint = HoveredValid && !occupied && game.SelectedCardId != "land-claim";
		if (showFootprint)
		{
			if (!footprint.IsValid())
			{
				footprint = new GameObject(true,"Placement footprint") { Parent = GameObject, NetworkMode = NetworkMode.Never };
				if (footprintModel == null)
				{
					var v = new VertexBuffer(); v.Init(true);
					var half = CityGrid.CellSize * 0.43f; var length = half * 2 + 1.5f;
					var ink = new Color(0.25f,0.55f,0.25f);
					v.AddCube(new Vector3(half,0,0),new Vector3(1.5f,length,0.35f),Rotation.Identity,ink);
					v.AddCube(new Vector3(-half,0,0),new Vector3(1.5f,length,0.35f),Rotation.Identity,ink);
					v.AddCube(new Vector3(0,half,0),new Vector3(length,1.5f,0.35f),Rotation.Identity,ink);
					v.AddCube(new Vector3(0,-half,0),new Vector3(length,1.5f,0.35f),Rotation.Identity,ink);
					var mesh = new Mesh(Material.Load("materials/board/owner_ink.vmat")); mesh.CreateBuffers(v);
					footprintModel = new ModelBuilder().AddMesh(mesh).Create();
				}
				footprint.Components.Create<ModelRenderer>().Model = footprintModel;
			}
			footprint.WorldRotation = Rotation.Identity;
			footprint.WorldPosition = Hovered.Center + Vector3.Up * (CityGrid.SurfaceHeight + 0.3f);
		}
		if (footprint.IsValid()) footprint.Enabled = showFootprint;
		if (occupied && !game.RenovationArmed)
		{
			if (ghost.IsValid()) ghost.Enabled = false;
			return;
		}
		if (!ghost.IsValid())
		{
			ghost = new GameObject(true,"Placement ghost") { Parent = GameObject };
			ghost.WorldScale = CityGrid.ModelScale;
			ghostRenderer = ghost.Components.Create<ModelRenderer>();
		}
		var type = game.Held.Type;
		var id = game.SelectedCardId;
		var road = game.Grid.RoadShape(Hovered.X,Hovered.Y);
		var model = id == "land-claim" ? TileRules.Ghost : type == TileType.Road ? road.model : null;
		if (id == "avenue" && model != null && model.EndsWith("road-crossroad.vmdl")) model = "models/kenney/roads/road-crossroad-line.vmdl";
		var visualSeed = CardVisualVariation.Seed(id,Hovered.X,Hovered.Y);
		var key = id + ":" + model + ":" + visualSeed;
		if (ghostModel != key)
		{
			ghostModel = key;
			ghostRenderer.Enabled = id == "land-claim";
			if (id == "land-claim") ghostRenderer.Model = Model.Load(model);
			if (id == "land-claim") CardVisuals.Clear(ghost);
			else if (type == TileType.Road) CardVisuals.BuildRoad(ghost,model,id == "roundabout");
			else CardVisuals.Build(ghost,id,visualSeed:visualSeed);
		}
		var yaw = type == TileType.Road ? road.yaw : CardVisualVariation.Yaw(game.Match,id,Hovered.X,Hovered.Y);
		ghost.WorldScale = (id == "sapling" ? 0.55f : 1f)*CityGrid.ModelScale;
		ghost.WorldRotation = Rotation.FromYaw(yaw+(denyTime > 0 ? MathF.Sin(denyTime*100)*denyTime*40 : 0));
		// Road meshes normally replace the ground tile. Lift the preview above the
		// still-visible ground so it cannot disappear underneath it.
		ghost.WorldPosition = Hovered.Center+Vector3.Up*(type == TileType.Road && id != "land-claim" ? CityGrid.SurfaceHeight+0.8f : Hovered.Type == TileType.Empty ? 0 : 4);
		ghost.Enabled = true;
		var tint = HoveredValid ? new Color(0.7f,1,0.7f) : new Color(1,0.4f,0.4f);
		if (id == "avenue") tint *= new Color(0.8f,0.85f,1f);
		ghostRenderer.Tint = tint; CardVisuals.Tint(ghost,tint);

	}
	public Vector2? ScreenPoint( Vector3 world )
	{
		if (Camera == null) return null;
		if ((world-Camera.WorldPosition).Dot(Camera.WorldRotation.Forward) <= 0) return null;
		var rect = Camera.BBoxToScreenPixels(new BBox(world-Vector3.One,world+Vector3.One),out _);
		return rect.Center / Screen.Size;
	}
	protected override void OnDestroy() { if (ghost.IsValid()) ghost.Destroy(); if (footprint.IsValid()) footprint.Destroy(); }
}