diff --git a/Theriapolis.Godot/Platform/SavePaths.cs b/Theriapolis.Godot/Platform/SavePaths.cs
new file mode 100644
index 0000000..9582be4
--- /dev/null
+++ b/Theriapolis.Godot/Platform/SavePaths.cs
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace Theriapolis.GodotHost.Platform;
+
+///
+/// OS-aware save directory resolution. Direct port of
+/// Theriapolis.Game/Platform/SavePaths.cs; deliberately uses the
+/// same directories as the MonoGame build so saves are interoperable
+/// across the two ports.
+///
+/// Locations:
+/// Windows: %LOCALAPPDATA%\Theriapolis\Saves\
+/// macOS: ~/Library/Application Support/Theriapolis/Saves/
+/// Linux: $XDG_DATA_HOME/Theriapolis/saves/ (default
+/// ~/.local/share/Theriapolis/saves/)
+///
+public static class SavePaths
+{
+ /// Top-level Theriapolis save directory. Created on first
+ /// call if missing.
+ public static string SavesDir
+ {
+ get
+ {
+ string dir = ResolveBase();
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+ }
+
+ public static string SlotPath(int slot) => Path.Combine(SavesDir, $"slot_{slot:D2}.trps");
+ public static string AutosavePath() => Path.Combine(SavesDir, "autosave.trps");
+
+ private static string ResolveBase()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Theriapolis", "Saves");
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Library", "Application Support", "Theriapolis", "Saves");
+ // Linux + others: respect XDG_DATA_HOME, fall back to ~/.local/share.
+ string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? "";
+ if (string.IsNullOrEmpty(xdg))
+ xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".local", "share");
+ return Path.Combine(xdg, "Theriapolis", "saves");
+ }
+
+ /// Atomic-rename file write so a crash mid-save can't
+ /// corrupt the slot.
+ public static void WriteAtomic(string path, byte[] bytes)
+ {
+ string dir = Path.GetDirectoryName(path)!;
+ Directory.CreateDirectory(dir);
+ string tmp = path + ".tmp";
+ File.WriteAllBytes(tmp, bytes);
+ if (File.Exists(path)) File.Replace(tmp, path, destinationBackupFileName: null);
+ else File.Move(tmp, path);
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs
index 80656fd..697ebc3 100644
--- a/Theriapolis.Godot/Scenes/PlayScreen.cs
+++ b/Theriapolis.Godot/Scenes/PlayScreen.cs
@@ -1,10 +1,15 @@
+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;
@@ -19,22 +24,25 @@ using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
///
-/// M7.2 — the play screen. Wraps with the
-/// game-state layer: player actor, world clock, chunk streamer, NPC
-/// markers, player controller, and a top-left HUD overlay. Click on the
-/// world map to travel; WASD to step at tactical zoom.
+/// 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).
///
-/// Per the M7 plan §6: PlayScreen owns the chunk streamer (so M7.3 save
-/// can serialise its delta store) and the actor manager (so M7.5 can
-/// drive interact prompts). The world-map view and the tactical view
-/// are the same scene at different zoom levels — there is no separate
-/// WorldMapScreen, by design.
+/// 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.2 omissions (deferred to later sub-milestones):
-/// - Save / load round-trip (M7.3)
-/// - Pause menu (M7.4)
-/// - Interact prompt + dialogue push (M7.5)
-/// - Encounter detection stub + autosave toast (M7.6)
+/// 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
{
@@ -49,6 +57,19 @@ public partial class PlayScreen : Control
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!;
@@ -56,6 +77,7 @@ public partial class PlayScreen : Control
private readonly Dictionary _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).
@@ -98,18 +120,29 @@ public partial class PlayScreen : Control
_anchorRegistry = new AnchorRegistry();
_anchorRegistry.RegisterAllAnchors(_ctx.World);
- // Spawn player at the Tier-1 anchor (Millhaven), or the centre of
- // the world if no inhabited settlement exists.
- var spawn = ChooseSpawn(_ctx.World);
- if (session.PendingCharacter is not null)
+ // 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)
{
- var p = _actors.SpawnPlayer(spawn, session.PendingCharacter);
- if (!string.IsNullOrWhiteSpace(session.PendingName))
- p.Name = session.PendingName;
+ ApplyRestoredBody(session.PendingRestore);
}
else
{
- _actors.SpawnPlayer(spawn);
+ 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);
@@ -127,6 +160,14 @@ public partial class PlayScreen : Control
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";
@@ -178,6 +219,22 @@ public partial class PlayScreen : Control
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);
}
@@ -196,7 +253,6 @@ public partial class PlayScreen : Control
return;
}
- // Release.
if (!_mouseDownTracked) return;
_mouseDownTracked = false;
bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
@@ -210,6 +266,22 @@ public partial class PlayScreen : Control
}
}
+ 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;
@@ -222,8 +294,12 @@ public partial class PlayScreen : Control
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;
@@ -277,6 +353,140 @@ public partial class PlayScreen : Control
_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
@@ -293,7 +503,6 @@ public partial class PlayScreen : Control
private void SetInitialZoom()
{
- // Frame ~24 tiles across the screen — comfortable overland zoom.
Vector2 viewport = GetViewport().GetVisibleRect().Size;
float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
targetZoom = Mathf.Clamp(targetZoom,
@@ -303,7 +512,7 @@ public partial class PlayScreen : Control
}
// ──────────────────────────────────────────────────────────────────────
- // HUD overlay (top-left panel, codex-styled)
+ // HUD overlay (top-left panel, codex-styled) + save-flash toast
private void BuildHud()
{
@@ -334,6 +543,22 @@ public partial class PlayScreen : Control
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)
@@ -369,20 +594,11 @@ public partial class PlayScreen : Control
$"{viewBlock}\n" +
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
$"{status}\n" +
- "M7.3 brings save/load. ESC pause arrives M7.4.";
+ "F5 quicksaves · Esc → title (pause menu lands M7.4)";
}
// ──────────────────────────────────────────────────────────────────────
- // Quit path (M7.4 will wire ESC → pause; for now Esc returns to title)
-
- public override void _Input(InputEvent @event)
- {
- if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
- {
- GetViewport().SetInputAsHandled();
- BackToTitle();
- }
- }
+ // Quit path
private void BackToTitle()
{
diff --git a/Theriapolis.Godot/Scenes/SaveLoadScreen.cs b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs
new file mode 100644
index 0000000..115a0e8
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs
@@ -0,0 +1,171 @@
+using System;
+using System.IO;
+using Godot;
+using Theriapolis.Core;
+using Theriapolis.Core.Persistence;
+using Theriapolis.GodotHost.Platform;
+using Theriapolis.GodotHost.UI;
+
+namespace Theriapolis.GodotHost.Scenes;
+
+///
+/// M7.3 — slot picker for *load*. Pushed by TitleScreen's "Continue"
+/// when at least one compatible save exists. Lists the autosave row
+/// followed by slots 1..; reads each
+/// slot's header (cheap) for the label and disables incompatible /
+/// unreadable rows.
+///
+/// On slot click: deserialise, stash the body + header + seed on
+/// , swap to
+/// which will hand off to with the
+/// restore-from-save path.
+///
+/// Save-from-pause (write) is M7.4 territory and intentionally lives
+/// in a separate widget — keeps each picker single-purpose.
+///
+public partial class SaveLoadScreen : Control
+{
+ public override void _Ready()
+ {
+ Theme = CodexTheme.Build();
+ SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+
+ var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
+ AddChild(bg);
+ bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+ MoveChild(bg, 0);
+
+ BuildUI();
+ }
+
+ private void BuildUI()
+ {
+ var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
+ AddChild(center);
+ center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+
+ var col = new VBoxContainer { CustomMinimumSize = new Vector2(520, 0) };
+ col.AddThemeConstantOverride("separation", 10);
+ center.AddChild(col);
+
+ col.AddChild(new Label
+ {
+ Text = "LOAD GAME",
+ ThemeTypeVariation = "H2",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+
+ // Autosave row first; numbered slots after.
+ AddSlotRow(col, "Autosave", SavePaths.AutosavePath());
+ for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
+ AddSlotRow(col, $"Slot {i:D2}", SavePaths.SlotPath(i));
+
+ var spacer = new Control { CustomMinimumSize = new Vector2(0, 12) };
+ col.AddChild(spacer);
+
+ var back = new Button
+ {
+ Text = "← Back",
+ CustomMinimumSize = new Vector2(220, 40),
+ SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
+ };
+ back.Pressed += BackToTitle;
+ col.AddChild(back);
+ }
+
+ private void AddSlotRow(VBoxContainer parent, string label, string path)
+ {
+ string text;
+ bool clickable = false;
+ bool exists = File.Exists(path);
+ if (exists)
+ {
+ try
+ {
+ var bytes = File.ReadAllBytes(path);
+ var header = SaveCodec.DeserializeHeaderOnly(bytes);
+ if (SaveCodec.IsCompatible(header))
+ {
+ text = $"{label} — {header.SlotLabel()}";
+ clickable = true;
+ }
+ else
+ {
+ text = $"{label} — ";
+ }
+ }
+ catch (Exception ex)
+ {
+ text = $"{label} — ";
+ }
+ }
+ else
+ {
+ text = $"{label} — ";
+ }
+
+ var btn = new Button
+ {
+ Text = text,
+ CustomMinimumSize = new Vector2(0, 40),
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ Disabled = !clickable,
+ Alignment = HorizontalAlignment.Left,
+ };
+ if (clickable) btn.Pressed += () => LoadSlot(path);
+ parent.AddChild(btn);
+ }
+
+ private void LoadSlot(string path)
+ {
+ try
+ {
+ var bytes = File.ReadAllBytes(path);
+ var (header, body) = SaveCodec.Deserialize(bytes);
+ if (!SaveCodec.IsCompatible(header))
+ {
+ GD.PushError($"[saveload] Refused incompatible save at {path}: "
+ + SaveCodec.IncompatibilityReason(header));
+ return;
+ }
+
+ var session = GameSession.From(this);
+ session.Seed = header.ParseSeed();
+ session.PendingRestore = body;
+ session.PendingHeader = header;
+ session.PendingCharacter = null; // restore path supplies it via body
+
+ // Swap Title → WorldGenProgress (which will swap to PlayScreen
+ // once the pipeline finishes and stage-hash drift is checked).
+ var parent = GetParent();
+ if (parent is null) return;
+ foreach (Node sibling in parent.GetChildren())
+ if (sibling != this) sibling.QueueFree();
+ parent.AddChild(new WorldGenProgressScreen());
+ QueueFree();
+ }
+ catch (Exception ex)
+ {
+ GD.PushError($"[saveload] Failed to load {path}: {ex}");
+ }
+ }
+
+ private void BackToTitle()
+ {
+ var parent = GetParent();
+ if (parent is null) return;
+ foreach (Node sibling in parent.GetChildren())
+ if (sibling != this) sibling.QueueFree();
+ parent.AddChild(new TitleScreen());
+ QueueFree();
+ }
+
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
+ {
+ GetViewport().SetInputAsHandled();
+ BackToTitle();
+ }
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs
index b88fc62..aff246d 100644
--- a/Theriapolis.Godot/Scenes/TitleScreen.cs
+++ b/Theriapolis.Godot/Scenes/TitleScreen.cs
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
///
public partial class TitleScreen : Control
{
- private const string VersionLabel = "PORT / GODOT · M7.2";
+ private const string VersionLabel = "PORT / GODOT · M7.3";
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
public override void _Ready()
@@ -70,7 +70,7 @@ public partial class TitleScreen : Control
buttonStack.AddChild(newBtn);
var continueBtn = MakeMenuButton("Continue", primary: false);
- continueBtn.Disabled = !FileAccess.FileExists(CharacterAssembler.PersistedStatePath);
+ continueBtn.Disabled = !AnyCompatibleSaveExists();
continueBtn.Pressed += OnContinue;
buttonStack.AddChild(continueBtn);
@@ -162,12 +162,38 @@ public partial class TitleScreen : Control
private void OnContinue()
{
- // M7 territory — the play-loop screens that consume the persisted
- // character don't exist yet. For now, surface a print so the click
- // does something visible and the button isn't dead UI.
- GD.Print($"[title] Continue: {CharacterAssembler.PersistedStatePath} exists. "
- + "Play-loop pickup lands with M7.");
+ var parent = GetParent();
+ if (parent is null) return;
+ foreach (Node sibling in parent.GetChildren())
+ if (sibling != this) sibling.QueueFree();
+ parent.AddChild(new SaveLoadScreen());
+ QueueFree();
}
private void OnQuit() => GetTree().Quit();
+
+ /// True iff at least one slot under
+ /// has a header that
+ /// accepts. Cheap:
+ /// reads only the JSON prefix, not the binary body.
+ private static bool AnyCompatibleSaveExists()
+ {
+ try
+ {
+ string dir = Platform.SavePaths.SavesDir;
+ if (!System.IO.Directory.Exists(dir)) return false;
+ foreach (var path in System.IO.Directory.EnumerateFiles(dir, "*.trps"))
+ {
+ try
+ {
+ var bytes = System.IO.File.ReadAllBytes(path);
+ var header = Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly(bytes);
+ if (Theriapolis.Core.Persistence.SaveCodec.IsCompatible(header)) return true;
+ }
+ catch { /* skip broken slot */ }
+ }
+ }
+ catch { /* defensive */ }
+ return false;
+ }
}