66055f9549
Switch Step 0 (Clade) and Step 1 (Species) from a 3-column card grid to a 1-column layout, with each card carrying a codex-voice description paragraph between the meta line and the trait chips. Rationale: establish the world's tone before mechanics — the player reads who Canidae or Wolf-Folk *are* before evaluating ability mods and trait pills. Trade is more vertical scrolling, but the card content was already going wider than three columns comfortably allowed once the parchment theme bumped padding. Schema: CladeDef and SpeciesDef gain a Description field (string, empty default). Populated for all 7 clades and 19 species, sourced from the doc's italicized blockquote + a one-sentence summary of the prose paragraph that follows. Empty descriptions fall through silently — a species without a description still renders, just without the paragraph. UI: MakeGrid in both steps becomes Columns = 1 with ExpandFill; BuildCard sets card.SizeFlagsHorizontal = ExpandFill (replaces the fixed CustomMinimumSize 200) and prepends the autowrap description label after the meta line. Hybrid mode stacks sire and dam single- column grids vertically — same logic as before, just one card wide each. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
458 lines
18 KiB
C#
458 lines
18 KiB
C#
using Godot;
|
|
using Theriapolis.Core.Data;
|
|
using Theriapolis.GodotHost.Scenes.Widgets;
|
|
using Theriapolis.GodotHost.UI;
|
|
|
|
namespace Theriapolis.GodotHost.Scenes.Steps;
|
|
|
|
/// <summary>
|
|
/// Step II — Species. Direct port of <c>StepSpecies</c> in
|
|
/// <c>src/steps.jsx</c> plus the Phase 6.5 hybrid extension: when
|
|
/// <see cref="CharacterDraft.IsHybrid"/> is true the step shows two
|
|
/// stacked grids, one filtered by SireCladeId and one by DamCladeId.
|
|
/// </summary>
|
|
public partial class StepSpecies : VBoxContainer, IStep
|
|
{
|
|
private CharacterDraft _draft = null!;
|
|
private VBoxContainer _purebredSection = null!;
|
|
private VBoxContainer _hybridSection = null!;
|
|
private GridContainer _purebredGrid = null!;
|
|
private GridContainer _sireGrid = null!;
|
|
private GridContainer _damGrid = null!;
|
|
|
|
// Phase B species trait + detriment pickers — single-pick per lineage.
|
|
private VBoxContainer _pickSection = null!;
|
|
private VBoxContainer _sirePickCol = null!;
|
|
private VBoxContainer _damPickCol = null!;
|
|
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;
|
|
// Defer Refresh so it runs after the click callback that triggered
|
|
// Changed completes — Free() called on a card mid-callback returns
|
|
// without freeing, leaving a stale duplicate in the grid.
|
|
_draft.Changed += () => Callable.From(Refresh).CallDeferred();
|
|
Build();
|
|
}
|
|
|
|
public string? Validate() => WizardValidation.Validate(1, _draft);
|
|
|
|
private void Build()
|
|
{
|
|
AddThemeConstantOverride("separation", 16);
|
|
|
|
var intro = new VBoxContainer();
|
|
intro.AddThemeConstantOverride("separation", 6);
|
|
AddChild(intro);
|
|
intro.AddChild(new Label { Text = "FOLIO II · SPECIES", ThemeTypeVariation = "Eyebrow" });
|
|
intro.AddChild(new Label { Text = "Choose a Species", ThemeTypeVariation = "H2" });
|
|
intro.AddChild(new Label
|
|
{
|
|
Text = "Refine your line. Species inherits the clade's traits and adds its "
|
|
+ "own — body size, base movement speed, and species-specific abilities. "
|
|
+ "Hybrid characters pick one species per parent lineage.",
|
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
|
});
|
|
|
|
_purebredSection = new VBoxContainer();
|
|
_purebredSection.AddThemeConstantOverride("separation", 6);
|
|
AddChild(_purebredSection);
|
|
_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);
|
|
|
|
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
|
_sireGrid = MakeGrid();
|
|
_hybridSection.AddChild(_sireGrid);
|
|
|
|
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
|
_damGrid = MakeGrid();
|
|
_hybridSection.AddChild(_damGrid);
|
|
|
|
// Phase B species pickers: one trait + one detriment per parent.
|
|
_pickSection = new VBoxContainer();
|
|
_pickSection.AddThemeConstantOverride("separation", 8);
|
|
_hybridSection.AddChild(_pickSection);
|
|
_pickSection.AddChild(new Label { Text = "SPECIES TRAITS & DETRIMENTS", ThemeTypeVariation = "Eyebrow" });
|
|
_pickSection.AddChild(new Label
|
|
{
|
|
Text = "Pick one species trait and one species detriment from each parent. "
|
|
+ "GMs may rule that traits express partially in hybrids.",
|
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
|
});
|
|
|
|
var pickGrid = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
|
pickGrid.AddThemeConstantOverride("separation", 16);
|
|
_pickSection.AddChild(pickGrid);
|
|
|
|
_sirePickCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
|
_sirePickCol.AddThemeConstantOverride("separation", 6);
|
|
pickGrid.AddChild(_sirePickCol);
|
|
|
|
_damPickCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
|
_damPickCol.AddThemeConstantOverride("separation", 6);
|
|
pickGrid.AddChild(_damPickCol);
|
|
|
|
Refresh();
|
|
}
|
|
|
|
private static GridContainer MakeGrid()
|
|
{
|
|
// Single-column layout: each card spans the wizard's content width
|
|
// and surfaces the species' description text. Matches StepClade.
|
|
var grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
|
grid.AddThemeConstantOverride("v_separation", 12);
|
|
return grid;
|
|
}
|
|
|
|
private void Refresh()
|
|
{
|
|
if (_purebredGrid is null) return;
|
|
bool hybrid = _draft.IsHybrid;
|
|
_purebredSection.Visible = !hybrid;
|
|
_hybridSection.Visible = hybrid;
|
|
|
|
if (hybrid)
|
|
{
|
|
RefreshGrid(_sireGrid, _draft.SireCladeId, _draft.SireSpeciesId,
|
|
spId => OnLineageSpeciesPicked("sire", spId));
|
|
RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId,
|
|
spId => OnLineageSpeciesPicked("dam", spId));
|
|
|
|
SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire",
|
|
_draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
|
|
SyncSpeciesPicks(_damPickCol, ref _damPicksBuiltFor, "dam",
|
|
_draft.DamSpeciesId, _draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment);
|
|
}
|
|
else
|
|
{
|
|
RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId,
|
|
spId => OnPurebredSpeciesPicked(spId));
|
|
SyncPurebredVariant();
|
|
}
|
|
}
|
|
|
|
private void OnPurebredSpeciesPicked(string speciesId)
|
|
{
|
|
_draft.Patch(new Godot.Collections.Dictionary
|
|
{
|
|
{ "species_id", speciesId },
|
|
// Species swap invalidates lineage variant.
|
|
{ "species_variant", "" },
|
|
});
|
|
}
|
|
|
|
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.
|
|
{ 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.
|
|
/// </summary>
|
|
private void SyncSpeciesPicks(VBoxContainer col, ref string builtFor,
|
|
string lineage, string speciesId,
|
|
string chosenTrait, string chosenDetriment)
|
|
{
|
|
if (speciesId == builtFor)
|
|
{
|
|
UpdateRadioGroup(col, "trait", chosenTrait);
|
|
UpdateRadioGroup(col, "detriment", chosenDetriment);
|
|
UpdateRadioGroup(col, "lineage",
|
|
lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant);
|
|
return;
|
|
}
|
|
|
|
foreach (var c in col.GetChildren()) c.Free();
|
|
builtFor = speciesId;
|
|
|
|
var sp = CodexContent.SpeciesById(speciesId);
|
|
col.AddChild(new Label
|
|
{
|
|
Text = lineage.ToUpperInvariant() + (sp is null ? "" : " — " + sp.Name),
|
|
ThemeTypeVariation = "Eyebrow",
|
|
});
|
|
if (sp is null)
|
|
{
|
|
col.AddChild(new Label { Text = "(pick a species above)" });
|
|
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);
|
|
|
|
col.AddChild(new Label { Text = "Detriment", ThemeTypeVariation = "Eyebrow" });
|
|
if (sp.Detriments.Length == 0)
|
|
{
|
|
col.AddChild(new Label { Text = "(none)" });
|
|
}
|
|
else
|
|
{
|
|
BuildRadioGroup(col, "detriment", lineage, sp.Detriments, chosenDetriment,
|
|
(lin, id) => OnSpeciesPickToggled(lin, "detriment", id), isDetriment: true);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
var holder = new VBoxContainer { Name = $"{kind}_group" };
|
|
holder.AddThemeConstantOverride("separation", 4);
|
|
parent.AddChild(holder);
|
|
|
|
foreach (var t in options)
|
|
{
|
|
string captured = t.Id;
|
|
string capturedName = t.Name;
|
|
string capturedDesc = t.Description;
|
|
var btn = new Button
|
|
{
|
|
Text = t.Name,
|
|
ToggleMode = true,
|
|
ButtonPressed = t.Id == selected,
|
|
FocusMode = Control.FocusModeEnum.None,
|
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
|
Name = t.Id,
|
|
};
|
|
// Single Pressed signal handles both directions: if the
|
|
// button is now pressed (its own click flipped it on),
|
|
// commit the pick; if it's now off, clear the field.
|
|
// Reading btn.ButtonPressed lets us avoid the stale-closure
|
|
// bug from capturing `selected` at build time.
|
|
var btnRef = btn;
|
|
btn.Pressed += () =>
|
|
onPicked(lineage, btnRef.ButtonPressed ? captured : "");
|
|
btn.MouseEntered += () =>
|
|
PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc,
|
|
isDetriment ? "" : "trait", isDetriment);
|
|
btn.MouseExited += () =>
|
|
PopoverLayer.Instance?.ScheduleClose();
|
|
holder.AddChild(btn);
|
|
}
|
|
}
|
|
|
|
private static void UpdateRadioGroup(VBoxContainer col, string kind, string selected)
|
|
{
|
|
// Find the holder by Name. Bail silently if missing — a species
|
|
// with no detriments has no detriment_group node.
|
|
var holder = col.GetNodeOrNull<VBoxContainer>($"{kind}_group");
|
|
if (holder is null) return;
|
|
foreach (var child in holder.GetChildren())
|
|
{
|
|
if (child is Button btn)
|
|
{
|
|
bool want = btn.Name == selected;
|
|
if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnSpeciesPickToggled(string lineage, string kind, string traitId)
|
|
{
|
|
string field = lineage + "_chosen_species_" + kind;
|
|
_draft.Patch(new Godot.Collections.Dictionary { { field, traitId } });
|
|
}
|
|
|
|
private static void RefreshGrid(GridContainer grid, string cladeId, string selectedSpecies, System.Action<string> onClick)
|
|
{
|
|
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));
|
|
}
|
|
|
|
private static Control BuildCard(SpeciesDef sp, bool selected, System.Action<string> onClick)
|
|
{
|
|
var card = CodexCard.Make();
|
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
CodexCard.SetSelected(card, selected);
|
|
|
|
card.GuiInput += (InputEvent e) =>
|
|
{
|
|
if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
|
onClick(sp.Id);
|
|
};
|
|
|
|
var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
|
|
box.AddThemeConstantOverride("separation", 6);
|
|
card.AddChild(box);
|
|
|
|
box.AddChild(new Label { Text = sp.Name, ThemeTypeVariation = "CardName" });
|
|
box.AddChild(new Label
|
|
{
|
|
Text = $"{sp.Size.ToUpperInvariant()} · {sp.BaseSpeedFt} FT/TURN",
|
|
ThemeTypeVariation = "CardMeta",
|
|
});
|
|
|
|
if (!string.IsNullOrEmpty(sp.Description))
|
|
{
|
|
box.AddChild(new Label
|
|
{
|
|
Text = sp.Description,
|
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
|
MouseFilter = Control.MouseFilterEnum.Pass,
|
|
});
|
|
}
|
|
|
|
if (sp.AbilityMods.Count > 0)
|
|
{
|
|
var modsRow = new HBoxContainer();
|
|
modsRow.AddThemeConstantOverride("separation", 8);
|
|
box.AddChild(modsRow);
|
|
foreach (var (k, v) in sp.AbilityMods)
|
|
modsRow.AddChild(new Label { Text = $"{k} {(v >= 0 ? "+" : "")}{v}" });
|
|
}
|
|
|
|
if (sp.Traits.Length > 0 || sp.Detriments.Length > 0)
|
|
{
|
|
var chips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass };
|
|
chips.AddThemeConstantOverride("h_separation", 6);
|
|
chips.AddThemeConstantOverride("v_separation", 4);
|
|
box.AddChild(chips);
|
|
foreach (var t in sp.Traits)
|
|
chips.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
|
foreach (var d in sp.Detriments)
|
|
chips.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
|
}
|
|
|
|
return card;
|
|
}
|
|
}
|