From 116193c1e39704cfc60135971bef358752da495a Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 10 May 2026 19:14:24 -0700 Subject: [PATCH] M7.4: Pause menu + save-from-pause, slot rows show wall-clock time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "" fallback for empty headers. Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/Platform/SaveSlotFormat.cs | 46 +++ Theriapolis.Godot/Scenes/PauseMenuScreen.cs | 306 +++++++++++++++++++ Theriapolis.Godot/Scenes/PlayScreen.cs | 16 +- Theriapolis.Godot/Scenes/SaveLoadScreen.cs | 2 +- Theriapolis.Godot/Scenes/TitleScreen.cs | 2 +- 5 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 Theriapolis.Godot/Platform/SaveSlotFormat.cs create mode 100644 Theriapolis.Godot/Scenes/PauseMenuScreen.cs diff --git a/Theriapolis.Godot/Platform/SaveSlotFormat.cs b/Theriapolis.Godot/Platform/SaveSlotFormat.cs new file mode 100644 index 0000000..f5eb5a9 --- /dev/null +++ b/Theriapolis.Godot/Platform/SaveSlotFormat.cs @@ -0,0 +1,46 @@ +using System; +using System.Globalization; +using Theriapolis.Core.Persistence; + +namespace Theriapolis.GodotHost.Platform; + +/// +/// Slot-picker label formatting. Pulls the in-game time from +/// (e.g. "Howlwind — Y0 Spring D5 +/// (Tier 1)") and appends the wall-clock saved-at time parsed from +/// , rendered in the player's local +/// timezone with a relative label when recent. +/// +/// Shared between (load picker +/// from Title) and 's save picker +/// so both surfaces present the same row format. +/// +public static class SaveSlotFormat +{ + /// Composed row label: "{slot} — {in-game} · saved {when}". + public static string FormatRow(string slotPrefix, SaveHeader header) + => $"{slotPrefix} — {header.SlotLabel()} · saved {FormatSavedAt(header.SavedAtUtc)}"; + + /// Parses the SaveHeader's UTC saved-at timestamp and + /// renders it relative to now, in local time. Returns "<unknown>" + /// for empty / unparseable inputs so the row still shows something. + public static string FormatSavedAt(string savedAtUtc) + { + if (string.IsNullOrWhiteSpace(savedAtUtc)) return ""; + 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); + } +} diff --git a/Theriapolis.Godot/Scenes/PauseMenuScreen.cs b/Theriapolis.Godot/Scenes/PauseMenuScreen.cs new file mode 100644 index 0000000..023735f --- /dev/null +++ b/Theriapolis.Godot/Scenes/PauseMenuScreen.cs @@ -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; + +/// +/// M7.4 — pause overlay. Pushed by PlayScreen when Esc is pressed. +/// Halts the game clock via GetTree().Paused = true so the +/// player + streamer + controller all freeze; the overlay itself +/// runs with ProcessModeEnum.WhenPaused so it stays +/// responsive to input. +/// +/// Two sub-states: shows the main menu +/// (Resume / Level Up / Save Game / Quicksave / Quit). Clicking +/// "Save Game" flips to — a per-slot +/// row list that calls back into . +/// 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). +/// +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 += " — "; } + } + else + { + label += " — "; + } + + 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; + } +} diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs index 697ebc3..b07397d 100644 --- a/Theriapolis.Godot/Scenes/PlayScreen.cs +++ b/Theriapolis.Godot/Scenes/PlayScreen.cs @@ -269,6 +269,12 @@ public partial class PlayScreen : Control public override void _Input(InputEvent @event) { 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) { case Key.F5: @@ -277,11 +283,17 @@ public partial class PlayScreen : Control break; case Key.Escape: GetViewport().SetInputAsHandled(); - BackToTitle(); + AddChild(new PauseMenuScreen(this)); break; } } + /// Read-only accessor for the live player Character — used + /// by to surface the level-up affordance + /// when eligible. + public Theriapolis.Core.Rules.Character.Character? PlayerCharacter() + => _actors?.Player?.Character; + private Vector2 ScreenToWorld(Vector2 screenPos) => _render.Camera.GetCanvasTransform().AffineInverse() * screenPos; @@ -594,7 +606,7 @@ public partial class PlayScreen : Control $"{viewBlock}\n" + $"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" + $"{status}\n" + - "F5 quicksaves · Esc → title (pause menu lands M7.4)"; + "F5 quicksaves · Esc opens pause"; } // ────────────────────────────────────────────────────────────────────── diff --git a/Theriapolis.Godot/Scenes/SaveLoadScreen.cs b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs index 115a0e8..d4b31cd 100644 --- a/Theriapolis.Godot/Scenes/SaveLoadScreen.cs +++ b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs @@ -86,7 +86,7 @@ public partial class SaveLoadScreen : Control var header = SaveCodec.DeserializeHeaderOnly(bytes); if (SaveCodec.IsCompatible(header)) { - text = $"{label} — {header.SlotLabel()}"; + text = SaveSlotFormat.FormatRow(label, header); clickable = true; } else diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs index aff246d..50ae3ce 100644 --- a/Theriapolis.Godot/Scenes/TitleScreen.cs +++ b/Theriapolis.Godot/Scenes/TitleScreen.cs @@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes; /// 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"; public override void _Ready()