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.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Godot;
|
using Godot;
|
||||||
using Theriapolis.Core;
|
using Theriapolis.Core;
|
||||||
using Theriapolis.Core.Data;
|
using Theriapolis.Core.Data;
|
||||||
using Theriapolis.Core.Entities;
|
using Theriapolis.Core.Entities;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
using Theriapolis.Core.Rules.Combat;
|
using Theriapolis.Core.Rules.Combat;
|
||||||
|
using Theriapolis.Core.Rules.Quests;
|
||||||
|
using Theriapolis.Core.Rules.Reputation;
|
||||||
using Theriapolis.Core.Tactical;
|
using Theriapolis.Core.Tactical;
|
||||||
using Theriapolis.Core.Time;
|
using Theriapolis.Core.Time;
|
||||||
using Theriapolis.Core.Util;
|
using Theriapolis.Core.Util;
|
||||||
@@ -19,22 +24,25 @@ using Theriapolis.GodotHost.UI;
|
|||||||
namespace Theriapolis.GodotHost.Scenes;
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// M7.2 — the play screen. Wraps <see cref="WorldRenderNode"/> with the
|
/// M7.2 + M7.3 — the play screen. Wraps <see cref="WorldRenderNode"/>
|
||||||
/// game-state layer: player actor, world clock, chunk streamer, NPC
|
/// with the game-state layer: player actor, world clock, chunk
|
||||||
/// markers, player controller, and a top-left HUD overlay. Click on the
|
/// streamer, NPC markers, player controller, save layer, and a
|
||||||
/// world map to travel; WASD to step at tactical zoom.
|
/// 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
|
/// Save round-trip (M7.3): <see cref="SaveTo"/> wraps
|
||||||
/// can serialise its delta store) and the actor manager (so M7.5 can
|
/// <see cref="SaveCodec.Serialize"/> + <see cref="SavePaths.WriteAtomic"/>.
|
||||||
/// drive interact prompts). The world-map view and the tactical view
|
/// <see cref="ApplyRestoredBody"/> on init consumes
|
||||||
/// are the same scene at different zoom levels — there is no separate
|
/// <see cref="GameSession.PendingRestore"/> set by the
|
||||||
/// WorldMapScreen, by design.
|
/// <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):
|
/// M7 sub-milestone status:
|
||||||
/// - Save / load round-trip (M7.3)
|
/// M7.4 (pause menu) — Esc still does quit-to-title for now.
|
||||||
/// - Pause menu (M7.4)
|
/// M7.5 (interact prompt) — F-to-talk not yet wired.
|
||||||
/// - Interact prompt + dialogue push (M7.5)
|
/// M7.6 (encounter trigger stub) — hostile detection not yet wired.
|
||||||
/// - Encounter detection stub + autosave toast (M7.6)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class PlayScreen : Control
|
public partial class PlayScreen : Control
|
||||||
{
|
{
|
||||||
@@ -49,6 +57,19 @@ public partial class PlayScreen : Control
|
|||||||
private WorldClock _clock = null!;
|
private WorldClock _clock = null!;
|
||||||
private PlayerController _controller = null!;
|
private PlayerController _controller = null!;
|
||||||
private AnchorRegistry _anchorRegistry = 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
|
// Godot tree
|
||||||
private WorldRenderNode _render = null!;
|
private WorldRenderNode _render = null!;
|
||||||
@@ -56,6 +77,7 @@ public partial class PlayScreen : Control
|
|||||||
private readonly Dictionary<int, NpcMarker> _npcMarkers = new();
|
private readonly Dictionary<int, NpcMarker> _npcMarkers = new();
|
||||||
private Label _hudLabel = null!;
|
private Label _hudLabel = null!;
|
||||||
private PanelContainer _hudPanel = null!;
|
private PanelContainer _hudPanel = null!;
|
||||||
|
private Label? _saveFlashLabel;
|
||||||
|
|
||||||
// Click-vs-drag state (left-click only; PanZoomCamera handles
|
// Click-vs-drag state (left-click only; PanZoomCamera handles
|
||||||
// middle/right-drag pan independently).
|
// middle/right-drag pan independently).
|
||||||
@@ -98,8 +120,18 @@ public partial class PlayScreen : Control
|
|||||||
_anchorRegistry = new AnchorRegistry();
|
_anchorRegistry = new AnchorRegistry();
|
||||||
_anchorRegistry.RegisterAllAnchors(_ctx.World);
|
_anchorRegistry.RegisterAllAnchors(_ctx.World);
|
||||||
|
|
||||||
// Spawn player at the Tier-1 anchor (Millhaven), or the centre of
|
// Phase 6 M4 — quest context wraps content/actors/rep/flags/clock/
|
||||||
// the world if no inhabited settlement exists.
|
// 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);
|
var spawn = ChooseSpawn(_ctx.World);
|
||||||
if (session.PendingCharacter is not null)
|
if (session.PendingCharacter is not null)
|
||||||
{
|
{
|
||||||
@@ -111,6 +143,7 @@ public partial class PlayScreen : Control
|
|||||||
{
|
{
|
||||||
_actors.SpawnPlayer(spawn);
|
_actors.SpawnPlayer(spawn);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
||||||
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
||||||
@@ -127,6 +160,14 @@ public partial class PlayScreen : Control
|
|||||||
SetInitialZoom();
|
SetInitialZoom();
|
||||||
BuildHud();
|
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.
|
// Clear pending so a quit-to-title doesn't see stale data.
|
||||||
session.PendingCharacter = null;
|
session.PendingCharacter = null;
|
||||||
session.PendingName = "Wanderer";
|
session.PendingName = "Wanderer";
|
||||||
@@ -178,6 +219,22 @@ public partial class PlayScreen : Control
|
|||||||
marker.Scale = new Vector2(inv, inv);
|
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);
|
UpdateHud(tactical);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +253,6 @@ public partial class PlayScreen : Control
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release.
|
|
||||||
if (!_mouseDownTracked) return;
|
if (!_mouseDownTracked) return;
|
||||||
_mouseDownTracked = false;
|
_mouseDownTracked = false;
|
||||||
bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
|
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)
|
private Vector2 ScreenToWorld(Vector2 screenPos)
|
||||||
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
|
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
|
||||||
|
|
||||||
@@ -222,8 +294,12 @@ public partial class PlayScreen : Control
|
|||||||
private void HandleChunkLoaded(TacticalChunk chunk)
|
private void HandleChunkLoaded(TacticalChunk chunk)
|
||||||
{
|
{
|
||||||
if (_content is null) return;
|
if (_content is null) return;
|
||||||
|
_killedByChunk.TryGetValue(chunk.Coord, out var killed);
|
||||||
for (int i = 0; i < chunk.Spawns.Count; i++)
|
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];
|
var spawn = chunk.Spawns[i];
|
||||||
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
|
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
|
||||||
|
|
||||||
@@ -277,6 +353,140 @@ public partial class PlayScreen : Control
|
|||||||
_npcMarkers[npc.Id] = 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
|
// Spawn + initial-zoom helpers
|
||||||
|
|
||||||
@@ -293,7 +503,6 @@ public partial class PlayScreen : Control
|
|||||||
|
|
||||||
private void SetInitialZoom()
|
private void SetInitialZoom()
|
||||||
{
|
{
|
||||||
// Frame ~24 tiles across the screen — comfortable overland zoom.
|
|
||||||
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
||||||
float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
|
float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
|
||||||
targetZoom = Mathf.Clamp(targetZoom,
|
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()
|
private void BuildHud()
|
||||||
{
|
{
|
||||||
@@ -334,6 +543,22 @@ public partial class PlayScreen : Control
|
|||||||
MouseFilter = MouseFilterEnum.Ignore,
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
};
|
};
|
||||||
margin.AddChild(_hudLabel);
|
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)
|
private void UpdateHud(bool tactical)
|
||||||
@@ -369,20 +594,11 @@ public partial class PlayScreen : Control
|
|||||||
$"{viewBlock}\n" +
|
$"{viewBlock}\n" +
|
||||||
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
|
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
|
||||||
$"{status}\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)
|
// Quit path
|
||||||
|
|
||||||
public override void _Input(InputEvent @event)
|
|
||||||
{
|
|
||||||
if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
|
|
||||||
{
|
|
||||||
GetViewport().SetInputAsHandled();
|
|
||||||
BackToTitle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BackToTitle()
|
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>
|
/// </summary>
|
||||||
public partial class TitleScreen : Control
|
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";
|
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
@@ -70,7 +70,7 @@ public partial class TitleScreen : Control
|
|||||||
buttonStack.AddChild(newBtn);
|
buttonStack.AddChild(newBtn);
|
||||||
|
|
||||||
var continueBtn = MakeMenuButton("Continue", primary: false);
|
var continueBtn = MakeMenuButton("Continue", primary: false);
|
||||||
continueBtn.Disabled = !FileAccess.FileExists(CharacterAssembler.PersistedStatePath);
|
continueBtn.Disabled = !AnyCompatibleSaveExists();
|
||||||
continueBtn.Pressed += OnContinue;
|
continueBtn.Pressed += OnContinue;
|
||||||
buttonStack.AddChild(continueBtn);
|
buttonStack.AddChild(continueBtn);
|
||||||
|
|
||||||
@@ -162,12 +162,38 @@ public partial class TitleScreen : Control
|
|||||||
|
|
||||||
private void OnContinue()
|
private void OnContinue()
|
||||||
{
|
{
|
||||||
// M7 territory — the play-loop screens that consume the persisted
|
var parent = GetParent();
|
||||||
// character don't exist yet. For now, surface a print so the click
|
if (parent is null) return;
|
||||||
// does something visible and the button isn't dead UI.
|
foreach (Node sibling in parent.GetChildren())
|
||||||
GD.Print($"[title] Continue: {CharacterAssembler.PersistedStatePath} exists. "
|
if (sibling != this) sibling.QueueFree();
|
||||||
+ "Play-loop pickup lands with M7.");
|
parent.AddChild(new SaveLoadScreen());
|
||||||
|
QueueFree();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnQuit() => GetTree().Quit();
|
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