Files
TheriapolisV3/Theriapolis.Godot/Scenes/TitleScreen.cs
T
Christopher Wiebe a802fb318f M7.6: Hostile encounter stub + marker-scale fix — closes M7
EncounterTrigger.FindHostileTrigger now polls each tactical-mode
tick. Edge-detected by NPC actor id: a fresh hostile entering
ENCOUNTER_TRIGGER_TILES range fires the stub exactly once — console
log with name/allegiance/template, save-flash toast "Combat HUD
lands with M8 — encounter logged: {name}". Player keeps moving;
the same hostile won't re-fire until you've left the trigger ring
and come back.

Deviation from the M7 plan §6.6: the plan proposed autosaving on
hostile detect so M8 testing would have fresh combat starts. Wired
that, then walked into a wolf and got a respawn loop — SaveTo →
CaptureBody → _streamer.FlushAll evicts every loaded chunk → NPCs
respawn with fresh actor ids on the next tactical tick → fresh id
breaks edge detection → stub re-fires → autosave again → loop. The
visible symptom was grey untiled chunks and a screen-filling red
blob (an unscaled NPC marker on the spawn frame at zoom 32 renders
at ~307 screen px radius). M8 owns combat-start autosave anyway:
at that point CombatHUDScreen captures combatant state before
FlushAll, so the loop can't form. Removed the SaveTo here; comment
in code records the reason.

Marker scale stamped at construction. NPCs spawn inside _Process
(EnsureLoadedAround → OnChunkLoaded → MountNpcMarker) *after* the
per-frame counter-scale loop has already iterated _npcMarkers, so
a new marker would render at Scale=(1,1) for one frame. New
CounterScaleVec() helper reads the current camera zoom and is
stamped into Scale at marker construction (both PlayerMarker on
spawn/restore and every NpcMarker). The player path also reorders
SetInitialZoom to run BEFORE the player marker constructs so the
counter-scale picks the post-zoom value rather than the fit-zoom
default.

That closes the M7 milestone — title → wizard → worldgen → play →
pause / save / load / dialogue / hostile-stub all wired, only the
M7.4 polish items left were the bugs surfaced in play-testing.
Next: §11.1 cross-build save-bytes parity test (user-driven), then
M8 (combat HUD, inventory, level-up, shop, quest log, dungeon).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:39:01 -07:00

200 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.6";
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;
}
}