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(); } }