using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Quests;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Settlements;
using Theriapolis.GodotHost.Input;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.Rendering;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
///
/// M7.2 + M7.3 — the play screen. Wraps
/// with the game-state layer: player actor, world clock, chunk
/// streamer, NPC markers, player controller, save layer, and a
/// top-left HUD overlay. Click on the world map to travel; WASD to
/// step at tactical zoom. F5 quicksaves; Esc returns to title (M7.4
/// will replace this with a pause menu).
///
/// Save round-trip (M7.3): wraps
/// + .
/// on init consumes
/// set by the
/// hand-off; replaces the new-game spawn.
/// The save format is owned by Core and untouched — saves written by
/// the MonoGame build load here byte-identically and vice versa.
///
/// M7 sub-milestone status:
/// M7.4 (pause menu) — Esc still does quit-to-title for now.
/// M7.5 (interact prompt) — F-to-talk not yet wired.
/// M7.6 (encounter trigger stub) — hostile detection not yet wired.
///
public partial class PlayScreen : Control
{
private const float ClickSlopPixels = 4f;
// Composed Core systems
private WorldGenContext _ctx = null!;
private ContentResolver _content = null!;
private InMemoryChunkDeltaStore _deltas = null!;
private ChunkStreamer _streamer = null!;
private ActorManager _actors = null!;
private WorldClock _clock = null!;
private PlayerController _controller = null!;
private AnchorRegistry _anchorRegistry = null!;
private readonly PlayerReputation _reputation = new();
private readonly Dictionary _flags = new();
private readonly QuestEngine _questEngine = new();
private QuestContext? _questCtx;
// M7.3 — save round-trip plumbing
private readonly Dictionary> _killedByChunk = new();
// Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD
// push. Captured by load, picked up after chunks load; M8 will turn
// this into an actual push to the combat screen.
private EncounterState? _pendingEncounterRestore;
private float _saveFlashTimer;
private string _saveFlashText = "";
// Godot tree
private WorldRenderNode _render = null!;
private PlayerMarker _playerMarker = null!;
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).
private Vector2 _mouseDownPos;
private int _mouseDownTileX, _mouseDownTileY;
private bool _mouseDownTracked;
public override void _Ready()
{
var session = GameSession.From(this);
if (session.Ctx is null)
{
GD.PushError("[play] No WorldGenContext on session — falling back to title.");
BackToTitle();
return;
}
_ctx = session.Ctx;
Theme = CodexTheme.Build();
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
// World render layer — biome + polylines + settlements + camera.
_render = new WorldRenderNode();
AddChild(_render);
_render.Initialize(_ctx.World);
// Core systems.
_content = new ContentResolver(
new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir));
_deltas = new InMemoryChunkDeltaStore();
_streamer = new ChunkStreamer(
_ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
_streamer.OnChunkLoaded += _render.AddChunkNode;
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
_streamer.OnChunkLoaded += HandleChunkLoaded;
_streamer.OnChunkEvicting += HandleChunkEvicting;
_clock = new WorldClock();
_actors = new ActorManager();
_anchorRegistry = new AnchorRegistry();
_anchorRegistry.RegisterAllAnchors(_ctx.World);
// Phase 6 M4 — quest context wraps content/actors/rep/flags/clock/
// world for the quest engine. Round-trips through the save body.
_questCtx = new QuestContext(
_content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World);
// Spawn or restore the player. Restore wins when a load was queued.
if (session.PendingRestore is not null)
{
ApplyRestoredBody(session.PendingRestore);
}
else
{
var spawn = ChooseSpawn(_ctx.World);
if (session.PendingCharacter is not null)
{
var p = _actors.SpawnPlayer(spawn, session.PendingCharacter);
if (!string.IsNullOrWhiteSpace(session.PendingName))
p.Name = session.PendingName;
}
else
{
_actors.SpawnPlayer(spawn);
}
}
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
// Player marker.
_playerMarker = new PlayerMarker
{
Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y),
Rotation = _actors.Player.FacingAngleRad,
};
AddChild(_playerMarker);
_render.Camera.Position = _playerMarker.Position;
SetInitialZoom();
BuildHud();
// M7.5/M8 will pick up _pendingEncounterRestore here once the
// combat HUD screen exists. For now we keep the snapshot on the
// body so a re-save preserves it across the Godot↔MonoGame round
// trip, but we don't attempt to resume combat.
if (_pendingEncounterRestore is not null)
GD.Print("[play] Loaded save has an active encounter — "
+ "combat HUD ships with M8; encounter preserved through save round-trip.");
// Clear pending so a quit-to-title doesn't see stale data.
session.PendingCharacter = null;
session.PendingName = "Wanderer";
session.PendingRestore = null;
session.PendingHeader = null;
}
public override void _Process(double delta)
{
if (_actors?.Player is null || _render is null) return;
float dt = (float)delta;
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
// WASD is context-sensitive: tactical mode steps the player,
// world-map mode pans the camera. Same keys, intent depends on zoom.
float wasdX = 0f, wasdY = 0f;
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) wasdY -= 1f;
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) wasdY += 1f;
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) wasdX -= 1f;
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) wasdX += 1f;
// Controller always ticks (path-follow runs even when WASD is idle).
// Pass step input only in tactical mode.
float stepX = tactical ? wasdX : 0f;
float stepY = tactical ? wasdY : 0f;
_controller.Update(dt, stepX, stepY, tactical, isFocused: true);
// World-map WASD pan. Skip while traveling — the follow logic below
// re-centres the camera on the player and would clobber the pan.
// Speed scales inversely with zoom so the on-screen pan rate feels
// consistent at any zoom level (matches MonoGame's 400 px/sec).
if (!tactical && !_controller.IsTraveling && (wasdX != 0f || wasdY != 0f))
{
const float PanScreenPxPerSec = 400f;
float invLen = (wasdX != 0f && wasdY != 0f) ? 0.70710678f : 1f;
float panSpeed = PanScreenPxPerSec / Mathf.Max(_render.Camera.Zoom.X, 0.01f);
_render.Camera.Position += new Vector2(wasdX * invLen, wasdY * invLen) * panSpeed * dt;
}
// Sync the player marker from Core state. Rotation drives the
// facing tick via the transform — auto-property setters on a
// PlayerMarker field would skip QueueRedraw and the cached
// _Draw commands would stay stuck at the initial angle.
var p = _actors.Player;
_playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
_playerMarker.Rotation = p.FacingAngleRad;
// Camera follow when traveling or in tactical (matches MonoGame).
if (_controller.IsTraveling || tactical)
_render.Camera.Position = _playerMarker.Position;
// Stream tactical chunks around the player when at tactical zoom.
if (tactical)
_streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES);
// Counter-scale markers so on-screen size stays constant.
float zoom = _render.Camera.Zoom.X;
if (zoom > 0f)
{
float inv = 1f / zoom;
_playerMarker.Scale = new Vector2(inv, inv);
_playerMarker.ShowFacingTick = tactical;
foreach (var marker in _npcMarkers.Values)
marker.Scale = new Vector2(inv, inv);
}
// Save-flash toast decay.
if (_saveFlashTimer > 0f)
{
_saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt);
if (_saveFlashLabel is not null)
{
_saveFlashLabel.Text = _saveFlashText;
_saveFlashLabel.Modulate = new Color(1, 1, 1, Mathf.Min(1f, _saveFlashTimer / 0.5f));
_saveFlashLabel.Visible = _saveFlashTimer > 0f;
}
}
else if (_saveFlashLabel is not null && _saveFlashLabel.Visible)
{
_saveFlashLabel.Visible = false;
}
UpdateHud(tactical);
UpdateCursorDebug(tactical);
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event is not InputEventMouseButton mb || mb.ButtonIndex != MouseButton.Left)
return;
if (mb.Pressed)
{
_mouseDownPos = mb.Position;
var worldPos = ScreenToWorld(mb.Position);
_mouseDownTileX = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
_mouseDownTileY = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
_mouseDownTracked = true;
return;
}
if (!_mouseDownTracked) return;
_mouseDownTracked = false;
bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
if (!wasClick) return;
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
if (!tactical && InBounds(_mouseDownTileX, _mouseDownTileY))
{
_controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
GetViewport().SetInputAsHandled();
}
}
public override void _Input(InputEvent @event)
{
if (@event is not InputEventKey { Pressed: true } key) return;
if (key.Echo) return;
// Skip key events while the game is paused — the pause overlay
// owns input handling for itself; PlayScreen shouldn't see Esc/F5
// again until the overlay closes.
if (GetTree().Paused) return;
switch (key.Keycode)
{
case Key.F5:
SaveTo(SavePaths.AutosavePath());
GetViewport().SetInputAsHandled();
break;
case Key.Escape:
GetViewport().SetInputAsHandled();
AddChild(new PauseMenuScreen(this));
break;
}
}
/// Read-only accessor for the live player Character — used
/// by to surface the level-up affordance
/// when eligible.
public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
=> _actors?.Player?.Character;
private Vector2 ScreenToWorld(Vector2 screenPos)
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
private static bool InBounds(int x, int y)
=> (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;
// ──────────────────────────────────────────────────────────────────────
// Chunk → NPC lifecycle (Phase 5 M5)
private void HandleChunkLoaded(TacticalChunk chunk)
{
if (_content is null) return;
_killedByChunk.TryGetValue(chunk.Coord, out var killed);
for (int i = 0; i < chunk.Spawns.Count; i++)
{
// Skip slots the player previously killed — they don't respawn
// on chunk reload until the save is wiped.
if (killed is not null && killed.Contains(i)) continue;
var spawn = chunk.Spawns[i];
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
if (spawn.Kind == SpawnKind.Resident)
{
var resident = ResidentInstantiator.Spawn(
_ctx.World.WorldSeed, chunk, i, spawn,
_ctx.World, _content, _actors, _anchorRegistry);
if (resident is not null) MountNpcMarker(resident);
continue;
}
var template = NpcInstantiator.PickTemplate(spawn.Kind, chunk.DangerZone, _content.Npcs);
if (template is null) continue;
int tx = chunk.OriginX + spawn.LocalX;
int ty = chunk.OriginY + spawn.LocalY;
var npc = _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
MountNpcMarker(npc);
}
}
private void HandleChunkEvicting(TacticalChunk chunk)
{
var toRemove = new List();
foreach (var npc in _actors.Npcs)
{
if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
{
toRemove.Add(npc.Id);
if (!string.IsNullOrEmpty(npc.RoleTag))
_anchorRegistry.UnregisterRole(npc.RoleTag);
}
}
foreach (int id in toRemove)
{
_actors.RemoveActor(id);
if (_npcMarkers.Remove(id, out var marker))
marker.QueueFree();
}
}
private void MountNpcMarker(NpcActor npc)
{
var marker = new NpcMarker
{
Position = new Vector2(npc.Position.X, npc.Position.Y),
Allegiance = npc.Allegiance,
};
AddChild(marker);
_npcMarkers[npc.Id] = marker;
}
// ──────────────────────────────────────────────────────────────────────
// M7.3 — Save / Load
/// Write the current state to the given slot path (atomic).
public bool SaveTo(string path)
{
try
{
var header = BuildHeader();
var body = CaptureBody();
var bytes = SaveCodec.Serialize(header, body);
SavePaths.WriteAtomic(path, bytes);
FlashSavedToast($"Saved to {Path.GetFileName(path)}");
return true;
}
catch (Exception ex)
{
FlashSavedToast($"Save failed: {ex.Message}");
GD.PushError($"[save] {ex}");
return false;
}
}
private SaveHeader BuildHeader()
{
var h = new SaveHeader
{
WorldSeedHex = $"0x{_ctx.World.WorldSeed:X}",
PlayerName = _actors.Player!.Name,
PlayerTier = _actors.Player.HighestTierReached,
InGameSeconds = _clock.InGameSeconds,
SavedAtUtc = DateTime.UtcNow.ToString("u"),
};
foreach (var kv in _ctx.World.StageHashes)
h.StageHashes[kv.Key] = $"0x{kv.Value:X}";
return h;
}
/// Build a save body from the current PlayScreen state. Mirrors
/// the MonoGame source's CaptureBody field-by-field so the
/// resulting byte stream is interoperable across builds.
private SaveBody CaptureBody()
{
// Mid-combat snapshot — null in M7 (combat HUD doesn't exist yet),
// but the field is preserved so a save loaded from MonoGame with
// an active encounter round-trips back through Godot intact.
EncounterState? activeEnc = _pendingEncounterRestore;
// Push every loaded chunk through eviction so any in-memory deltas
// land in the store before we read it.
_streamer.FlushAll();
var body = new SaveBody
{
Player = _actors.Player!.CaptureState(),
Clock = _clock.CaptureState(),
};
if (_actors.Player.Character is not null)
body.PlayerCharacter = CharacterCodec.Capture(_actors.Player.Character);
foreach (var kv in _deltas.All)
body.ModifiedChunks[kv.Key] = kv.Value;
foreach (var kv in _killedByChunk)
body.NpcRoster.ChunkDeltas.Add(new NpcChunkDelta
{
ChunkX = kv.Key.X,
ChunkY = kv.Key.Y,
KilledSpawnIndices = kv.Value.ToArray(),
});
body.ActiveEncounter = activeEnc;
body.ReputationState = ReputationCodec.Capture(_reputation);
body.Flags = new Dictionary(_flags);
body.QuestEngineState = QuestCodec.Capture(_questEngine);
return body;
}
/// Restore PlayScreen state from a deserialised body. Caller
/// must have already set _ctx, _content, _streamer,
/// _actors, _clock, and _anchorRegistry — this is
/// invoked from after those are wired but before
/// the player marker is created.
private void ApplyRestoredBody(SaveBody body)
{
var player = _actors.RestorePlayer(body.Player);
_clock.RestoreState(body.Clock);
foreach (var kv in body.ModifiedChunks)
_deltas.Put(kv.Key, kv.Value);
foreach (var d in body.ModifiedWorldTiles)
{
ref var t = ref _ctx.World.TileAt(d.X, d.Y);
t.Biome = (BiomeId)d.NewBiome;
t.Features = (FeatureFlags)d.NewFeatures;
}
if (body.PlayerCharacter is not null)
player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content);
_killedByChunk.Clear();
foreach (var d in body.NpcRoster.ChunkDeltas)
{
var coord = new ChunkCoord(d.ChunkX, d.ChunkY);
_killedByChunk[coord] = new HashSet(d.KilledSpawnIndices);
}
// Defer the mid-combat encounter restore until M8 wires the combat
// HUD — but keep the body so a re-save round-trips byte-identical.
_pendingEncounterRestore = body.ActiveEncounter;
// Reputation aggregate — mutate the existing instance in place so
// consumers holding a reference (future ReputationScreen / dialogue
// runner) keep working.
var restoredRep = ReputationCodec.Restore(body.ReputationState);
_reputation.Factions.Clear();
foreach (var (k, v) in restoredRep.Factions.Standings) _reputation.Factions.Set(k, v);
_reputation.Personal.Clear();
foreach (var (k, v) in restoredRep.Personal) _reputation.Personal[k] = v;
_reputation.Ledger.Clear();
foreach (var ev in restoredRep.Ledger.Entries) _reputation.Ledger.Append(ev);
_flags.Clear();
foreach (var (k, v) in body.Flags) _flags[k] = v;
QuestCodec.Restore(_questEngine, body.QuestEngineState);
}
private void FlashSavedToast(string text)
{
_saveFlashText = text;
_saveFlashTimer = 2.5f;
}
// ──────────────────────────────────────────────────────────────────────
// Spawn + initial-zoom helpers
private static Vec2 ChooseSpawn(WorldState w)
{
var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
return new Vec2(
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
}
private void SetInitialZoom()
{
Vector2 viewport = GetViewport().GetVisibleRect().Size;
float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
targetZoom = Mathf.Clamp(targetZoom,
_render.Camera.MinZoom,
WorldRenderNode.TacticalRenderZoomMin * 0.95f);
_render.Camera.Zoom = new Vector2(targetZoom, targetZoom);
}
// ──────────────────────────────────────────────────────────────────────
// HUD overlay (top-left panel, codex-styled) + save-flash toast
private void BuildHud()
{
var hudLayer = new CanvasLayer { Layer = 50, Name = "Hud" };
AddChild(hudLayer);
_hudPanel = new PanelContainer
{
ThemeTypeVariation = "Card",
MouseFilter = MouseFilterEnum.Ignore,
OffsetLeft = 12, OffsetTop = 12,
OffsetRight = 420, OffsetBottom = 220,
};
hudLayer.AddChild(_hudPanel);
var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
margin.AddThemeConstantOverride("margin_left", 12);
margin.AddThemeConstantOverride("margin_top", 8);
margin.AddThemeConstantOverride("margin_right", 12);
margin.AddThemeConstantOverride("margin_bottom", 8);
_hudPanel.AddChild(margin);
_hudLabel = new Label
{
Text = "…",
ThemeTypeVariation = "CardBody",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
MouseFilter = MouseFilterEnum.Ignore,
};
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
{
Text = "",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
MouseFilter = MouseFilterEnum.Ignore,
AnchorLeft = 0.5f, AnchorRight = 0.5f,
AnchorTop = 1.0f, AnchorBottom = 1.0f,
OffsetLeft = -180, OffsetRight = 180,
OffsetTop = -56, OffsetBottom = -28,
Visible = false,
};
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!;
int ptx = (int)Mathf.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
int pty = (int)Mathf.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
int cx = Mathf.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1);
int cy = Mathf.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1);
ref var t = ref _ctx.World.TileAt(cx, cy);
string charBlock = "";
if (p.Character is { } pc)
{
int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}\n";
}
string viewBlock = tactical
? "View: Tactical (WASD to step)"
: "View: World Map (WASD to pan · click a tile to travel)";
string status = _controller.IsTraveling
? "Traveling…"
: tactical
? "Mouse-wheel out to leave tactical."
: "Mouse-wheel in for tactical.";
_hudLabel.Text =
charBlock +
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
$"Player: ({ptx}, {pty}) {t.Biome}\n" +
$"{viewBlock}\n" +
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
$"{status}\n" +
"F5 quicksaves · Esc opens pause";
}
// ──────────────────────────────────────────────────────────────────────
// Quit path
private void BackToTitle()
{
var session = GameSession.From(this);
session.ClearPending();
session.Ctx = null;
var parent = GetParent();
if (parent is null) return;
foreach (Node sibling in parent.GetChildren())
if (sibling != this) sibling.QueueFree();
parent.AddChild(new TitleScreen());
QueueFree();
}
}