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>
This commit is contained in:
Christopher Wiebe
2026-05-10 19:14:24 -07:00
parent 8e2efdd878
commit 116193c1e3
5 changed files with 368 additions and 4 deletions
@@ -0,0 +1,46 @@
using System;
using System.Globalization;
using Theriapolis.Core.Persistence;
namespace Theriapolis.GodotHost.Platform;
/// <summary>
/// Slot-picker label formatting. Pulls the in-game time from
/// <see cref="SaveHeader.SlotLabel"/> (e.g. "Howlwind — Y0 Spring D5
/// (Tier 1)") and appends the wall-clock saved-at time parsed from
/// <see cref="SaveHeader.SavedAtUtc"/>, rendered in the player's local
/// timezone with a relative label when recent.
///
/// Shared between <see cref="Scenes.SaveLoadScreen"/> (load picker
/// from Title) and <see cref="Scenes.PauseMenuScreen"/>'s save picker
/// so both surfaces present the same row format.
/// </summary>
public static class SaveSlotFormat
{
/// <summary>Composed row label: "{slot} — {in-game} · saved {when}".</summary>
public static string FormatRow(string slotPrefix, SaveHeader header)
=> $"{slotPrefix} — {header.SlotLabel()} · saved {FormatSavedAt(header.SavedAtUtc)}";
/// <summary>Parses the SaveHeader's UTC saved-at timestamp and
/// renders it relative to now, in local time. Returns "&lt;unknown&gt;"
/// for empty / unparseable inputs so the row still shows something.</summary>
public static string FormatSavedAt(string savedAtUtc)
{
if (string.IsNullOrWhiteSpace(savedAtUtc)) return "<unknown>";
if (!DateTime.TryParse(
savedAtUtc, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var utc))
return savedAtUtc;
DateTime local = utc.ToLocalTime();
DateTime now = DateTime.Now;
if (local.Date == now.Date)
return $"today, {local:HH:mm}";
if (local.Date == now.Date.AddDays(-1))
return $"yesterday, {local:HH:mm}";
if (local.Year == now.Year)
return local.ToString("MMM d, HH:mm", CultureInfo.InvariantCulture);
return local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
}
+306
View File
@@ -0,0 +1,306 @@
using System;
using System.IO;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// M7.4 — pause overlay. Pushed by PlayScreen when Esc is pressed.
/// Halts the game clock via <c>GetTree().Paused = true</c> so the
/// player + streamer + controller all freeze; the overlay itself
/// runs with <c>ProcessModeEnum.WhenPaused</c> so it stays
/// responsive to input.
///
/// Two sub-states: <see cref="BuildMain"/> shows the main menu
/// (Resume / Level Up / Save Game / Quicksave / Quit). Clicking
/// "Save Game" flips to <see cref="BuildSlotPicker"/> — a per-slot
/// row list that calls back into <see cref="PlayScreen.SaveTo"/>.
/// Esc backs out of the slot picker to main, then closes the
/// overlay on a second press.
///
/// Save-from-pause shows a status line inside the panel (separate
/// from PlayScreen's save-flash toast, which is suppressed while
/// the tree is paused).
/// </summary>
public partial class PauseMenuScreen : CanvasLayer
{
private readonly PlayScreen _playScreen;
private Control _root = null!;
private PanelContainer _panel = null!;
private VBoxContainer _content = null!;
private Label _statusLabel = null!;
private bool _showingSlots;
// Edge-detection for the Esc that opened the overlay — without this
// the same press both opens AND closes the menu on the next frame.
private bool _consumedOpeningEsc;
public PauseMenuScreen(PlayScreen playScreen)
{
_playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen));
}
public override void _Ready()
{
Layer = 50;
ProcessMode = ProcessModeEnum.WhenPaused;
GetTree().Paused = true;
_root = new Control
{
MouseFilter = Control.MouseFilterEnum.Stop,
ProcessMode = ProcessModeEnum.WhenPaused,
};
_root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
AddChild(_root);
// Half-opaque scrim so the world reads as backgrounded.
var scrim = new ColorRect
{
Color = new Color(0, 0, 0, 0.55f),
MouseFilter = Control.MouseFilterEnum.Ignore,
};
scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
_root.AddChild(scrim);
var center = new CenterContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
center.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
_root.AddChild(center);
// Apply the codex theme on the panel root so the cascade reaches
// every button (overlays mount outside the PlayScreen Control tree,
// so the parent theme doesn't cascade automatically).
_panel = new PanelContainer
{
ThemeTypeVariation = "Card",
Theme = CodexTheme.Build(),
CustomMinimumSize = new Vector2(360, 0),
};
center.AddChild(_panel);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_left", 28);
margin.AddThemeConstantOverride("margin_right", 28);
margin.AddThemeConstantOverride("margin_top", 20);
margin.AddThemeConstantOverride("margin_bottom", 20);
_panel.AddChild(margin);
_content = new VBoxContainer();
_content.AddThemeConstantOverride("separation", 10);
margin.AddChild(_content);
BuildMain();
}
private void BuildMain()
{
_showingSlots = false;
ClearContent();
_content.AddChild(new Label
{
Text = "PAUSED",
ThemeTypeVariation = "H2",
HorizontalAlignment = HorizontalAlignment.Center,
});
AddSpacer(8);
var resume = MakeMenuButton("Resume", primary: true);
resume.Pressed += Close;
_content.AddChild(resume);
// Level-up affordance — only when eligible. Disabled in M7 with a
// tooltip; the actual LevelUpScreen ships with M8.
var pc = _playScreen.PlayerCharacter();
if (pc is not null && LevelUpFlow.CanLevelUp(pc))
{
var lvlBtn = MakeMenuButton($"★ Level Up ({pc.Level} → {pc.Level + 1})", primary: false);
lvlBtn.Disabled = true;
lvlBtn.TooltipText = "Level-up screen ships with M8.";
_content.AddChild(lvlBtn);
}
var saveBtn = MakeMenuButton("Save Game", primary: false);
saveBtn.Pressed += BuildSlotPicker;
_content.AddChild(saveBtn);
var quickSave = MakeMenuButton("Quicksave (autosave slot)", primary: false);
quickSave.Pressed += OnQuicksave;
_content.AddChild(quickSave);
var quitBtn = MakeMenuButton("Quit to Title", primary: false);
quitBtn.Pressed += OnQuitToTitle;
_content.AddChild(quitBtn);
AddSpacer(6);
_statusLabel = new Label
{
Text = " ",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
};
_content.AddChild(_statusLabel);
}
private void BuildSlotPicker()
{
_showingSlots = true;
ClearContent();
_content.AddChild(new Label
{
Text = "SAVE TO SLOT",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
AddSpacer(4);
for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
{
int slotNum = i;
string path = SavePaths.SlotPath(slotNum);
string prefix = $"Slot {slotNum:D2}";
string label = prefix;
if (File.Exists(path))
{
try
{
var bytes = File.ReadAllBytes(path);
var header = SaveCodec.DeserializeHeaderOnly(bytes);
label = SaveSlotFormat.FormatRow(prefix, header);
}
catch { label += " — <unreadable>"; }
}
else
{
label += " — <empty>";
}
var btn = new Button
{
Text = label,
CustomMinimumSize = new Vector2(0, 36),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
Alignment = HorizontalAlignment.Left,
};
btn.Pressed += () => OnSaveToSlot(slotNum, path);
_content.AddChild(btn);
}
AddSpacer(6);
var back = MakeMenuButton("← Back", primary: false);
back.Pressed += BuildMain;
_content.AddChild(back);
_statusLabel = new Label
{
Text = " ",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
};
_content.AddChild(_statusLabel);
}
private void OnQuicksave()
{
bool ok = _playScreen.SaveTo(SavePaths.AutosavePath());
ShowStatus(ok ? "Quicksaved." : "Quicksave failed.");
}
private void OnSaveToSlot(int slotNum, string path)
{
bool ok = _playScreen.SaveTo(path);
if (ok)
{
BuildMain();
ShowStatus($"Saved to slot {slotNum:D2}.");
}
else
{
ShowStatus("Save failed.");
}
}
private void OnQuitToTitle()
{
// Autosave on quit-to-title, matching MonoGame behaviour. A failed
// autosave doesn't block the quit — better to let the user leave
// than trap them with an angry dialog.
_playScreen.SaveTo(SavePaths.AutosavePath());
GetTree().Paused = false;
var playParent = _playScreen.GetParent();
if (playParent is not null)
{
foreach (Node child in playParent.GetChildren())
child.QueueFree();
playParent.AddChild(new TitleScreen());
}
QueueFree();
}
private void Close()
{
GetTree().Paused = false;
QueueFree();
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event is not InputEventKey { Pressed: true, Keycode: Key.Escape } key) return;
if (key.Echo) return;
// The same physical Esc press that *opened* this overlay produces
// exactly one event. We're routed via _UnhandledInput on the layer,
// which dispatches AFTER PlayScreen.Input has run + consumed the
// event with SetInputAsHandled — so this branch fires on the NEXT
// Esc press. The _consumedOpeningEsc guard is a belt-and-braces
// for the case where input routing skips the consumed flag.
if (!_consumedOpeningEsc)
{
_consumedOpeningEsc = true;
return;
}
GetViewport().SetInputAsHandled();
if (_showingSlots) BuildMain();
else Close();
}
// ──────────────────────────────────────────────────────────────────────
// Layout helpers
private void ClearContent()
{
foreach (Node child in _content.GetChildren()) child.QueueFree();
}
private void AddSpacer(int height)
=> _content.AddChild(new Control { CustomMinimumSize = new Vector2(0, height) });
private static Button MakeMenuButton(string text, bool primary)
{
var btn = new Button
{
Text = text,
FocusMode = Control.FocusModeEnum.None,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 40),
};
if (primary) btn.ThemeTypeVariation = "PrimaryButton";
return btn;
}
private void ShowStatus(string text)
{
if (_statusLabel is not null) _statusLabel.Text = text;
}
}
+14 -2
View File
@@ -269,6 +269,12 @@ public partial class PlayScreen : Control
public override void _Input(InputEvent @event) public override void _Input(InputEvent @event)
{ {
if (@event is not InputEventKey { Pressed: true } key) return; if (@event is not InputEventKey { Pressed: true } key) return;
if (key.Echo) return;
// Skip key events while the game is paused — the pause overlay
// owns input handling for itself; PlayScreen shouldn't see Esc/F5
// again until the overlay closes.
if (GetTree().Paused) return;
switch (key.Keycode) switch (key.Keycode)
{ {
case Key.F5: case Key.F5:
@@ -277,11 +283,17 @@ public partial class PlayScreen : Control
break; break;
case Key.Escape: case Key.Escape:
GetViewport().SetInputAsHandled(); GetViewport().SetInputAsHandled();
BackToTitle(); AddChild(new PauseMenuScreen(this));
break; break;
} }
} }
/// <summary>Read-only accessor for the live player Character — used
/// by <see cref="PauseMenuScreen"/> to surface the level-up affordance
/// when eligible.</summary>
public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
=> _actors?.Player?.Character;
private Vector2 ScreenToWorld(Vector2 screenPos) private Vector2 ScreenToWorld(Vector2 screenPos)
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos; => _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
@@ -594,7 +606,7 @@ 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" +
"F5 quicksaves · Esc → title (pause menu lands M7.4)"; "F5 quicksaves · Esc opens pause";
} }
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -86,7 +86,7 @@ public partial class SaveLoadScreen : Control
var header = SaveCodec.DeserializeHeaderOnly(bytes); var header = SaveCodec.DeserializeHeaderOnly(bytes);
if (SaveCodec.IsCompatible(header)) if (SaveCodec.IsCompatible(header))
{ {
text = $"{label} — {header.SlotLabel()}"; text = SaveSlotFormat.FormatRow(label, header);
clickable = true; clickable = true;
} }
else else
+1 -1
View File
@@ -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.3"; private const string VersionLabel = "PORT / GODOT · M7.4";
private const string WizardScenePath = "res://Scenes/Wizard.tscn"; private const string WizardScenePath = "res://Scenes/Wizard.tscn";
public override void _Ready() public override void _Ready()