M6.1: Character creation wizard foundation (.tscn + Resource-based draft)

Pivots from M5's code-built UI to the editor-authorable .tscn pattern
recommended in GODOT_PORTING_GUIDE.md, after a session of fighting
Godot idioms with code-only layout. Default theme only; the parchment
Theme lands last per the guide's §12 build order so layout bugs surface
as layout bugs, not theming bugs.

GODOT_PORTING_GUIDE.md:
  Authored by Claude Design as the canonical port reference. Maps the
  React prototype's structure onto Godot 4.6 with concrete code sketches
  and a build-order recommendation. Drove the M6 architecture.

Fonts/:
  Cormorant Garamond (Medium + MediumItalic) and Crimson Pro (Regular +
  Italic + SemiBold) under OFL — the React prototype's serif-display
  and serif-body families. Not yet wired through CodexTheme.Build()
  because theming is deferred; CodexTheme.LoadFontFromFonts already
  picks them up automatically when the Theme pass lands.

Scenes/Wizard.tscn + Wizard.cs:
  Wizard shell per guide §4: codex-header (title + folio counter) +
  Stepper + Page (StepHost + Aside) + NavBar (Back / validation / Next).
  All node lookups via unique-name (%) syntax; layout authored as a
  scene file you can open in the editor. Step lifecycle drives the
  Aside via signal binding. Stepper logic mirrors app.jsx — locked
  iff some EARLIER step is unsatisfied; "type not yet implemented"
  doesn't lock.

Scenes/Aside.tscn + Aside.cs:
  Right-rail summary per guide §10. Single Refresh() rebuild on
  CharacterDraft.Changed; cheap enough not to bother with partial
  updates. Width 320 (was 380 before the layout overflow fix).

Scenes/Steps/IStep.cs + StepClade.cs:
  Per-step Bind(draft) + Validate() contract. StepClade renders the
  3-column clade card grid; click commits via CharacterDraft.Patch
  which triggers the Resource.Changed signal that Aside and Wizard
  both subscribe to.

UI/CharacterDraft.cs:
  Resource (not Node) per guide §2.1. Mirrors app.jsx's `state` shape
  exactly. Patch(dictionary) emits the inherited Resource.Changed
  signal — listeners use `draft.Changed += handler` regardless of
  which field changed. CodexContent provides lazy-loaded immutable
  content tables (Clades, Species, Classes, Subclasses, Backgrounds).

Main.{cs,tscn}: Node → Control
  When Main was a Node, Control children couldn't anchor to a real
  parent rect — they sat at (0,0) at intrinsic min size. With wide
  step content (3-column 200-px-card grid), the Wizard's min size
  pushed the navbar beyond the viewport's right edge, hiding the Next
  button on smaller windowed viewports. Making Main a full-rect-
  anchored Control gives child scenes a proper rect to lay out in.

UI/Widgets/CodexStepper.cs:
  Anchored the inner vbox to fill the button rect. Without this, the
  vbox sat at the button's top-left at intrinsic size and labels
  rendered in the corner — visible as the active-step label being
  off-center from the highlight bar.

Verified at 1152x720 windowed and (separately) at fullscreen:
  - 3-column card grid fits inside Wrap margins + Aside without
    horizontal overflow
  - Stepper labels centered under their highlight bars
  - Next button visible after clade selection; future steps switch
    to "coming soon" placeholder when clicked
  - Aside summary fills in CLADE block on selection

Closes M6.1.  Next per guide §12 build order: M6.2 — StepStats with
drag-drop (highest-risk piece, de-risk before easy steps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-02 19:35:03 -07:00
parent 953bb985ad
commit ee5439285c
18 changed files with 1543 additions and 5 deletions
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2018 The Crimson Pro Project Authors (https://github.com/Fonthausen/CrimsonPro)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+20 -1
View File
@@ -5,7 +5,11 @@ using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost;
public partial class Main : Node
// Control (not Node) so child Control scenes (Wizard, KitchenSink, etc.)
// can anchor to a real rect that fills the viewport. With a plain Node
// parent, anchors are ignored and Controls sit at (0,0) at intrinsic min
// size, which causes wide content to overflow off the right edge.
public partial class Main : Control
{
public override void _Ready()
{
@@ -22,6 +26,7 @@ public partial class Main : Node
ulong? worldMapSeed = null;
bool runAssetTest = false;
bool runCodexTest = false;
bool runWizard = false;
(ulong seed, int tx, int ty)? tacticalArgs = null;
for (int i = 0; i < args.Length; i++)
{
@@ -30,6 +35,11 @@ public partial class Main : Node
runCodexTest = true;
break;
}
if (args[i] == "--wizard")
{
runWizard = true;
break;
}
if (args[i] == "--smoke-test")
{
ulong seed = 12345UL;
@@ -109,6 +119,15 @@ public partial class Main : Node
return;
}
if (runWizard)
{
foreach (Node child in GetChildren())
child.QueueFree();
var packed = ResourceLoader.Load<PackedScene>("res://Scenes/Wizard.tscn");
AddChild(packed.Instantiate());
return;
}
GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
}
+4 -1
View File
@@ -2,7 +2,10 @@
[ext_resource type="Script" path="res://Main.cs" id="1_main"]
[node name="Main" type="Node"]
[node name="Main" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_main")
[node name="Label" type="Label" parent="."]
+58
View File
@@ -0,0 +1,58 @@
using Godot;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// Right-rail summary of the in-progress character. Single
/// <see cref="Refresh"/> rebuilds every section per
/// GODOT_PORTING_GUIDE.md §10 — the panel is small enough that full
/// rebuild is cheap and partial-update logic isn't worth it. Connect
/// the draft via <see cref="SetDraft"/>; the Wizard does this on _Ready.
/// </summary>
public partial class Aside : MarginContainer
{
private CharacterDraft? _draft;
private VBoxContainer _content = null!;
public override void _Ready()
{
AddThemeConstantOverride("margin_left", 18);
AddThemeConstantOverride("margin_right", 18);
AddThemeConstantOverride("margin_top", 18);
AddThemeConstantOverride("margin_bottom", 18);
_content = new VBoxContainer();
_content.AddThemeConstantOverride("separation", 18);
AddChild(_content);
}
public void SetDraft(CharacterDraft draft)
{
_draft = draft;
_draft.Changed += Refresh;
Refresh();
}
private void Refresh()
{
if (_draft is null || _content is null) return;
foreach (var c in _content.GetChildren()) c.QueueFree();
_content.AddChild(new Label { Text = "SUMMARY" });
AddBlock("Clade", CodexContent.Clade(_draft.CladeId)?.Name);
AddBlock("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name);
AddBlock("Calling", CodexContent.Class(_draft.ClassId)?.Name);
AddBlock("Background", CodexContent.Background(_draft.BackgroundId)?.Name);
AddBlock("Name", string.IsNullOrEmpty(_draft.CharacterName) ? null : _draft.CharacterName);
}
private void AddBlock(string label, string? value)
{
var v = new VBoxContainer();
v.AddThemeConstantOverride("separation", 4);
_content.AddChild(v);
v.AddChild(new Label { Text = label.ToUpperInvariant() });
v.AddChild(new Label { Text = value ?? "—" });
}
}
+9
View File
@@ -0,0 +1,9 @@
[gd_scene load_steps=2 format=3 uid="uid://aside6m6v1"]
[ext_resource type="Script" path="res://Scenes/Aside.cs" id="1_aside"]
[node name="Aside" type="MarginContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(320, 0)
size_flags_horizontal = 0
script = ExtResource("1_aside")
+17
View File
@@ -0,0 +1,17 @@
namespace Theriapolis.GodotHost.Scenes.Steps;
/// <summary>
/// Common contract for character-creation step scenes. Mirrors
/// <c>app.jsx</c>'s per-step <c>validate</c> pattern: each step inspects
/// the shared draft and returns null when its requirements are met, or
/// a short error string otherwise.
///
/// Step classes also extend <see cref="Godot.Control"/> so they can be
/// parented into the wizard's StepHost. Bind() is called once after
/// instantiation; Godot drives the lifecycle as usual.
/// </summary>
public interface IStep
{
void Bind(UI.CharacterDraft draft);
string? Validate();
}
+153
View File
@@ -0,0 +1,153 @@
using Godot;
using Theriapolis.Core.Data;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes.Steps;
/// <summary>
/// Step I — Clade. Direct port of <c>StepClade</c> in
/// <c>src/steps.jsx</c>: intro paragraph, then a card grid with one
/// card per clade. Click selects via <see cref="CharacterDraft.Patch"/>.
///
/// Default theme only at this layer (per GODOT_PORTING_GUIDE.md §12 build
/// order); the parchment look lands in the final theming pass.
/// </summary>
public partial class StepClade : VBoxContainer, IStep
{
private CharacterDraft _draft = null!;
private GridContainer _grid = null!;
public void Bind(CharacterDraft draft)
{
_draft = draft;
_draft.Changed += Refresh;
Build();
}
public string? Validate() => string.IsNullOrEmpty(_draft?.CladeId) ? "Pick a clade." : null;
private void Build()
{
AddThemeConstantOverride("separation", 16);
var intro = new VBoxContainer();
intro.AddThemeConstantOverride("separation", 6);
AddChild(intro);
intro.AddChild(new Label { Text = "FOLIO I · CLADE" });
intro.AddChild(new Label { Text = "Choose a Clade" });
intro.AddChild(new Label
{
Text = "The broad mammalian family of your line. Clade defines the largest "
+ "strokes — predator or prey, communal or solitary, scent-driven or "
+ "sight-driven. Each clade carries inherited traits and limits that "
+ "no character escapes.",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(0, 0),
});
_grid = new GridContainer
{
Columns = 3,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
_grid.AddThemeConstantOverride("h_separation", 16);
_grid.AddThemeConstantOverride("v_separation", 16);
AddChild(_grid);
Refresh();
}
private void Refresh()
{
if (_grid is null) return;
foreach (var c in _grid.GetChildren()) c.QueueFree();
foreach (var clade in CodexContent.Clades)
_grid.AddChild(BuildCard(clade));
}
private Control BuildCard(CladeDef clade)
{
bool selected = _draft.CladeId == clade.Id;
var btn = new Button
{
Text = "",
Flat = false,
ToggleMode = true,
ButtonPressed = selected,
FocusMode = Control.FocusModeEnum.None,
// 200 wide so 3 cards + separators (≈ 632) + page margins +
// Aside fit inside ≥ 1024-px viewports (the smaller-screen
// floor we want to support). Height held at 200 so the inner
// labels render at readable size without a content-driven
// height collapse (Button isn't a Container, so child vbox
// height doesn't bubble up to the button's intrinsic min size).
CustomMinimumSize = new Vector2(200, 200),
ClipText = false,
Alignment = HorizontalAlignment.Left,
};
btn.Pressed += () =>
{
// Default species for the new clade — match React app.jsx:
// when clade changes, species defaults to first species in clade.
string speciesId = "";
foreach (var s in CodexContent.SpeciesOfClade(clade.Id))
{
speciesId = s.Id;
break;
}
_draft.Patch(new Godot.Collections.Dictionary
{
{ "clade_id", clade.Id },
{ "species_id", speciesId },
});
};
// Label content stacked inside the button via an anchored VBoxContainer
// (Button isn't a Container, so we anchor the vbox to fill the button's
// rect and let the children flow within it).
var box = new VBoxContainer
{
MouseFilter = MouseFilterEnum.Ignore,
};
box.AnchorRight = 1f;
box.AnchorBottom = 1f;
box.OffsetLeft = 12;
box.OffsetTop = 12;
box.OffsetRight = -12;
box.OffsetBottom = -12;
box.AddThemeConstantOverride("separation", 6);
btn.AddChild(box);
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
box.AddChild(new Label
{
Text = clade.Kind.ToUpperInvariant(),
MouseFilter = MouseFilterEnum.Ignore,
});
if (clade.AbilityMods.Count > 0)
{
var modsRow = new HBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
modsRow.AddThemeConstantOverride("separation", 8);
box.AddChild(modsRow);
foreach (var (k, v) in clade.AbilityMods)
modsRow.AddChild(new Label
{
Text = $"{k} {(v >= 0 ? "+" : "")}{v}",
MouseFilter = MouseFilterEnum.Ignore,
});
}
if (clade.Traits.Length > 0)
{
box.AddChild(new Label
{
Text = $"{clade.Traits.Length} traits, {clade.Detriments.Length} detriments",
MouseFilter = MouseFilterEnum.Ignore,
});
}
return btn;
}
}
+166
View File
@@ -0,0 +1,166 @@
using Godot;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// Character creation wizard shell. Mirrors <c>src/app.jsx</c> per
/// GODOT_PORTING_GUIDE.md §4: header + stepper + page (step + aside) +
/// nav bar. Owns the <see cref="CharacterDraft"/> resource and dispatches
/// each step's content into the StepHost.
///
/// Default theme only at this layer — per guide §12 (build order),
/// the parchment Theme lands as a final pass once structural correctness
/// is verified. Until then, font/colour issues are clearly font/colour
/// issues, not layout issues.
/// </summary>
public partial class Wizard : Control
{
[Signal] public delegate void BackToTitleEventHandler();
private static readonly string[] StepKeys =
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
private static readonly string[] StepNames =
{ "Clade", "Species", "Calling", "Subclass", "History", "Abilities", "Skills", "Sign" };
public UI.CharacterDraft Character { get; private set; } = null!;
private UI.Widgets.CodexStepper _stepper = null!;
private Control _stepHost = null!;
private Label _folioLabel = null!;
private Label _validation = null!;
private Label _navProgress = null!;
private Button _backBtn = null!;
private Button _nextBtn = null!;
private int _step;
private Steps.IStep? _activeStep;
private static readonly System.Type?[] StepTypes =
{
typeof(Steps.StepClade), // 0 Clade — implemented
null, // 1 Species
null, // 2 Calling
null, // 3 Subclass
null, // 4 History
null, // 5 Abilities
null, // 6 Skills
null, // 7 Sign
};
public override void _Ready()
{
Character = new UI.CharacterDraft();
_stepper = GetNode<UI.Widgets.CodexStepper>("%Stepper");
_stepHost = GetNode<Control>("%StepHost");
_folioLabel = GetNode<Label>("%FolioLabel");
_validation = GetNode<Label>("%ValidationLabel");
_navProgress = GetNode<Label>("%NavProgress");
_backBtn = GetNode<Button>("%BackButton");
_nextBtn = GetNode<Button>("%NextButton");
var aside = GetNode<Aside>("%Aside");
aside.SetDraft(Character);
_stepper.StepClicked += OnStepperClicked;
_backBtn.Pressed += OnBackPressed;
_nextBtn.Pressed += OnNextPressed;
Character.Changed += UpdateChrome;
SwitchToStep(0);
}
// ──────────────────────────────────────────────────────────────────────
// Step lifecycle
private void SwitchToStep(int index)
{
if (index < 0 || index >= StepKeys.Length) return;
_step = index;
foreach (var c in _stepHost.GetChildren()) c.QueueFree();
_activeStep = null;
var t = StepTypes[index];
if (t is null)
{
_stepHost.AddChild(new Label
{
Text = $"{StepNames[index]} step — coming soon.",
});
}
else
{
var instance = (Steps.IStep)System.Activator.CreateInstance(t)!;
_activeStep = instance;
instance.Bind(Character);
_stepHost.AddChild((Control)instance);
}
UpdateChrome();
}
private void OnStepperClicked(int index)
{
if (index <= _step) { SwitchToStep(index); return; }
// Forward jump requires current step satisfied. Unimplemented future
// steps still accept the jump — SwitchToStep will show a placeholder.
if (_activeStep?.Validate() is not null) return;
SwitchToStep(index);
}
private void OnBackPressed()
{
if (_step == 0) { EmitSignal(SignalName.BackToTitle); return; }
SwitchToStep(_step - 1);
}
private void OnNextPressed()
{
if (_step < StepKeys.Length - 1) SwitchToStep(_step + 1);
}
// ──────────────────────────────────────────────────────────────────────
// Chrome (header, stepper, nav-bar) refresh
private void UpdateChrome()
{
_folioLabel.Text = $"Folio {Roman(_step + 1)} of VIII — {StepNames[_step]}";
bool valid = _activeStep?.Validate() is null;
string? err = _activeStep?.Validate();
_validation.Text = err ?? (_step == StepKeys.Length - 1 ? "Ready to sign" : "Folio complete");
_nextBtn.Disabled = !valid;
_nextBtn.Visible = _step < StepKeys.Length - 1;
_backBtn.Text = _step == 0 ? "← Title" : "← Back";
_navProgress.Text = $"{_step + 1} / {StepKeys.Length}";
RebuildStepperStates(valid);
}
private void RebuildStepperStates(bool currentSatisfied)
{
// Mirrors app.jsx's lock semantics: a step is Locked iff some EARLIER
// step is unsatisfied. "Type not yet implemented" doesn't affect the
// lock state — clicking an unimplemented step just shows a placeholder.
var states = new UI.Widgets.CodexStepper.StepState[StepNames.Length];
for (int i = 0; i < StepNames.Length; i++)
{
if (i == _step)
states[i] = UI.Widgets.CodexStepper.StepState.Active;
else if (i < _step)
states[i] = UI.Widgets.CodexStepper.StepState.Complete;
else
states[i] = currentSatisfied
? UI.Widgets.CodexStepper.StepState.Pending
: UI.Widgets.CodexStepper.StepState.Locked;
}
_stepper.SetSteps(StepNames, states);
}
private static string Roman(int n) => n switch
{
1 => "I", 2 => "II", 3 => "III", 4 => "IV",
5 => "V", 6 => "VI", 7 => "VII", 8 => "VIII",
_ => n.ToString(),
};
}
+87
View File
@@ -0,0 +1,87 @@
[gd_scene load_steps=4 format=3 uid="uid://wizard6m6v1"]
[ext_resource type="Script" path="res://Scenes/Wizard.cs" id="1_wizard"]
[ext_resource type="Script" path="res://UI/Widgets/CodexStepper.cs" id="2_stepper"]
[ext_resource type="PackedScene" path="res://Scenes/Aside.tscn" id="3_aside"]
[node name="Wizard" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_wizard")
[node name="Wrap" type="MarginContainer" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/margin_left = 36
theme_override_constants/margin_right = 36
theme_override_constants/margin_top = 16
theme_override_constants/margin_bottom = 16
[node name="Layout" type="VBoxContainer" parent="Wrap"]
[node name="Header" type="HBoxContainer" parent="Wrap/Layout"]
theme_override_constants/separation = 24
[node name="TitleCol" type="VBoxContainer" parent="Wrap/Layout/Header"]
size_flags_horizontal = 3
[node name="Title" type="Label" parent="Wrap/Layout/Header/TitleCol"]
text = "THERIAPOLIS · CODEX OF BECOMING"
[node name="FolioLabel" type="Label" parent="Wrap/Layout/Header/TitleCol"]
unique_name_in_owner = true
text = "Folio I of VIII — Clade"
[node name="MetaLabel" type="Label" parent="Wrap/Layout/Header"]
text = "PORT/GODOT · M6"
[node name="Stepper" type="HBoxContainer" parent="Wrap/Layout"]
unique_name_in_owner = true
size_flags_horizontal = 3
script = ExtResource("2_stepper")
[node name="Page" type="HBoxContainer" parent="Wrap/Layout"]
size_flags_vertical = 3
[node name="PageMain" type="MarginContainer" parent="Wrap/Layout/Page"]
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/margin_left = 12
theme_override_constants/margin_right = 28
theme_override_constants/margin_top = 16
theme_override_constants/margin_bottom = 16
[node name="Scroll" type="ScrollContainer" parent="Wrap/Layout/Page/PageMain"]
size_flags_horizontal = 3
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="StepHost" type="VBoxContainer" parent="Wrap/Layout/Page/PageMain/Scroll"]
unique_name_in_owner = true
size_flags_horizontal = 3
[node name="Aside" parent="Wrap/Layout/Page" instance=ExtResource("3_aside")]
[node name="NavBar" type="HBoxContainer" parent="Wrap/Layout"]
theme_override_constants/separation = 24
[node name="BackButton" type="Button" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "← Back"
[node name="Spacer" type="Control" parent="Wrap/Layout/NavBar"]
size_flags_horizontal = 3
[node name="ValidationLabel" type="Label" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = ""
[node name="NavProgress" type="Label" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "1 / 8"
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "Next "
+106
View File
@@ -0,0 +1,106 @@
using Godot;
using System.Collections.Generic;
using Theriapolis.Core.Data;
using Theriapolis.GodotHost.Platform;
namespace Theriapolis.GodotHost.UI;
/// <summary>
/// In-progress character record. Resource (not Node) per
/// GODOT_PORTING_GUIDE.md §2.1: serializable via ResourceSaver.save,
/// inspectable in the editor, doesn't need tree presence.
///
/// Mirrors <c>app.jsx</c>'s <c>state</c> shape — clade/species/class/etc.,
/// stat assignment, chosen skills, name. Mutate via <see cref="Patch"/> so
/// every change emits <see cref="Changed"/>; the Aside and Wizard listen
/// and re-render on that single signal.
/// </summary>
[GlobalClass]
public partial class CharacterDraft : Resource
{
// NB: Resource defines its own `Changed` signal (emitted via EmitChanged()).
// We piggyback on it instead of declaring our own to avoid a name clash —
// listeners do `draft.Changed += ...` exactly the same way either way.
[Export] public string CladeId { get; set; } = "";
[Export] public string SpeciesId { get; set; } = "";
[Export] public string ClassId { get; set; } = "";
[Export] public string SubclassId { get; set; } = "";
[Export] public string BackgroundId { get; set; } = "";
/// <summary>"array" or "roll" — assignment method for ability scores.</summary>
[Export] public string StatMethod { get; set; } = "array";
[Export] public Godot.Collections.Array<int> StatPool { get; set; }
= new() { 15, 14, 13, 12, 10, 8 };
[Export] public Godot.Collections.Dictionary StatAssign { get; set; } = new();
[Export] public Godot.Collections.Array<string> ChosenSkills { get; set; } = new();
[Export] public string CharacterName { get; set; } = "";
/// <summary>
/// Mutate one or more fields, then emit Changed once. Mirrors
/// the JS prototype's <c>set(patch)</c> from app.jsx.
/// </summary>
public void Patch(Godot.Collections.Dictionary patch)
{
foreach (var key in patch.Keys)
{
string k = (string)key;
switch (k)
{
case "clade_id": CladeId = (string)patch[key]; break;
case "species_id": SpeciesId = (string)patch[key]; break;
case "class_id": ClassId = (string)patch[key]; break;
case "subclass_id": SubclassId = (string)patch[key]; break;
case "background_id": BackgroundId = (string)patch[key]; break;
case "stat_method": StatMethod = (string)patch[key]; break;
case "stat_pool": StatPool = (Godot.Collections.Array<int>)patch[key]; break;
case "stat_assign": StatAssign = (Godot.Collections.Dictionary)patch[key]; break;
case "chosen_skills": ChosenSkills = (Godot.Collections.Array<string>)patch[key]; break;
case "character_name":CharacterName = (string)patch[key]; break;
default:
GD.PushWarning($"[CharacterDraft] unknown patch key: {k}");
break;
}
}
EmitChanged();
}
}
/// <summary>
/// Lazy-loaded immutable content tables. Replaces the React prototype's
/// data.jsx + autoload Content per GODOT_PORTING_GUIDE.md §2.2 — same
/// purpose, idiomatic C#.
/// </summary>
public static class CodexContent
{
private static CladeDef[]? _clades;
private static SpeciesDef[]? _species;
private static ClassDef[]? _classes;
private static SubclassDef[]? _subclasses;
private static BackgroundDef[]? _backgrounds;
public static CladeDef[] Clades => _clades ??= Load(l => l.LoadClades());
public static SpeciesDef[] Species => _species ??= Load(l => l.LoadSpecies(Clades));
public static ClassDef[] Classes => _classes ??= Load(l => l.LoadClasses());
public static SubclassDef[] Subclasses => _subclasses ??= Load(l => l.LoadSubclasses(Classes));
public static BackgroundDef[] Backgrounds => _backgrounds ??= Load(l => l.LoadBackgrounds());
public static CladeDef? Clade(string id) => System.Array.Find(Clades, c => c.Id == id);
public static SpeciesDef? SpeciesById(string id) => System.Array.Find(Species, s => s.Id == id);
public static ClassDef? Class(string id) => System.Array.Find(Classes, c => c.Id == id);
public static BackgroundDef? Background(string id) => System.Array.Find(Backgrounds, b => b.Id == id);
public static IEnumerable<SpeciesDef> SpeciesOfClade(string cladeId)
{
foreach (var s in Species)
if (string.Equals(s.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase))
yield return s;
}
private static T Load<T>(System.Func<Theriapolis.Core.Data.ContentLoader, T> fn)
{
var loader = new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir);
return fn(loader);
}
}
+11 -3
View File
@@ -55,14 +55,22 @@ public partial class CodexStepper : HBoxContainer
SizeFlagsHorizontal = SizeFlags.ExpandFill,
ToggleMode = false,
};
// Build a vbox child for two stacked labels.
// Build a vbox child for two stacked labels. Button isn't a
// Container, so the vbox doesn't get auto-laid-out — anchor it to
// fill the button's rect explicitly. Without this, the vbox sits at
// (0,0) with intrinsic size and the labels render in the top-left
// corner instead of centered.
var vbox = new VBoxContainer
{
MouseFilter = MouseFilterEnum.Ignore,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
Alignment = BoxContainer.AlignmentMode.Center,
};
vbox.AnchorRight = 1f;
vbox.AnchorBottom = 1f;
vbox.OffsetLeft = 0;
vbox.OffsetRight = 0;
vbox.OffsetTop = 0;
vbox.OffsetBottom = 0;
btn.AddChild(vbox);
var num = new Label