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