Compare commits

...

2 Commits

Author SHA1 Message Date
Christopher Wiebe 83c6343783 M6.21: Dark theme parity + card shadow polish + hover/selection fixes
--dark command-line flag swaps CodexTheme.DefaultPalette to Dark
before any UI mounts; both TitleScreen and Wizard pick it up via
the no-arg Build() overload.

Stepper colours track the active palette. ApplyStateColors and the
Active step's gild underline previously read from a stub that
hardcoded parchment values, so the Active label rendered as
brown-black ink against the dark bg (invisible). Both sites now
read CodexTheme.DefaultPalette directly.

Card hover stays applied while the cursor is over an inner Button.
PanelContainer.MouseExited fires when the cursor crosses onto a
child that captures input (Sire/Dam toggles, Sheep/Goat toggles,
trait pickers); the recheck defers and uses GetGlobalRect.HasPoint
on the cursor position so hover only drops when the cursor truly
leaves the card area.

Selection stylebox lands on first refresh. SetSelected was
previously called inside BuildCard before AddChild, so
HasThemeStylebox returned false (theme cascade unreachable) and
the override silently dropped — it only re-attached when
MouseEntered later re-ran Apply. Refactored SetSelected/SetHover
through a new ApplyOrDefer helper that uses CallDeferred when the
card isn't in tree yet, so the seal border + drop shadow appear
immediately on selection rather than only after the first hover.

Selection drop shadow refined. Was a 14px shadow at offset (0,14)
which overlapped the next card by 16px in the v_separation:12
grid. Now offset (4,4) + size 6 — diagonal "light from upper-left"
direction, total reach 10px, leaves a 2px clearance before the
next card so the shadow reads as a shadow on the surface below.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:55:40 -07:00
Christopher Wiebe 2db442be7e M6.20: TitleScreen entry point with parchment-themed button stack
Default boot now lands on a centered title screen instead of the M0
hello-world label. Vertical button stack — New Character, Continue,
Quit — over the codex parchment field, with the H1 codex title and a
PORT / GODOT · M6.20 version chip in the bottom-right.

Continue is disabled until user://character.json exists; clicking it
prints a placeholder until the M7 play loop can pick the persisted
state up. New Character swaps the title for the wizard scene under
the Main parent. The wizard's existing "← Title" back-button on
Step 0 now actually does something — TitleScreen wires its
BackToTitle signal to a parent-side swap that reinstates the title
screen when the player backs out.

--wizard command-line flag still skips straight to the wizard for
fast-path development. Layout uses SetAnchorsAndOffsetsPreset
(LayoutPreset.FullRect) on the backing panel and CenterContainer —
manual AnchorRight = 1 doesn't fill in code because Godot's anchor
setters preserve visual position by adjusting offsets, leaving the
control at 0×0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:01:51 -07:00
6 changed files with 234 additions and 37 deletions
+20 -1
View File
@@ -1,6 +1,7 @@
using Godot; using Godot;
using Theriapolis.GodotHost.Platform; using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.Rendering; using Theriapolis.GodotHost.Rendering;
using Theriapolis.GodotHost.Scenes;
using Theriapolis.GodotHost.UI; using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost; namespace Theriapolis.GodotHost;
@@ -28,6 +29,19 @@ public partial class Main : Control
bool runCodexTest = false; bool runCodexTest = false;
bool runWizard = false; bool runWizard = false;
(ulong seed, int tx, int ty)? tacticalArgs = null; (ulong seed, int tx, int ty)? tacticalArgs = null;
// --dark is independent of the entry-point flags: it sets the codex
// palette default before any UI mounts so both TitleScreen and the
// wizard pick it up via CodexTheme.Build()'s no-arg overload.
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--dark")
{
CodexTheme.DefaultPalette = CodexPalette.Dark;
break;
}
}
for (int i = 0; i < args.Length; i++) for (int i = 0; i < args.Length; i++)
{ {
if (args[i] == "--codex-test") if (args[i] == "--codex-test")
@@ -128,7 +142,12 @@ public partial class Main : Control
return; return;
} }
GD.Print("Theriapolis.Godot host ready (M0 hello-world)."); // Default entry point — TitleScreen. M0's hello-world Label is no
// longer the boot UI; the title swaps itself for the wizard when
// "New Character" is clicked, or shuts the engine down on Quit.
foreach (Node child in GetChildren())
child.QueueFree();
AddChild(new TitleScreen());
} }
public override void _UnhandledInput(InputEvent @event) public override void _UnhandledInput(InputEvent @event)
-14
View File
@@ -7,17 +7,3 @@ anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
script = ExtResource("1_main") script = ExtResource("1_main")
[node name="Label" type="Label" parent="."]
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -200.0
offset_top = -16.0
offset_right = 200.0
offset_bottom = 16.0
horizontal_alignment = 1
vertical_alignment = 1
text = "Theriapolis · Godot port · M0 · F11 toggles fullscreen"
+147
View File
@@ -0,0 +1,147 @@
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 · M6.20";
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 = !FileAccess.FileExists(CharacterAssembler.PersistedStatePath);
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);
// The wizard's "← Title" back-button (visible on step 0) emits
// BackToTitle; reinstate this title screen when that fires.
if (wizardNode is Wizard wizard)
wizard.BackToTitle += () => SwapBackToTitle(parent);
QueueFree();
}
private static void SwapBackToTitle(Node parent)
{
foreach (Node child in parent.GetChildren()) child.QueueFree();
parent.AddChild(new TitleScreen());
}
private void OnContinue()
{
// M7 territory — the play-loop screens that consume the persisted
// character don't exist yet. For now, surface a print so the click
// does something visible and the button isn't dead UI.
GD.Print($"[title] Continue: {CharacterAssembler.PersistedStatePath} exists. "
+ "Play-loop pickup lands with M7.");
}
private void OnQuit() => GetTree().Quit();
}
+39 -4
View File
@@ -21,8 +21,11 @@ public static class CodexCard
/// <summary> /// <summary>
/// Creates a PanelContainer with ThemeTypeVariation = "Card" plus /// Creates a PanelContainer with ThemeTypeVariation = "Card" plus
/// hover signal wiring. The MouseEntered/MouseExited handlers update /// hover signal wiring. MouseEntered marks hover; MouseExited defers
/// the hover meta and re-apply the right stylebox. /// a recheck against the card's global rect so moving the cursor
/// from the card body onto an inner Button (which captures the
/// parent's MouseExited via mouse-filter Stop) does not clear the
/// hover state — the cursor is still visually within the card.
/// </summary> /// </summary>
public static PanelContainer Make() public static PanelContainer Make()
{ {
@@ -32,20 +35,52 @@ public static class CodexCard
MouseFilter = Control.MouseFilterEnum.Stop, MouseFilter = Control.MouseFilterEnum.Stop,
}; };
card.MouseEntered += () => SetHover(card, true); card.MouseEntered += () => SetHover(card, true);
card.MouseExited += () => SetHover(card, false); card.MouseExited += () =>
Callable.From(() => RecheckHover(card)).CallDeferred();
return card; return card;
} }
private static void RecheckHover(PanelContainer card)
{
if (!GodotObject.IsInstanceValid(card)) return;
// Hover stays true as long as the cursor is anywhere within the
// card's rect (including over any child control). Drop only when
// the cursor has truly left the card area.
bool stillOver = card.GetGlobalRect().HasPoint(card.GetGlobalMousePosition());
SetHover(card, stillOver);
}
public static void SetSelected(PanelContainer card, bool selected) public static void SetSelected(PanelContainer card, bool selected)
{ {
card.SetMeta(SelectedMeta, selected); card.SetMeta(SelectedMeta, selected);
Apply(card); ApplyOrDefer(card);
} }
private static void SetHover(PanelContainer card, bool hover) private static void SetHover(PanelContainer card, bool hover)
{ {
card.SetMeta(HoverMeta, hover); card.SetMeta(HoverMeta, hover);
ApplyOrDefer(card);
}
/// <summary>
/// Apply now if the card is already in the scene tree, otherwise defer
/// until end-of-frame so the parent theme cascade is reachable. Step
/// builders call SetSelected on a freshly-created card before
/// AddChild — the theme isn't visible at that point and HasThemeStylebox
/// returns false, which previously meant the override silently dropped
/// and only re-attached when MouseEntered later re-ran Apply.
/// </summary>
private static void ApplyOrDefer(PanelContainer card)
{
if (card.IsInsideTree())
{
Apply(card); Apply(card);
return;
}
Callable.From(() =>
{
if (GodotObject.IsInstanceValid(card)) Apply(card);
}).CallDeferred();
} }
private static void Apply(PanelContainer card) private static void Apply(PanelContainer card)
+18 -4
View File
@@ -29,7 +29,15 @@ public static class CodexTheme
private static FontFile? _mono; private static FontFile? _mono;
private static bool _fontsLoaded; private static bool _fontsLoaded;
public static Theme Build() => Build(CodexPalette.Parchment); /// <summary>
/// Palette used by the no-arg <see cref="Build()"/>. Set this before any
/// UI mounts to swap the active codex palette globally — e.g. Main reads
/// the <c>--dark</c> command-line flag and assigns <see cref="CodexPalette.Dark"/>
/// here. Defaults to <see cref="CodexPalette.Parchment"/>.
/// </summary>
public static CodexPalette DefaultPalette { get; set; } = CodexPalette.Parchment;
public static Theme Build() => Build(DefaultPalette);
public static Theme Build(CodexPalette palette) public static Theme Build(CodexPalette palette)
{ {
@@ -101,9 +109,15 @@ public static class CodexTheme
var cardSelected = (StyleBoxFlat)card.Duplicate(); var cardSelected = (StyleBoxFlat)card.Duplicate();
cardSelected.BorderColor = p.Seal; cardSelected.BorderColor = p.Seal;
cardSelected.SetBorderWidthAll(3); cardSelected.SetBorderWidthAll(3);
cardSelected.ShadowColor = WithAlpha(p.Seal, 0.6f); // Drop shadow: directional (light from upper-left) and sized so the
cardSelected.ShadowSize = 14; // shadow's bottom edge stays clear of the next card. Card grids
cardSelected.ShadowOffset = new Vector2(0, 14); // separate cards by 12px (v_separation in StepClade / StepSpecies /
// StepClass / etc.) — offset.y + size ≤ 11 keeps a 1px-minimum gap
// before the next card so the shadow reads as a shadow on the
// surface below, not as a smudge between cards.
cardSelected.ShadowColor = WithAlpha(p.Seal, 0.55f);
cardSelected.ShadowSize = 6;
cardSelected.ShadowOffset = new Vector2(4, 4);
theme.SetStylebox("panel_selected", "Card", cardSelected); theme.SetStylebox("panel_selected", "Card", cardSelected);
// Popover frame — gild border + soft shadow + rounded corners. // Popover frame — gild border + soft shadow + rounded corners.
+8 -12
View File
@@ -104,7 +104,7 @@ public partial class CodexStepper : HBoxContainer
{ {
var underline = new ColorRect var underline = new ColorRect
{ {
Color = TryGetThemeColor("font_color", "Gild") ?? new Color("#b48a3c"), Color = CodexTheme.DefaultPalette.Gild,
MouseFilter = MouseFilterEnum.Ignore, MouseFilter = MouseFilterEnum.Ignore,
}; };
underline.AnchorTop = 1.0f; underline.AnchorTop = 1.0f;
@@ -124,15 +124,19 @@ public partial class CodexStepper : HBoxContainer
// Default theme colours come from the StepperNum/StepperName variations // Default theme colours come from the StepperNum/StepperName variations
// (ink-mute). State overrides bring active steps to ink and complete // (ink-mute). State overrides bring active steps to ink and complete
// to seal-red. Locked uses the dim default plus reduced opacity. // to seal-red. Locked uses the dim default plus reduced opacity.
// Pull the colours from CodexTheme.DefaultPalette so the stepper
// tracks the active palette (parchment vs dark) instead of forcing
// the parchment values regardless.
var palette = CodexTheme.DefaultPalette;
Color? numColor = state switch Color? numColor = state switch
{ {
StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")), StepState.Active => palette.Ink,
StepState.Complete => TryGetGlobalThemeColor("Seal", new Color("#7a1f12")), StepState.Complete => palette.Seal,
_ => null, _ => null,
}; };
Color? nameColor = state switch Color? nameColor = state switch
{ {
StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")), StepState.Active => palette.Ink,
_ => null, _ => null,
}; };
@@ -149,14 +153,6 @@ public partial class CodexStepper : HBoxContainer
} }
} }
private static Color? TryGetGlobalThemeColor(string name, Color fallback) => fallback;
private Color? TryGetThemeColor(string property, string variation)
{
if (HasThemeColor(property, variation)) return GetThemeColor(property, variation);
return null;
}
private static string Roman(int n) => n switch private static string Roman(int n) => n switch
{ {
1 => "I", 1 => "I",