Files
Christopher Wiebe 116193c1e3 M7.4: Pause menu + save-from-pause, slot rows show wall-clock time
PauseMenuScreen — CanvasLayer overlay on PlayScreen, Layer=50 with
ProcessMode=WhenPaused so it stays responsive while the tree is
paused. _Ready sets GetTree().Paused=true; Resume/Close/QuitToTitle
unpause first. Two sub-states share one VBoxContainer-and-status-label
panel: main menu (Resume / ★ Level Up / Save Game / Quicksave / Quit
to Title) and slot picker. Save Game flips to the picker, click a
slot to write, back returns to main. Esc backs out of picker to main
on first press, closes the overlay on a second. CodexTheme applied
at the panel root since overlays mount outside PlayScreen's Control
tree and theme cascade doesn't cross CanvasLayer boundaries.

Level-up button surfaces only when LevelUpFlow.CanLevelUp(pc)
returns true (matches MonoGame), and is rendered disabled with a
"ships with M8" tooltip — fresh L1 characters won't see it in M7
play-tests.

Quit to Title autosaves first (matches MonoGame). A failed autosave
doesn't block the quit; better to let the user leave than trap them.

Save-from-pause writes to an internal status label inside the panel
rather than PlayScreen's save-flash toast — the toast lives on the
paused tree branch and would freeze mid-fade.

PlayScreen Esc now AddChild(new PauseMenuScreen(this)) instead of
BackToTitle. Added paused-guard + Echo filter in _Input. New public
PlayerCharacter() accessor lets the pause panel call CanLevelUp.
HUD hint updated to "F5 quicksaves · Esc opens pause".

SaveSlotFormat — shared helper between SaveLoadScreen (load picker)
and PauseMenuScreen (save picker) so both surfaces render rows with
the same prefix + in-game time + wall-clock time. Parses
SavedAtUtc, converts to local time, renders relative
(today/yesterday), short (MMM d, HH:mm within year), or full
(yyyy-MM-dd HH:mm), with "<unknown>" fallback for empty headers.

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

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 = SaveSlotFormat.FormatRow(label, header);
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();
}
}
}