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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
=> _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";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
|
||||
/// </summary>
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user