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:
@@ -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 "<unknown>"
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user