M6.5: StepSkills + ability-bonus breakdown + Aside redesign
The skills step lands as the last data-driven character creation step
(only Sign — name + confirm — remains for M6.6). Brought a few cross-
cutting refactors with it.
Scenes/Steps/StepSkills.cs:
Direct port of StepSkills in src/steps.jsx — all 18 skills laid out
in 6 ability-grouped panels (STR/DEX/CON/INT/WIS/CHA), 2-column
grid. Background-granted skills appear pre-checked and locked;
user picks `class.SkillsChoose` more from `class.SkillOptions`.
Hover the skill name → popover with the codex flavor description
(limited to the title only — hovering checkboxes / source tags
doesn't trigger the popover, avoids interference with adjacent
rows' click targets). Fixed-width [✓] / [ ] / [—] checkbox slot
so toggling doesn't shift the row layout.
UI/SkillsCatalog.cs (new):
Static skill table — JSON id, display label, governing ability,
and the codex SKILL_DESC text ported verbatim from src/data.jsx.
Mirrors Theriapolis.Core.Rules.Stats.SkillId; descriptions live
here because backgrounds.json and classes.json don't carry them.
UI/AbilityCalc.cs (new):
Final-score math — base assignment + clade and species mods, with
per-source breakdown for the bonus popover ("+1 from Canidae · +2
from Wolf"). Hybrid mode tags each clade source with its lineage
("(sire)" / "(dam)"). Used by both StepStats and the Aside so the
two views can never disagree on what a +N badge means.
UI/BackgroundAvailability.cs (new):
Extracted from StepBackground — shared rules table for hybrid-only
and clade-restricted backgrounds. Now also consulted by StepClade
when the player changes lineage: the currently-selected background
is auto-cleared if the new lineage no longer satisfies its rule
(e.g., Pack-Raised clears when switching from Canidae to Felidae,
Passer clears when toggling Hybrid off). Implemented via
Resource.Duplicate + Patch on the duplicate to evaluate the
hypothetical post-patch state without committing prematurely.
StepStats.cs:
Per-row layout extended: ability label | slot | bonus chip | final
| d20 mod. Bonus chip is a TraitChip with the per-source breakdown
in its hover description. Auto-assign now sorts empty abilities by
AbilityCalc.TotalBonus DESCENDING (with class.PrimaryAbility as
tiebreaker) — biggest pool value lands on the ability already
receiving the biggest lineage bonus, maximising final scores.
Aside.cs (significant redesign):
- Name centered at top.
- Lineage details: 2-column grid, full-width.
- Purebred: Clade | Species, then Calling | Background, then
Subclass | (empty).
- Hybrid: SIRE ★ | DAM (centered + underlined column heads),
Clade | Clade, Species | Species, then the same
calling/background/subclass rows.
- Attributes: STR/DEX/CON/INT/WIS/CHA each with bonus chip (omitted
when +0), final score, d20 modifier. Self-contained min-width
table so it can't widen the panel beyond its alloc.
- Pills: traits, detriments, level-1 features, background feature,
skill chips (BG-locked + user-chosen). All hoverable for descriptions.
- Whole panel wraps in a ScrollContainer so an over-tall summary
scrolls in place instead of pushing the wizard layout off-screen.
- Width nudged 320 → 360px. Smaller font on label tags, autowrap
on value labels so long names ("Hybrid Underground") wrap rather
than push the panel wider.
Card grids: changed all five card-grid steps (Clade, Species, Class,
Subclass, Background) from SizeFlagsHorizontal.ExpandFill →
ShrinkCenter. Cards stay at their CustomMinimumSize 200 wide and
the grid horizontally centers in PageMain. The right-side gap
between content and Aside is now uniform regardless of how many
cards or whether the last row is partial — fixes the "Clade tab
feels too padded, Background tab too tight" perception.
Closes M6.5. Per guide §12, what's left in M6: M6.6 (StepReview —
name + summary + Confirm handoff per guide §11) and M6.7 (parchment
Theme pass).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,22 @@
|
||||
using Godot;
|
||||
using System.Collections.Generic;
|
||||
using Theriapolis.GodotHost.Scenes.Widgets;
|
||||
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.
|
||||
/// Right-rail summary of the in-progress character. Sections, top-down:
|
||||
/// 1. Name (or placeholder until Step VIII).
|
||||
/// 2. Lineage details — 2-column grid:
|
||||
/// purebred: Clade | Species, then Calling | Background.
|
||||
/// hybrid: Sire | Dam (column headers); each parent's
|
||||
/// clade and species below; Calling | Background.
|
||||
/// 3. Attributes — final score + d20 modifier per ability.
|
||||
/// 4. Pills — traits + skills selected so far, with hover popovers.
|
||||
///
|
||||
/// One Refresh() rebuild per draft change; the panel is small enough
|
||||
/// that partial-update logic isn't worth the complexity.
|
||||
/// </summary>
|
||||
public partial class Aside : MarginContainer
|
||||
{
|
||||
@@ -22,9 +30,21 @@ public partial class Aside : MarginContainer
|
||||
AddThemeConstantOverride("margin_top", 18);
|
||||
AddThemeConstantOverride("margin_bottom", 18);
|
||||
|
||||
_content = new VBoxContainer();
|
||||
// Wrap content in a ScrollContainer so the Aside's intrinsic
|
||||
// height stays bounded by the panel's allocated size — without
|
||||
// this, an over-tall summary (lots of pills) forces the parent
|
||||
// Layout to expand and pushes the navbar off the viewport.
|
||||
var scroll = new ScrollContainer
|
||||
{
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled,
|
||||
};
|
||||
AddChild(scroll);
|
||||
|
||||
_content = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
_content.AddThemeConstantOverride("separation", 18);
|
||||
AddChild(_content);
|
||||
scroll.AddChild(_content);
|
||||
}
|
||||
|
||||
public void SetDraft(CharacterDraft draft)
|
||||
@@ -39,42 +59,277 @@ public partial class Aside : MarginContainer
|
||||
if (_draft is null || _content is null) return;
|
||||
foreach (var c in _content.GetChildren()) c.QueueFree();
|
||||
|
||||
_content.AddChild(new Label { Text = "SUMMARY" });
|
||||
BuildName();
|
||||
BuildDetailsGrid();
|
||||
BuildAttributes();
|
||||
BuildPills();
|
||||
}
|
||||
|
||||
if (_draft.IsHybrid)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Section 1 — Name
|
||||
|
||||
private void BuildName()
|
||||
{
|
||||
var name = string.IsNullOrEmpty(_draft!.CharacterName) ? "Unnamed" : _draft.CharacterName;
|
||||
_content.AddChild(new Label
|
||||
{
|
||||
AddBlock("Origin", "Hybrid");
|
||||
AddBlock(_draft.DominantParent == "sire" ? "Sire (dominant)" : "Sire",
|
||||
FormatLineage(_draft.SireCladeId, _draft.SireSpeciesId));
|
||||
AddBlock(_draft.DominantParent == "dam" ? "Dam (dominant)" : "Dam",
|
||||
FormatLineage(_draft.DamCladeId, _draft.DamSpeciesId));
|
||||
Text = name,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
_content.AddChild(new HSeparator());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Section 2 — Lineage details (2-column grid)
|
||||
|
||||
private void BuildDetailsGrid()
|
||||
{
|
||||
if (_draft!.IsHybrid)
|
||||
{
|
||||
// Hybrid layout: SIRE / DAM column headers above the parent
|
||||
// detail rows, then the Calling / Background row spans both
|
||||
// halves of the same kind of grid.
|
||||
var headers = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
headers.AddThemeConstantOverride("separation", 12);
|
||||
_content.AddChild(headers);
|
||||
headers.AddChild(MakeColumnHeader("SIRE" + (_draft.DominantParent == "sire" ? " ★" : "")));
|
||||
headers.AddChild(MakeColumnHeader("DAM" + (_draft.DominantParent == "dam" ? " ★" : "")));
|
||||
|
||||
var lineageGrid = MakeFullWidthGrid();
|
||||
_content.AddChild(lineageGrid);
|
||||
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.SireCladeId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.DamCladeId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SireSpeciesId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.DamSpeciesId)?.Name));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddBlock("Clade", CodexContent.Clade(_draft.CladeId)?.Name);
|
||||
AddBlock("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name);
|
||||
var lineageGrid = MakeFullWidthGrid();
|
||||
_content.AddChild(lineageGrid);
|
||||
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.CladeId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("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);
|
||||
// Calling + Background — last row of the lineage block, with
|
||||
// Subclass tucked underneath Calling in the same column.
|
||||
var callingGrid = MakeFullWidthGrid();
|
||||
_content.AddChild(callingGrid);
|
||||
callingGrid.AddChild(MakeCell("Calling", CodexContent.Class(_draft.ClassId)?.Name));
|
||||
callingGrid.AddChild(MakeCell("Background", CodexContent.Background(_draft.BackgroundId)?.Name));
|
||||
|
||||
var subclassDef = System.Array.Find(CodexContent.Subclasses, s => s.Id == _draft.SubclassId);
|
||||
callingGrid.AddChild(MakeCell("Subclass", subclassDef?.Name));
|
||||
callingGrid.AddChild(new Control()); // empty cell to align grid
|
||||
|
||||
_content.AddChild(new HSeparator());
|
||||
}
|
||||
|
||||
private static string? FormatLineage(string cladeId, string speciesId)
|
||||
private static GridContainer MakeFullWidthGrid()
|
||||
{
|
||||
var clade = CodexContent.Clade(cladeId);
|
||||
var species = CodexContent.SpeciesById(speciesId);
|
||||
if (clade is null && species is null) return null;
|
||||
if (species is not null) return $"{species.Name} ({clade?.Name ?? cladeId})";
|
||||
return clade?.Name;
|
||||
var grid = new GridContainer { Columns = 2, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
grid.AddThemeConstantOverride("h_separation", 12);
|
||||
grid.AddThemeConstantOverride("v_separation", 8);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private void AddBlock(string label, string? value)
|
||||
private static Control MakeColumnHeader(string label)
|
||||
{
|
||||
var v = new VBoxContainer();
|
||||
v.AddThemeConstantOverride("separation", 4);
|
||||
_content.AddChild(v);
|
||||
v.AddChild(new Label { Text = label.ToUpperInvariant() });
|
||||
v.AddChild(new Label { Text = value ?? "—" });
|
||||
// Centered label + underline; sized to take half the parent
|
||||
// HBoxContainer width so SIRE and DAM align over their data
|
||||
// columns.
|
||||
var col = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
col.AddThemeConstantOverride("separation", 2);
|
||||
col.AddChild(new Label
|
||||
{
|
||||
Text = label,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
col.AddChild(new HSeparator());
|
||||
return col;
|
||||
}
|
||||
|
||||
private static Control MakeCell(string label, string? value)
|
||||
{
|
||||
var v = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
v.AddThemeConstantOverride("separation", 2);
|
||||
|
||||
// Smaller font on the label tag — keeps the row compact in the
|
||||
// narrow side rail.
|
||||
var lbl = new Label { Text = label.ToUpperInvariant() };
|
||||
lbl.AddThemeFontSizeOverride("font_size", 11);
|
||||
v.AddChild(lbl);
|
||||
|
||||
// Autowrap on the value so long names ("Hybrid Underground")
|
||||
// wrap rather than push the whole panel wider than its alloc.
|
||||
var val = new Label
|
||||
{
|
||||
Text = string.IsNullOrEmpty(value) ? "—" : value,
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
CustomMinimumSize = new Vector2(0, 0),
|
||||
};
|
||||
v.AddChild(val);
|
||||
return v;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Section 3 — Attributes (final score + modifier)
|
||||
|
||||
private void BuildAttributes()
|
||||
{
|
||||
_content.AddChild(new Label { Text = "ATTRIBUTES" });
|
||||
|
||||
// Self-contained sub-panel so the attributes table never widens
|
||||
// beyond the Aside's own rect. Columns: ab | bonus | final | d20.
|
||||
// Cells have explicit min widths but DON'T ExpandFill, so the
|
||||
// grid's total width is bounded by the cell mins instead of
|
||||
// taking whatever parent width is available.
|
||||
var grid = new GridContainer { Columns = 4 };
|
||||
grid.AddThemeConstantOverride("h_separation", 6);
|
||||
grid.AddThemeConstantOverride("v_separation", 6);
|
||||
_content.AddChild(grid);
|
||||
|
||||
foreach (var ab in SkillsCatalog.Abilities)
|
||||
{
|
||||
int baseScore = AbilityCalc.BaseScore(ab, _draft!);
|
||||
int bonus = AbilityCalc.TotalBonus(ab, _draft!);
|
||||
int final = baseScore + bonus;
|
||||
int dMod = AbilityCalc.D20Modifier(final);
|
||||
|
||||
grid.AddChild(new Label
|
||||
{
|
||||
Text = ab,
|
||||
CustomMinimumSize = new Vector2(36, 0),
|
||||
});
|
||||
|
||||
// Bonus chip — only render when non-zero. +0 entries get an
|
||||
// empty Control so the column stays aligned without the
|
||||
// panel chrome of an empty TraitChip pushing the row wider.
|
||||
if (bonus != 0)
|
||||
{
|
||||
grid.AddChild(new Widgets.TraitChip
|
||||
{
|
||||
TraitName = AbilityCalc.FormatSigned(bonus),
|
||||
Description = AbilityCalc.FormatBreakdown(AbilityCalc.Sources(ab, _draft!)),
|
||||
Tag = "bonus",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
grid.AddChild(new Control { CustomMinimumSize = new Vector2(40, 0) });
|
||||
}
|
||||
|
||||
grid.AddChild(new Label
|
||||
{
|
||||
Text = baseScore == 0 ? "—" : final.ToString(),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
CustomMinimumSize = new Vector2(36, 0),
|
||||
});
|
||||
|
||||
grid.AddChild(new Label
|
||||
{
|
||||
Text = baseScore == 0 ? "" : AbilityCalc.FormatSigned(dMod),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
CustomMinimumSize = new Vector2(36, 0),
|
||||
});
|
||||
}
|
||||
|
||||
_content.AddChild(new HSeparator());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Section 4 — Pills (traits, skills, features chosen so far)
|
||||
|
||||
private void BuildPills()
|
||||
{
|
||||
_content.AddChild(new Label { Text = "TRAITS · FEATS · SKILLS" });
|
||||
|
||||
var flow = new HFlowContainer();
|
||||
flow.AddThemeConstantOverride("h_separation", 6);
|
||||
flow.AddThemeConstantOverride("v_separation", 6);
|
||||
_content.AddChild(flow);
|
||||
|
||||
// Clade traits (purebred = single clade; hybrid = both).
|
||||
if (_draft!.IsHybrid)
|
||||
{
|
||||
AddCladeTraits(flow, CodexContent.Clade(_draft.SireCladeId));
|
||||
AddCladeTraits(flow, CodexContent.Clade(_draft.DamCladeId));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddCladeTraits(flow, CodexContent.Clade(_draft.CladeId));
|
||||
}
|
||||
|
||||
// Species traits.
|
||||
var sp = CodexContent.SpeciesById(_draft.EffectiveSpeciesId);
|
||||
if (sp is not null)
|
||||
{
|
||||
foreach (var t in sp.Traits)
|
||||
flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||
foreach (var d in sp.Detriments)
|
||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
}
|
||||
|
||||
// Class level-1 features.
|
||||
var cls = CodexContent.Class(_draft.ClassId);
|
||||
if (cls is not null)
|
||||
{
|
||||
var lvl1 = System.Array.Find(cls.LevelTable, e => e.Level == 1);
|
||||
if (lvl1 is not null)
|
||||
{
|
||||
foreach (var fid in lvl1.Features)
|
||||
{
|
||||
if (!cls.FeatureDefinitions.TryGetValue(fid, out var fd)) continue;
|
||||
if (fd.Kind == "stub" || fid.StartsWith("subclass_")) continue;
|
||||
flow.AddChild(new TraitChip
|
||||
{
|
||||
TraitName = fd.Name,
|
||||
Description = fd.Description,
|
||||
Tag = fd.Kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Background feature + granted skills.
|
||||
var bg = CodexContent.Background(_draft.BackgroundId);
|
||||
if (bg is not null && !string.IsNullOrEmpty(bg.FeatureName))
|
||||
{
|
||||
flow.AddChild(new TraitChip
|
||||
{
|
||||
TraitName = bg.FeatureName,
|
||||
Description = bg.FeatureDescription,
|
||||
Tag = "history",
|
||||
});
|
||||
}
|
||||
|
||||
// Skills — background-locked first, then user-chosen class skills.
|
||||
if (bg is not null)
|
||||
{
|
||||
foreach (var skillId in bg.SkillProficiencies)
|
||||
AddSkillChip(flow, skillId, "BG");
|
||||
}
|
||||
foreach (var skillId in _draft.ChosenSkills)
|
||||
AddSkillChip(flow, skillId, "skill");
|
||||
}
|
||||
|
||||
private static void AddCladeTraits(HFlowContainer flow, Theriapolis.Core.Data.CladeDef? clade)
|
||||
{
|
||||
if (clade is null) return;
|
||||
foreach (var t in clade.Traits)
|
||||
flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||
foreach (var d in clade.Detriments)
|
||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
}
|
||||
|
||||
private static void AddSkillChip(HFlowContainer flow, string skillId, string tag)
|
||||
{
|
||||
var s = SkillsCatalog.Get(skillId);
|
||||
if (s is null) return;
|
||||
flow.AddChild(new TraitChip
|
||||
{
|
||||
TraitName = s.Label,
|
||||
Description = s.Description,
|
||||
Tag = tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user