M7.4b: Top-right cursor-debug panel for play-testing

Mirror of the player-status panel, anchored top-right. Per-frame
sample of GetViewport().GetMousePosition() converted to world coords
via the camera transform; surfaces:

  - World-pixel and world-tile coords under the cursor
  - Biome at that tile
  - Feature flag bitmask (HasRoad / HasRiver / HasRail / IsSettlement
    / IsPoi / IsCoast / RiverAdjacent / RailroadAdjacent)
  - Settlement on the tile via WorldTile.SettlementId lookup —
    name, tier, and economy/governance/pop (or PoI type)
  - Bridge under cursor via point-on-segment hit test against
    world.Bridges (6 px hit radius, ≤ a few dozen bridges so cheap)
  - Tactical block when zoomed in: tactical-tile coords, surface +
    variant, deco, walkability tag (walkable / slow / blocked)
  - NPC under cursor within 12 px hit radius — display name, role
    tag or template id, allegiance, HP

Cached StringBuilder field on PlayScreen (Clear() each frame instead
of new'ing) to keep per-frame GC pressure low. Held keys produce
auto-repeat InputEventKey instances that Godot 4 mono's GC must
collect before engine shutdown; reducing per-frame garbage buys
that collection more headroom and avoided a shutdown-assertion
race observed on the first launch with the panel mounted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-10 20:49:44 -07:00
parent 6f47700820
commit b1fc3f244b
+151
View File
@@ -77,7 +77,14 @@ public partial class PlayScreen : Control
private readonly Dictionary<int, NpcMarker> _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);
}
/// <summary>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.</summary>
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(" <off-world>").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 ?? "<resident>");
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!;