8e2efdd878
SavePaths ported verbatim from Theriapolis.Game/Platform/. Same OS directories as MonoGame (%LOCALAPPDATA%\Theriapolis\Saves on Windows, ~/Library/Application Support/Theriapolis/Saves on macOS, $XDG_DATA_HOME/Theriapolis/saves on Linux) so saves round-trip across the two builds without migration. PlayScreen save layer. Wired PlayerReputation + Flags + QuestEngine + QuestContext + _killedByChunk + _pendingEncounterRestore in _Ready, even though M7.3 doesn't actively drive any of those — they're round-trip-required, so a save written by the MonoGame build with non-empty rep/flags/quest state loads here and re-saves without data loss. SaveTo/BuildHeader/CaptureBody/ApplyRestoredBody are field-for-field ports of the MonoGame methods (Phase 5 M3 + M5, Phase 6 M2 + M4); CaptureBody flushes the streamer first so chunk deltas land in the store before serialisation. HandleChunkLoaded now honours _killedByChunk so a killed spawn stays dead across chunk reload + save round-trip. F5 quicksaves to the autosave slot. Save-flash toast (bottom-center Label, fade-out via Modulate.A) confirms each write. _Ready branches on session.PendingRestore: when set (load path), calls ApplyRestoredBody and skips the new-game spawn; otherwise spawns at the Tier-1 anchor with the M6 character. The mid-combat encounter snapshot is captured on save but the push to CombatHUDScreen is the M8 stub (logs a console diagnostic). SaveLoadScreen — load-only slot picker. Header-only deserialise per row (SaveCodec.DeserializeHeaderOnly reads just the JSON prefix, body untouched), so opening the picker is cheap even with many large saves. Slot label matches MonoGame's SlotLabel() format exactly. Incompatible / unreadable rows render disabled with the reason inline. TitleScreen Continue. Enable-gate replaced — was "user://character.json exists" (M7.1 placeholder), now scans SavesDir for *.trps + checks SaveCodec.IsCompatible. OnContinue swaps to SaveLoadScreen instead of the print stub. Manual play-test loop confirmed: F5 in run #1, quit, relaunch, Continue → Autosave row → progress bar → PlayScreen with character restored at saved tile. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
200 lines
7.9 KiB
C#
200 lines
7.9 KiB
C#
using Godot;
|
||
using Theriapolis.GodotHost.UI;
|
||
|
||
namespace Theriapolis.GodotHost.Scenes;
|
||
|
||
/// <summary>
|
||
/// Entry-point screen — vertical button stack on a parchment field with the
|
||
/// codex title and a version label. Per port-plan §M6, exists primarily to
|
||
/// validate the design system in a non-trivial composition before the player
|
||
/// reaches character creation.
|
||
///
|
||
/// Button actions:
|
||
/// New Character — swap self for the Wizard scene under the Main parent
|
||
/// (siblings cleared so the wizard fills the viewport).
|
||
/// Continue — disabled until <see cref="CharacterAssembler.PersistedStatePath"/>
|
||
/// exists; full pickup lands with the M7 play loop.
|
||
/// Quit — shut down the engine.
|
||
/// </summary>
|
||
public partial class TitleScreen : Control
|
||
{
|
||
private const string VersionLabel = "PORT / GODOT · M7.3";
|
||
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
|
||
|
||
public override void _Ready()
|
||
{
|
||
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||
Theme = CodexTheme.Build();
|
||
|
||
// Backing panel so the parchment Bg fills the viewport (the Control
|
||
// itself paints nothing). Same pattern as Wizard.cs.
|
||
// Note: SetAnchorsAndOffsetsPreset is required (not just AnchorRight =
|
||
// 1) because Godot's anchor setters preserve visual position by
|
||
// adjusting offsets — manual anchor edits leave the control at 0×0.
|
||
var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
|
||
AddChild(bg);
|
||
bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||
MoveChild(bg, 0);
|
||
|
||
// Centered title + button stack column.
|
||
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||
AddChild(center);
|
||
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||
|
||
var col = new VBoxContainer { CustomMinimumSize = new Vector2(360, 0) };
|
||
col.AddThemeConstantOverride("separation", 28);
|
||
center.AddChild(col);
|
||
|
||
var titleBlock = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
||
titleBlock.AddThemeConstantOverride("separation", 4);
|
||
col.AddChild(titleBlock);
|
||
titleBlock.AddChild(new Label
|
||
{
|
||
Text = "THERIAPOLIS",
|
||
ThemeTypeVariation = "CodexTitle",
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
});
|
||
titleBlock.AddChild(new Label
|
||
{
|
||
Text = "CODEX OF BECOMING",
|
||
ThemeTypeVariation = "Eyebrow",
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
});
|
||
|
||
var buttonStack = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||
buttonStack.AddThemeConstantOverride("separation", 12);
|
||
col.AddChild(buttonStack);
|
||
|
||
var newBtn = MakeMenuButton("New Character", primary: true);
|
||
newBtn.Pressed += OnNewCharacter;
|
||
buttonStack.AddChild(newBtn);
|
||
|
||
var continueBtn = MakeMenuButton("Continue", primary: false);
|
||
continueBtn.Disabled = !AnyCompatibleSaveExists();
|
||
continueBtn.Pressed += OnContinue;
|
||
buttonStack.AddChild(continueBtn);
|
||
|
||
var quitBtn = MakeMenuButton("Quit", primary: false);
|
||
quitBtn.Pressed += OnQuit;
|
||
buttonStack.AddChild(quitBtn);
|
||
|
||
// Version chip in the bottom-right corner — small mono Eyebrow tag,
|
||
// sits over the parchment field at a comfortable margin.
|
||
var versionLabel = new Label
|
||
{
|
||
Text = VersionLabel,
|
||
ThemeTypeVariation = "Eyebrow",
|
||
AnchorLeft = 1, AnchorRight = 1,
|
||
AnchorTop = 1, AnchorBottom = 1,
|
||
OffsetLeft = -180, OffsetTop = -28,
|
||
OffsetRight = -16, OffsetBottom = -10,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
};
|
||
AddChild(versionLabel);
|
||
}
|
||
|
||
private static Button MakeMenuButton(string text, bool primary)
|
||
{
|
||
var btn = new Button
|
||
{
|
||
Text = text,
|
||
FocusMode = FocusModeEnum.None,
|
||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||
CustomMinimumSize = new Vector2(0, 44),
|
||
};
|
||
if (primary) btn.ThemeTypeVariation = "PrimaryButton";
|
||
return btn;
|
||
}
|
||
|
||
private void OnNewCharacter()
|
||
{
|
||
var packed = ResourceLoader.Load<PackedScene>(WizardScenePath);
|
||
if (packed is null)
|
||
{
|
||
GD.PushError($"[title] Failed to load {WizardScenePath}");
|
||
return;
|
||
}
|
||
var parent = GetParent();
|
||
if (parent is null) return;
|
||
// Clear siblings so the wizard fills the viewport, then swap in.
|
||
foreach (Node sibling in parent.GetChildren())
|
||
if (sibling != this) sibling.QueueFree();
|
||
var wizardNode = packed.Instantiate();
|
||
parent.AddChild(wizardNode);
|
||
if (wizardNode is Wizard wizard)
|
||
{
|
||
// "← Title" back-button (visible on step 0) emits BackToTitle.
|
||
wizard.BackToTitle += () => SwapBackToTitle(parent);
|
||
// M7.1 — Confirm & Begin in StepReview is forwarded by the
|
||
// wizard as CharacterConfirmed. Stash the built character on
|
||
// GameSession and hand off to WorldGenProgressScreen.
|
||
wizard.CharacterConfirmed += draft => SwapToWorldGen(parent, draft);
|
||
}
|
||
QueueFree();
|
||
}
|
||
|
||
private static void SwapBackToTitle(Node parent)
|
||
{
|
||
foreach (Node child in parent.GetChildren()) child.QueueFree();
|
||
parent.AddChild(new TitleScreen());
|
||
}
|
||
|
||
/// <summary>M7.1 hand-off: snapshot the built character + chosen
|
||
/// name onto <see cref="GameSession"/>, default the seed (a seed-entry
|
||
/// UI lands later), and swap to <see cref="WorldGenProgressScreen"/>.</summary>
|
||
private static void SwapToWorldGen(Node parent, UI.CharacterDraft draft)
|
||
{
|
||
var session = GameSession.From(parent);
|
||
// CharacterAssembler.LastBuilt is populated by StepReview's
|
||
// OnConfirmPressed → TryBuild call immediately before the
|
||
// CharacterConfirmed signal fires.
|
||
session.PendingCharacter = CharacterAssembler.LastBuilt;
|
||
session.PendingName = string.IsNullOrWhiteSpace(draft.CharacterName)
|
||
? "Wanderer"
|
||
: draft.CharacterName;
|
||
session.Seed = 12345UL; // default for M7; seed-entry UI is M8+.
|
||
session.PendingRestore = null;
|
||
session.PendingHeader = null;
|
||
|
||
foreach (Node child in parent.GetChildren()) child.QueueFree();
|
||
parent.AddChild(new WorldGenProgressScreen());
|
||
}
|
||
|
||
private void OnContinue()
|
||
{
|
||
var parent = GetParent();
|
||
if (parent is null) return;
|
||
foreach (Node sibling in parent.GetChildren())
|
||
if (sibling != this) sibling.QueueFree();
|
||
parent.AddChild(new SaveLoadScreen());
|
||
QueueFree();
|
||
}
|
||
|
||
private void OnQuit() => GetTree().Quit();
|
||
|
||
/// <summary>True iff at least one slot under <see cref="Platform.SavePaths.SavesDir"/>
|
||
/// has a header that <see cref="Theriapolis.Core.Persistence.SaveCodec.IsCompatible"/>
|
||
/// accepts. Cheap: <see cref="Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly"/>
|
||
/// reads only the JSON prefix, not the binary body.</summary>
|
||
private static bool AnyCompatibleSaveExists()
|
||
{
|
||
try
|
||
{
|
||
string dir = Platform.SavePaths.SavesDir;
|
||
if (!System.IO.Directory.Exists(dir)) return false;
|
||
foreach (var path in System.IO.Directory.EnumerateFiles(dir, "*.trps"))
|
||
{
|
||
try
|
||
{
|
||
var bytes = System.IO.File.ReadAllBytes(path);
|
||
var header = Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly(bytes);
|
||
if (Theriapolis.Core.Persistence.SaveCodec.IsCompatible(header)) return true;
|
||
}
|
||
catch { /* skip broken slot */ }
|
||
}
|
||
}
|
||
catch { /* defensive */ }
|
||
return false;
|
||
}
|
||
}
|