116193c1e3
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>
307 lines
10 KiB
C#
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;
|
|
}
|
|
}
|