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>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Theriapolis.GodotHost.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// OS-aware save directory resolution. Direct port of
|
||||
/// <c>Theriapolis.Game/Platform/SavePaths.cs</c>; deliberately uses the
|
||||
/// same directories as the MonoGame build so saves are interoperable
|
||||
/// across the two ports.
|
||||
///
|
||||
/// Locations:
|
||||
/// Windows: <c>%LOCALAPPDATA%\Theriapolis\Saves\</c>
|
||||
/// macOS: <c>~/Library/Application Support/Theriapolis/Saves/</c>
|
||||
/// Linux: <c>$XDG_DATA_HOME/Theriapolis/saves/</c> (default
|
||||
/// <c>~/.local/share/Theriapolis/saves/</c>)
|
||||
/// </summary>
|
||||
public static class SavePaths
|
||||
{
|
||||
/// <summary>Top-level Theriapolis save directory. Created on first
|
||||
/// call if missing.</summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>Atomic-rename file write so a crash mid-save can't
|
||||
/// corrupt the slot.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// M7.2 — the play screen. Wraps <see cref="WorldRenderNode"/> 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 <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).
|
||||
///
|
||||
/// 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): <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.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.
|
||||
/// </summary>
|
||||
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<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!;
|
||||
@@ -56,6 +77,7 @@ public partial class PlayScreen : Control
|
||||
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).
|
||||
@@ -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
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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..<see cref="C.SAVE_SLOT_COUNT"/>; reads each
|
||||
/// slot's header (cheap) for the label and disables incompatible /
|
||||
/// unreadable rows.
|
||||
///
|
||||
/// On slot click: deserialise, stash the body + header + seed on
|
||||
/// <see cref="GameSession"/>, swap to <see cref="WorldGenProgressScreen"/>
|
||||
/// which will hand off to <see cref="PlayScreen"/> 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.
|
||||
/// </summary>
|
||||
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} — <v{header.Version}: {SaveCodec.IncompatibilityReason(header)}>";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
text = $"{label} — <unreadable: {ex.Message}>";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
text = $"{label} — <empty>";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>True iff at least one slot under <see cref="Platform.SavePaths.SavesDir"/>
|
||||
/// has a header that <see cref="Theriapolis.Core.Persistence.SaveCodec.IsCompatible"/>
|
||||
/// accepts. Cheap: <see cref="Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly"/>
|
||||
/// reads only the JSON prefix, not the binary body.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user