Files
TheriapolisV3/Theriapolis.Godot/Scenes/PlayScreen.cs
T
Christopher Wiebe 8e2efdd878 M7.3: Save/load round-trip — F5 quicksave, Continue → slot picker
SavePaths ported verbatim from Theriapolis.Game/Platform/. Same OS
directories as MonoGame (%LOCALAPPDATA%\Theriapolis\Saves on
Windows, ~/Library/Application Support/Theriapolis/Saves on macOS,
$XDG_DATA_HOME/Theriapolis/saves on Linux) so saves round-trip
across the two builds without migration.

PlayScreen save layer. Wired PlayerReputation + Flags + QuestEngine
+ QuestContext + _killedByChunk + _pendingEncounterRestore in
_Ready, even though M7.3 doesn't actively drive any of those —
they're round-trip-required, so a save written by the MonoGame
build with non-empty rep/flags/quest state loads here and re-saves
without data loss. SaveTo/BuildHeader/CaptureBody/ApplyRestoredBody
are field-for-field ports of the MonoGame methods (Phase 5 M3 + M5,
Phase 6 M2 + M4); CaptureBody flushes the streamer first so chunk
deltas land in the store before serialisation. HandleChunkLoaded
now honours _killedByChunk so a killed spawn stays dead across
chunk reload + save round-trip.

F5 quicksaves to the autosave slot. Save-flash toast (bottom-center
Label, fade-out via Modulate.A) confirms each write.

_Ready branches on session.PendingRestore: when set (load path),
calls ApplyRestoredBody and skips the new-game spawn; otherwise
spawns at the Tier-1 anchor with the M6 character. The
mid-combat encounter snapshot is captured on save but the push to
CombatHUDScreen is the M8 stub (logs a console diagnostic).

SaveLoadScreen — load-only slot picker. Header-only deserialise
per row (SaveCodec.DeserializeHeaderOnly reads just the JSON
prefix, body untouched), so opening the picker is cheap even with
many large saves. Slot label matches MonoGame's SlotLabel() format
exactly. Incompatible / unreadable rows render disabled with the
reason inline.

TitleScreen Continue. Enable-gate replaced — was "user://character.json
exists" (M7.1 placeholder), now scans SavesDir for *.trps + checks
SaveCodec.IsCompatible. OnContinue swaps to SaveLoadScreen instead
of the print stub. Manual play-test loop confirmed: F5 in run #1,
quit, relaunch, Continue → Autosave row → progress bar → PlayScreen
with character restored at saved tile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 19:03:18 -07:00

617 lines
24 KiB
C#

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;
/// <summary>
/// M7.2 + M7.3 — the play screen. Wraps <see cref="WorldRenderNode"/>
/// 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): <see cref="SaveTo"/> wraps
/// <see cref="SaveCodec.Serialize"/> + <see cref="SavePaths.WriteAtomic"/>.
/// <see cref="ApplyRestoredBody"/> on init consumes
/// <see cref="GameSession.PendingRestore"/> set by the
/// <see cref="SaveLoadScreen"/> 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.
/// </summary>
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<string, int> _flags = new();
private readonly QuestEngine _questEngine = new();
private QuestContext? _questCtx;
// M7.3 — save round-trip plumbing
private readonly Dictionary<ChunkCoord, HashSet<int>> _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<int, NpcMarker> _npcMarkers = new();
private Label _hudLabel = null!;
private PanelContainer _hudPanel = null!;
private Label? _saveFlashLabel;
// 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),
FacingAngleRad = _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;
// Tactical WASD direction (world-map mode ignores keys — middle-drag
// pans, click-to-travel sets the destination).
float dx = 0f, dy = 0f;
if (tactical)
{
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dy -= 1f;
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dy += 1f;
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dx -= 1f;
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dx += 1f;
}
_controller.Update(dt, dx, dy, tactical, isFocused: true);
// Sync the player marker from Core state.
var p = _actors.Player;
_playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
_playerMarker.FacingAngleRad = 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);
}
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;
switch (key.Keycode)
{
case Key.F5:
SaveTo(SavePaths.AutosavePath());
GetViewport().SetInputAsHandled();
break;
case Key.Escape:
GetViewport().SetInputAsHandled();
BackToTitle();
break;
}
}
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<int>();
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
/// <summary>Write the current state to the given slot path (atomic).</summary>
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;
}
/// <summary>Build a save body from the current PlayScreen state. Mirrors
/// the MonoGame source's <c>CaptureBody</c> field-by-field so the
/// resulting byte stream is interoperable across builds.</summary>
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<string, int>(_flags);
body.QuestEngineState = QuestCodec.Capture(_questEngine);
return body;
}
/// <summary>Restore PlayScreen state from a deserialised body. Caller
/// must have already set <c>_ctx</c>, <c>_content</c>, <c>_streamer</c>,
/// <c>_actors</c>, <c>_clock</c>, and <c>_anchorRegistry</c> — this is
/// invoked from <see cref="_Ready"/> after those are wired but before
/// the player marker is created.</summary>
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<int>(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);
// 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);
}
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 (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 → title (pause menu lands M7.4)";
}
// ──────────────────────────────────────────────────────────────────────
// 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();
}
}