2026-05-09 22:01:51 -07:00
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-05-10 21:19:13 -07:00
|
|
|
|
private const string VersionLabel = "PORT / GODOT · M7.5";
|
2026-05-09 22:01:51 -07:00
|
|
|
|
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);
|
2026-05-10 19:03:18 -07:00
|
|
|
|
continueBtn.Disabled = !AnyCompatibleSaveExists();
|
2026-05-09 22:01:51 -07:00
|
|
|
|
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)
|
2026-05-10 18:07:28 -07:00
|
|
|
|
{
|
|
|
|
|
|
// "← Title" back-button (visible on step 0) emits BackToTitle.
|
2026-05-09 22:01:51 -07:00
|
|
|
|
wizard.BackToTitle += () => SwapBackToTitle(parent);
|
2026-05-10 18:07:28 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-05-09 22:01:51 -07:00
|
|
|
|
QueueFree();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static void SwapBackToTitle(Node parent)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (Node child in parent.GetChildren()) child.QueueFree();
|
|
|
|
|
|
parent.AddChild(new TitleScreen());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 18:07:28 -07:00
|
|
|
|
/// <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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 22:01:51 -07:00
|
|
|
|
private void OnContinue()
|
|
|
|
|
|
{
|
2026-05-10 19:03:18 -07:00
|
|
|
|
var parent = GetParent();
|
|
|
|
|
|
if (parent is null) return;
|
|
|
|
|
|
foreach (Node sibling in parent.GetChildren())
|
|
|
|
|
|
if (sibling != this) sibling.QueueFree();
|
|
|
|
|
|
parent.AddChild(new SaveLoadScreen());
|
|
|
|
|
|
QueueFree();
|
2026-05-09 22:01:51 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void OnQuit() => GetTree().Quit();
|
2026-05-10 19:03:18 -07:00
|
|
|
|
|
|
|
|
|
|
/// <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;
|
|
|
|
|
|
}
|
2026-05-09 22:01:51 -07:00
|
|
|
|
}
|