M6.17: Variant content + Sheep/Goat split + calling lore + uniform card layout

Species variants populated against the M6.13 schema:
- Lion-Folk sex axis: Mane Guard (male) / Huntress Reflexes (female,
  +5 ft speed + advantage on initiative).
- Elk-Folk sex axis: Antler Combat with 10 ft reach when full rack
  (male, retains seasonal Antler Drag) / Kick (female, prone on crit).
  Base traits restored to doc canon: Herd Coordination (Help → +3) +
  Endurance Runner (40 ft + advantage CON vs forced march); base speed
  bumped 30 → 40; new base detriment Herd Instinct.

Ram-Folk replaced with separate Sheep-Folk + Goat-Folk species rather
than a lineage-axis variant on a single Ram entry. Bovidae now has 4
species. The lineage-axis toggle UI in StepSpecies BuildCard rolled
back; the schema stays for sex-axis (Lion/Elk) which auto-resolves.
ContentLoadTests + HybridCharacterTests updated; Size.cs comment too.

Calling lore: ClassDef gains Description; classes.json populated for
all 8 callings with the doc's italic blockquote + paragraph profile.
StepClass surfaces the description on the card.

Card layout uniformity: StepClass / StepSubclass / StepBackground all
switched to single-column ExpandFill grids (matching StepClade /
StepSpecies). Each card now spans the wizard's content width so the
description and feature chips have room to breathe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-07 22:23:47 -07:00
parent 0ab4715aee
commit 39117a09ed
10 changed files with 121 additions and 174 deletions
@@ -43,9 +43,8 @@ public partial class StepBackground : VBoxContainer, IStep
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
_grid.AddThemeConstantOverride("h_separation", 16);
_grid.AddThemeConstantOverride("v_separation", 16);
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
_grid.AddThemeConstantOverride("v_separation", 12);
AddChild(_grid);
Refresh();
@@ -67,7 +66,7 @@ public partial class StepBackground : VBoxContainer, IStep
bool selected = _draft.BackgroundId == bg.Id;
var card = CodexCard.Make();
card.CustomMinimumSize = new Vector2(200, 0);
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
CodexCard.SetSelected(card, selected);
card.GuiInput += (InputEvent e) =>
+16 -4
View File
@@ -47,9 +47,11 @@ public partial class StepClass : VBoxContainer, IStep
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
_grid.AddThemeConstantOverride("h_separation", 16);
_grid.AddThemeConstantOverride("v_separation", 16);
// Single-column layout matches StepClade / StepSpecies — each card
// spans the wizard's content width so the description text fits
// comfortably and the calling's tone lands before the mechanics.
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
_grid.AddThemeConstantOverride("v_separation", 12);
AddChild(_grid);
Refresh();
@@ -68,7 +70,7 @@ public partial class StepClass : VBoxContainer, IStep
bool selected = _draft.ClassId == cls.Id;
var card = CodexCard.Make();
card.CustomMinimumSize = new Vector2(200, 0);
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
CodexCard.SetSelected(card, selected);
card.GuiInput += (InputEvent e) =>
@@ -97,6 +99,16 @@ public partial class StepClass : VBoxContainer, IStep
ThemeTypeVariation = "CardMeta",
});
if (!string.IsNullOrEmpty(cls.Description))
{
box.AddChild(new Label
{
Text = cls.Description,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
MouseFilter = Control.MouseFilterEnum.Pass,
});
}
if (cls.Saves.Length > 0)
{
var savesRow = new HBoxContainer();
+14 -150
View File
@@ -28,11 +28,6 @@ public partial class StepSpecies : VBoxContainer, IStep
private string _sirePicksBuiltFor = "";
private string _damPicksBuiltFor = "";
// Lineage-axis variant picker for purebred path (Ram-Folk sheep/goat etc.).
// Hybrid path embeds its own lineage picker into the per-parent col.
private VBoxContainer _purebredVariantSection = null!;
private string _purebredVariantBuiltFor = "";
public void Bind(CharacterDraft draft)
{
_draft = draft;
@@ -68,12 +63,6 @@ public partial class StepSpecies : VBoxContainer, IStep
_purebredGrid = MakeGrid();
_purebredSection.AddChild(_purebredGrid);
// Lineage picker (Ram-Folk sheep/goat). Visible only when the
// selected species has VariantAxis == "lineage".
_purebredVariantSection = new VBoxContainer { Visible = false };
_purebredVariantSection.AddThemeConstantOverride("separation", 6);
_purebredSection.AddChild(_purebredVariantSection);
_hybridSection = new VBoxContainer();
_hybridSection.AddThemeConstantOverride("separation", 16);
AddChild(_hybridSection);
@@ -141,111 +130,25 @@ public partial class StepSpecies : VBoxContainer, IStep
}
else
{
RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId,
spId => OnPurebredSpeciesPicked(spId));
SyncPurebredVariant();
RefreshPurebredGrid();
}
}
private void OnPurebredSpeciesPicked(string speciesId)
{
_draft.Patch(new Godot.Collections.Dictionary
{
{ "species_id", speciesId },
// Species swap invalidates lineage variant.
{ "species_variant", "" },
});
}
private void OnPurebredSpeciesPicked(string speciesId) =>
_draft.Patch(new Godot.Collections.Dictionary { { "species_id", speciesId } });
private void OnLineageSpeciesPicked(string lineage, string speciesId)
{
private void OnLineageSpeciesPicked(string lineage, string speciesId) =>
_draft.Patch(new Godot.Collections.Dictionary
{
{ lineage + "_species_id", speciesId },
// Species swap invalidates the previously-picked species trait/detriment + variant.
// Species swap invalidates the previously-picked trait/detriment.
{ lineage + "_chosen_species_trait", "" },
{ lineage + "_chosen_species_detriment", "" },
{ lineage + "_species_variant", "" },
});
}
/// <summary>Sync the purebred lineage picker row. Visible iff the
/// picked species declares a lineage-axis variant.</summary>
private void SyncPurebredVariant()
{
var sp = CodexContent.SpeciesById(_draft.SpeciesId);
bool show = sp is not null && sp.VariantAxis == "lineage" && sp.Variants.Length > 0;
_purebredVariantSection.Visible = show;
if (!show)
{
_purebredVariantBuiltFor = "";
foreach (var c in _purebredVariantSection.GetChildren()) c.Free();
return;
}
if (_purebredVariantBuiltFor == _draft.SpeciesId)
{
// Same species — just update which lineage button is pressed.
foreach (var child in _purebredVariantSection.GetChildren())
{
if (child is Button btn)
{
bool want = btn.Name == _draft.SpeciesVariant;
if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want);
}
}
return;
}
foreach (var c in _purebredVariantSection.GetChildren()) c.Free();
_purebredVariantBuiltFor = _draft.SpeciesId;
_purebredVariantSection.AddChild(new Label { Text = "LINEAGE", ThemeTypeVariation = "Eyebrow" });
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", 8);
_purebredVariantSection.AddChild(row);
foreach (var v in sp!.Variants)
{
string captured = v.Id;
var btn = new Button
{
Text = v.Name,
ToggleMode = true,
ButtonPressed = v.Id == _draft.SpeciesVariant,
FocusMode = Control.FocusModeEnum.None,
Name = v.Id,
};
var btnRef = btn;
btn.Pressed += () =>
_draft.Patch(new Godot.Collections.Dictionary
{
{ "species_variant", btnRef.ButtonPressed ? captured : "" },
});
// Hover popover summarizes the variant's traits + detriments.
string capturedName = v.Name;
string capturedDesc = SummarizeVariant(v);
btn.MouseEntered += () =>
PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, "lineage", false);
btn.MouseExited += () =>
PopoverLayer.Instance?.ScheduleClose();
row.AddChild(btn);
}
}
private static string SummarizeVariant(Theriapolis.Core.Data.SpeciesVariantDef v)
{
var parts = new System.Collections.Generic.List<string>();
foreach (var t in v.Traits) parts.Add($"• {t.Name}: {t.Description}");
foreach (var d in v.Detriments) parts.Add($"• {d.Name} (detriment): {d.Description}");
return parts.Count == 0 ? "(no extra traits)" : string.Join("\n", parts);
}
/// <summary>
/// Mutate-in-place sync for the species-pick column (one trait button
/// group + one detriment button group, radio-style; plus a lineage
/// picker when the species declares a lineage-axis variant). Same
/// Free()-defer hazard as the bonus rows in StepClade — only rebuild
/// when the species id changes.
/// group + one detriment button group, radio-style).
/// </summary>
private void SyncSpeciesPicks(VBoxContainer col, ref string builtFor,
string lineage, string speciesId,
@@ -255,8 +158,6 @@ public partial class StepSpecies : VBoxContainer, IStep
{
UpdateRadioGroup(col, "trait", chosenTrait);
UpdateRadioGroup(col, "detriment", chosenDetriment);
UpdateRadioGroup(col, "lineage",
lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant);
return;
}
@@ -275,22 +176,6 @@ public partial class StepSpecies : VBoxContainer, IStep
return;
}
// Lineage picker first when applicable, so the player picks
// lineage before reading the trait/detriment list (variant
// content layers on top).
if (sp.VariantAxis == "lineage" && sp.Variants.Length > 0)
{
col.AddChild(new Label { Text = "Lineage", ThemeTypeVariation = "Eyebrow" });
string currentVariant = lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant;
BuildRadioGroup(col, "lineage", lineage, VariantsAsTraits(sp.Variants),
currentVariant,
(lin, id) => _draft.Patch(new Godot.Collections.Dictionary
{
{ lin + "_species_variant", id },
}),
isDetriment: false);
}
col.AddChild(new Label { Text = "Trait", ThemeTypeVariation = "Eyebrow" });
BuildRadioGroup(col, "trait", lineage, sp.Traits, chosenTrait,
(lin, id) => OnSpeciesPickToggled(lin, "trait", id), isDetriment: false);
@@ -307,27 +192,6 @@ public partial class StepSpecies : VBoxContainer, IStep
}
}
/// <summary>
/// Adapter — BuildRadioGroup operates on TraitDef[]; project the variant
/// list into TraitDef-shape so it can drive the same radio renderer.
/// Description summarises the variant's contents for the hover popover.
/// </summary>
private static Theriapolis.Core.Data.TraitDef[] VariantsAsTraits(
Theriapolis.Core.Data.SpeciesVariantDef[] variants)
{
var arr = new Theriapolis.Core.Data.TraitDef[variants.Length];
for (int i = 0; i < variants.Length; i++)
{
arr[i] = new Theriapolis.Core.Data.TraitDef
{
Id = variants[i].Id,
Name = variants[i].Name,
Description = SummarizeVariant(variants[i]),
};
}
return arr;
}
private static void BuildRadioGroup(VBoxContainer parent, string kind, string lineage,
Theriapolis.Core.Data.TraitDef[] options, string selected,
System.Action<string, string> onPicked, bool isDetriment)
@@ -389,12 +253,13 @@ public partial class StepSpecies : VBoxContainer, IStep
_draft.Patch(new Godot.Collections.Dictionary { { field, traitId } });
}
private static void RefreshGrid(GridContainer grid, string cladeId, string selectedSpecies, System.Action<string> onClick)
private void RefreshPurebredGrid()
{
foreach (var c in grid.GetChildren()) c.Free();
if (string.IsNullOrEmpty(cladeId)) return;
foreach (var sp in CodexContent.SpeciesOfClade(cladeId))
grid.AddChild(BuildCard(sp, sp.Id == selectedSpecies, onClick));
foreach (var c in _purebredGrid.GetChildren()) c.Free();
if (string.IsNullOrEmpty(_draft.CladeId)) return;
foreach (var sp in CodexContent.SpeciesOfClade(_draft.CladeId))
_purebredGrid.AddChild(BuildCard(sp, sp.Id == _draft.SpeciesId,
spId => OnPurebredSpeciesPicked(spId)));
}
/// <summary>
@@ -402,9 +267,8 @@ public partial class StepSpecies : VBoxContainer, IStep
/// followed by dam-clade species. Sire and dam clades are
/// guaranteed distinct by StepClade's parent-conflict rule, so the
/// species lists are disjoint — each card unambiguously belongs to
/// one lineage and a click on the card commits the pick. Full
/// rebuild on every Refresh is safe because Bind installs Refresh
/// as a deferred callback.
/// one lineage. Full rebuild on every Refresh is safe because Bind
/// installs Refresh as a deferred callback.
/// </summary>
private void RefreshHybridGrid()
{
@@ -50,9 +50,8 @@ public partial class StepSubclass : VBoxContainer, IStep
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
_grid.AddThemeConstantOverride("h_separation", 16);
_grid.AddThemeConstantOverride("v_separation", 16);
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
_grid.AddThemeConstantOverride("v_separation", 12);
AddChild(_grid);
Refresh();
@@ -78,7 +77,7 @@ public partial class StepSubclass : VBoxContainer, IStep
bool selected = _draft.SubclassId == sub.Id;
var card = CodexCard.Make();
card.CustomMinimumSize = new Vector2(200, 0);
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
CodexCard.SetSelected(card, selected);
card.GuiInput += (InputEvent e) =>