Files
TheriapolisV3/Theriapolis.Godot/Scenes/PauseMenuScreen.cs
T
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

307 lines
10 KiB
C#

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;
}
}