8e2efdd878
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>
172 lines
5.5 KiB
C#
172 lines
5.5 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|