ee5439285c
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>
154 lines
5.2 KiB
C#
154 lines
5.2 KiB
C#
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;
|
|
}
|
|
}
|