diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs index 2105abd..85abe53 100644 --- a/Theriapolis.Godot/Scenes/PlayScreen.cs +++ b/Theriapolis.Godot/Scenes/PlayScreen.cs @@ -77,7 +77,14 @@ public partial class PlayScreen : Control private readonly Dictionary _npcMarkers = new(); private Label _hudLabel = null!; private PanelContainer _hudPanel = null!; + private Label _cursorDebugLabel = null!; private Label? _saveFlashLabel; + // Reused per-frame builders — avoid GC pressure on hot _Process path. + // Holding a key produces auto-repeat InputEventKey objects that the C# + // GC must release before engine shutdown asserts on empty bindings; + // reducing per-frame allocations buys headroom for those collections. + private readonly System.Text.StringBuilder _cursorSb = new(256); + private readonly System.Text.StringBuilder _hudSb = new(256); // Click-vs-drag state (left-click only; PanZoomCamera handles // middle/right-drag pan independently). @@ -252,6 +259,7 @@ public partial class PlayScreen : Control } UpdateHud(tactical); + UpdateCursorDebug(tactical); } public override void _UnhandledInput(InputEvent @event) @@ -572,6 +580,34 @@ public partial class PlayScreen : Control }; margin.AddChild(_hudLabel); + // Cursor-debug panel — top-right counterpart to the player-status + // panel. Shows tile coords, biome, feature flags, settlement, + // tactical-tile surface/deco, and any NPC under the mouse. + var cursorPanel = new PanelContainer + { + ThemeTypeVariation = "Card", + MouseFilter = MouseFilterEnum.Ignore, + AnchorLeft = 1, AnchorRight = 1, + OffsetLeft = -460, OffsetTop = 12, OffsetRight = -12, OffsetBottom = 260, + }; + hudLayer.AddChild(cursorPanel); + + var cursorMargin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore }; + cursorMargin.AddThemeConstantOverride("margin_left", 12); + cursorMargin.AddThemeConstantOverride("margin_top", 8); + cursorMargin.AddThemeConstantOverride("margin_right", 12); + cursorMargin.AddThemeConstantOverride("margin_bottom", 8); + cursorPanel.AddChild(cursorMargin); + + _cursorDebugLabel = new Label + { + Text = "CURSOR", + ThemeTypeVariation = "CardBody", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + MouseFilter = MouseFilterEnum.Ignore, + }; + cursorMargin.AddChild(_cursorDebugLabel); + // Save-flash toast, mounted bottom-center on the same canvas // layer. Hidden by default; FlashSavedToast pops it in. _saveFlashLabel = new Label @@ -589,6 +625,121 @@ public partial class PlayScreen : Control hudLayer.AddChild(_saveFlashLabel); } + /// Top-right debug panel — what is under the mouse this + /// frame. World/tile coords, biome, feature flags, the settlement + /// whose footprint contains the tile, the tactical surface + deco + /// + walkability when zoomed in, and any NPC within hit radius. + private void UpdateCursorDebug(bool tactical) + { + var screenPos = GetViewport().GetMousePosition(); + var worldPos = ScreenToWorld(screenPos); + int tx = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS); + int ty = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS); + + var sb = _cursorSb; + sb.Clear(); + sb.Append("CURSOR world (").Append((int)worldPos.X).Append(", ") + .Append((int)worldPos.Y).Append(") tile (") + .Append(tx).Append(", ").Append(ty).Append(')').AppendLine(); + + if ((uint)tx < C.WORLD_WIDTH_TILES && (uint)ty < C.WORLD_HEIGHT_TILES) + { + ref var t = ref _ctx.World.TileAt(tx, ty); + sb.Append(" Biome: ").Append(t.Biome).AppendLine(); + if (t.Features != FeatureFlags.None) + sb.Append(" Flags: ").Append(t.Features).AppendLine(); + + // Copy SettlementId out of the ref local before the lambda + // capture below — `ref var t` can't escape into a closure. + int settlementId = t.SettlementId; + if (settlementId != 0) + { + var settle = _ctx.World.Settlements.FirstOrDefault(s => s.Id == settlementId); + if (settle is not null) + { + sb.Append(" Settlement: ").Append(settle.Name) + .Append(" (Tier ").Append(settle.Tier).Append(')').AppendLine(); + if (!settle.IsPoi) + sb.Append(" ").Append(settle.Economy) + .Append(" · ").Append(settle.Governance) + .Append(" · pop ").Append(settle.Population).AppendLine(); + else if (settle.PoiType != PoiType.None) + sb.Append(" PoI: ").Append(settle.PoiType).AppendLine(); + } + } + + if (tactical) + { + int tacticalX = (int)Mathf.Floor(worldPos.X); + int tacticalY = (int)Mathf.Floor(worldPos.Y); + var tt = _streamer.SampleTile(tacticalX, tacticalY); + string move = !tt.IsWalkable ? "blocked" + : tt.SlowsMovement ? "slow" : "walkable"; + string deco = tt.Deco == TacticalDeco.None ? "—" : tt.Deco.ToString(); + sb.Append(" Tactical (").Append(tacticalX).Append(", ").Append(tacticalY).Append(')').AppendLine(); + sb.Append(" Surface: ").Append(tt.Surface) + .Append(" (v").Append(tt.Variant).Append(") Deco: ").Append(deco).AppendLine(); + sb.Append(" Move: ").Append(move).AppendLine(); + } + } + else + { + sb.Append(" ").AppendLine(); + } + + // Bridge under cursor (point-on-segment test — cheap, ≤ a few dozen bridges). + const float BridgeHitPx = 6f; + foreach (var bridge in _ctx.World.Bridges) + { + if (DistancePointToSegmentSq(worldPos.X, worldPos.Y, + bridge.Start.X, bridge.Start.Y, bridge.End.X, bridge.End.Y) < BridgeHitPx * BridgeHitPx) + { + sb.Append(" Bridge over road ").Append(bridge.RoadId).AppendLine(); + break; + } + } + + // NPC under cursor (within marker hit radius). + const float NpcHitPx = 12f; + float closestSq = NpcHitPx * NpcHitPx; + NpcActor? hovered = null; + foreach (var npc in _actors.Npcs) + { + float ddx = npc.Position.X - worldPos.X; + float ddy = npc.Position.Y - worldPos.Y; + float distSq = ddx * ddx + ddy * ddy; + if (distSq < closestSq) { closestSq = distSq; hovered = npc; } + } + if (hovered is not null) + { + string tag = !string.IsNullOrEmpty(hovered.RoleTag) + ? hovered.RoleTag + : (hovered.Template?.Id ?? ""); + sb.AppendLine(); + sb.Append("NPC: ").Append(hovered.DisplayName) + .Append(" [").Append(tag).Append(']').AppendLine(); + sb.Append(" Allegiance: ").Append(hovered.Allegiance) + .Append(" HP ").Append(hovered.CurrentHp).Append('/').Append(hovered.MaxHp).AppendLine(); + } + + _cursorDebugLabel.Text = sb.ToString().TrimEnd(); + } + + private static float DistancePointToSegmentSq(float px, float py, + float ax, float ay, float bx, float by) + { + float vx = bx - ax, vy = by - ay; + float wx = px - ax, wy = py - ay; + float c1 = vx * wx + vy * wy; + if (c1 <= 0f) return wx * wx + wy * wy; + float c2 = vx * vx + vy * vy; + if (c2 <= c1) { float ex = px - bx, ey = py - by; return ex * ex + ey * ey; } + float t = c1 / c2; + float qx = ax + t * vx, qy = ay + t * vy; + float dx = px - qx, dy = py - qy; + return dx * dx + dy * dy; + } + private void UpdateHud(bool tactical) { var p = _actors.Player!;