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>
583 lines
23 KiB
C#
583 lines
23 KiB
C#
using Godot;
|
|
using System.Collections.Generic;
|
|
using Theriapolis.Core.Data;
|
|
using Theriapolis.GodotHost.Scenes.Widgets;
|
|
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>, plus the Phase 6.5 hybrid-origin extension
|
|
/// per port plan §10: a Hybrid toggle splits the picker into Sire +
|
|
/// Dam grids, each independent. The dominant-parent radio drives
|
|
/// <see cref="CharacterDraft.EffectiveCladeId"/> for downstream steps.
|
|
///
|
|
/// Cards are built once and mutated in place (Modulate update only) on
|
|
/// selection change — tearing down + rebuilding inside the click
|
|
/// callback chain caused duplicates because Free() defers when the
|
|
/// freed node is currently mid-signal.
|
|
/// </summary>
|
|
public partial class StepClade : VBoxContainer, IStep
|
|
{
|
|
private CharacterDraft _draft = null!;
|
|
private Button _hybridToggle = null!;
|
|
private Button _sexMaleBtn = null!;
|
|
private Button _sexFemaleBtn = null!;
|
|
private VBoxContainer _purebredSection = null!;
|
|
private VBoxContainer _hybridSection = null!;
|
|
private OptionButton _dominantToggle = null!;
|
|
|
|
// Lineage-bonus pickers (per theriapolis-rpg-clades.md hybrid rules,
|
|
// simplified to allow stacking on the same ability).
|
|
private VBoxContainer _bonusSection = null!;
|
|
private HBoxContainer _sireBonusRow = null!;
|
|
private HBoxContainer _damBonusRow = null!;
|
|
|
|
// Phase B clade-trait pickers: 2 from dominant parent + 1 from non-dominant.
|
|
private VBoxContainer _traitSection = null!;
|
|
private VBoxContainer _sireTraitCol = null!;
|
|
private VBoxContainer _damTraitCol = null!;
|
|
private Label _sireTraitHeader = null!;
|
|
private Label _damTraitHeader = null!;
|
|
|
|
private readonly Dictionary<string, PanelContainer> _purebredCards = new();
|
|
private readonly Dictionary<string, PanelContainer> _sireCards = new();
|
|
private readonly Dictionary<string, PanelContainer> _damCards = new();
|
|
|
|
// Bonus rows are mutated in place too: when the clade hasn't changed
|
|
// since the last build, we only flip ButtonPressed states. Rebuilding
|
|
// would Free() the very button whose Pressed callback we're inside,
|
|
// which Godot defers — leaving stale buttons next to the new ones.
|
|
private readonly Dictionary<string, Button> _sireBonusButtons = new();
|
|
private readonly Dictionary<string, Button> _damBonusButtons = new();
|
|
private string _sireBonusBuiltFor = "";
|
|
private string _damBonusBuiltFor = "";
|
|
|
|
private readonly Dictionary<string, Button> _sireTraitButtons = new();
|
|
private readonly Dictionary<string, Button> _damTraitButtons = new();
|
|
private string _sireTraitsBuiltFor = "";
|
|
private string _damTraitsBuiltFor = "";
|
|
|
|
public void Bind(CharacterDraft draft)
|
|
{
|
|
_draft = draft;
|
|
_draft.Changed += Refresh;
|
|
Build();
|
|
}
|
|
|
|
public string? Validate() => WizardValidation.Validate(0, _draft);
|
|
|
|
private void Build()
|
|
{
|
|
AddThemeConstantOverride("separation", 16);
|
|
|
|
var intro = new VBoxContainer();
|
|
intro.AddThemeConstantOverride("separation", 6);
|
|
AddChild(intro);
|
|
intro.AddChild(new Label { Text = "FOLIO I · CLADE", ThemeTypeVariation = "Eyebrow" });
|
|
intro.AddChild(new Label { Text = "Choose a Clade", ThemeTypeVariation = "H2" });
|
|
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. Hybrid characters blend two lineages.",
|
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
|
});
|
|
|
|
// Sex picker — required for every character. For purebreds with
|
|
// sex-axis variants (Elk, Lion) this drives variant resolution;
|
|
// for hybrids it's identity-only since sire/dam are male/female
|
|
// by parent-role definition.
|
|
var sexRow = new HBoxContainer();
|
|
sexRow.AddThemeConstantOverride("separation", 8);
|
|
AddChild(sexRow);
|
|
sexRow.AddChild(new Label { Text = "SEX", ThemeTypeVariation = "Eyebrow" });
|
|
_sexMaleBtn = new Button
|
|
{
|
|
Text = "Male", ToggleMode = true, FocusMode = Control.FocusModeEnum.None,
|
|
};
|
|
_sexFemaleBtn = new Button
|
|
{
|
|
Text = "Female", ToggleMode = true, FocusMode = Control.FocusModeEnum.None,
|
|
};
|
|
_sexMaleBtn.Pressed += () => OnSexPicked("male");
|
|
_sexFemaleBtn.Pressed += () => OnSexPicked("female");
|
|
sexRow.AddChild(_sexMaleBtn);
|
|
sexRow.AddChild(_sexFemaleBtn);
|
|
|
|
// Toggle Button (not CheckBox) so the inverted-on-press button style
|
|
// from the codex theme handles selection visually — no checkbox glyph
|
|
// needed, the bg colour shift is the affordance.
|
|
var toggleRow = new HBoxContainer();
|
|
toggleRow.AddThemeConstantOverride("separation", 12);
|
|
AddChild(toggleRow);
|
|
_hybridToggle = new Button
|
|
{
|
|
Text = "Hybrid Origin (two parent lineages)",
|
|
ToggleMode = true,
|
|
FocusMode = Control.FocusModeEnum.None,
|
|
};
|
|
_hybridToggle.Toggled += OnHybridToggled;
|
|
toggleRow.AddChild(_hybridToggle);
|
|
|
|
// Purebred section
|
|
_purebredSection = new VBoxContainer();
|
|
_purebredSection.AddThemeConstantOverride("separation", 6);
|
|
AddChild(_purebredSection);
|
|
var purebredGrid = MakeGrid();
|
|
_purebredSection.AddChild(purebredGrid);
|
|
PopulateGrid(purebredGrid, _purebredCards, OnPurebredCladePicked);
|
|
|
|
// Hybrid section
|
|
_hybridSection = new VBoxContainer();
|
|
_hybridSection.AddThemeConstantOverride("separation", 16);
|
|
AddChild(_hybridSection);
|
|
|
|
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
|
var sireGrid = MakeGrid();
|
|
_hybridSection.AddChild(sireGrid);
|
|
PopulateGrid(sireGrid, _sireCards, id => OnLineageCladePicked("sire", id));
|
|
|
|
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
|
var damGrid = MakeGrid();
|
|
_hybridSection.AddChild(damGrid);
|
|
PopulateGrid(damGrid, _damCards, id => OnLineageCladePicked("dam", id));
|
|
|
|
// Lineage bonus pickers — hybrids pick ONE ability mod from each
|
|
// parent clade. Stacking on the same ability is allowed; mods sum.
|
|
_bonusSection = new VBoxContainer();
|
|
_bonusSection.AddThemeConstantOverride("separation", 8);
|
|
_hybridSection.AddChild(_bonusSection);
|
|
_bonusSection.AddChild(new Label { Text = "LINEAGE BONUSES", ThemeTypeVariation = "Eyebrow" });
|
|
|
|
_sireBonusRow = new HBoxContainer();
|
|
_sireBonusRow.AddThemeConstantOverride("separation", 8);
|
|
_bonusSection.AddChild(_sireBonusRow);
|
|
|
|
_damBonusRow = new HBoxContainer();
|
|
_damBonusRow.AddThemeConstantOverride("separation", 8);
|
|
_bonusSection.AddChild(_damBonusRow);
|
|
|
|
var dominantRow = new HBoxContainer();
|
|
dominantRow.AddThemeConstantOverride("separation", 8);
|
|
_hybridSection.AddChild(dominantRow);
|
|
dominantRow.AddChild(new Label { Text = "DOMINANT LINEAGE", ThemeTypeVariation = "Eyebrow" });
|
|
_dominantToggle = new OptionButton();
|
|
_dominantToggle.AddItem("Sire", 0);
|
|
_dominantToggle.AddItem("Dam", 1);
|
|
_dominantToggle.ItemSelected += OnDominantSelected;
|
|
dominantRow.AddChild(_dominantToggle);
|
|
|
|
// Phase B clade-trait pickers — per doc "2/1 split, player's choice
|
|
// of which parent is dominant". DominantParent drives the limit.
|
|
_traitSection = new VBoxContainer();
|
|
_traitSection.AddThemeConstantOverride("separation", 8);
|
|
_hybridSection.AddChild(_traitSection);
|
|
_traitSection.AddChild(new Label { Text = "CLADE TRAITS", ThemeTypeVariation = "Eyebrow" });
|
|
_traitSection.AddChild(new Label
|
|
{
|
|
Text = "Pick two clade traits from the dominant parent and one from the other. "
|
|
+ "All clade detriments from both parents are inherited automatically.",
|
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
|
});
|
|
|
|
var traitGrid = new HBoxContainer();
|
|
traitGrid.AddThemeConstantOverride("separation", 16);
|
|
traitGrid.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
|
_traitSection.AddChild(traitGrid);
|
|
|
|
_sireTraitCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
|
_sireTraitCol.AddThemeConstantOverride("separation", 4);
|
|
traitGrid.AddChild(_sireTraitCol);
|
|
_sireTraitHeader = new Label { Text = "SIRE", ThemeTypeVariation = "Eyebrow" };
|
|
_sireTraitCol.AddChild(_sireTraitHeader);
|
|
|
|
_damTraitCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
|
_damTraitCol.AddThemeConstantOverride("separation", 4);
|
|
traitGrid.AddChild(_damTraitCol);
|
|
_damTraitHeader = new Label { Text = "DAM", ThemeTypeVariation = "Eyebrow" };
|
|
_damTraitCol.AddChild(_damTraitHeader);
|
|
|
|
Refresh();
|
|
}
|
|
|
|
private static GridContainer MakeGrid()
|
|
{
|
|
// Single-column layout: each card spans the wizard's content width
|
|
// and surfaces the clade's description text. Establishes the
|
|
// world's tone before mechanics — the trade is more scrolling.
|
|
var grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
|
grid.AddThemeConstantOverride("v_separation", 12);
|
|
return grid;
|
|
}
|
|
|
|
private void PopulateGrid(GridContainer grid, Dictionary<string, PanelContainer> cardMap, System.Action<string> onClick)
|
|
{
|
|
foreach (var clade in CodexContent.Clades)
|
|
{
|
|
var card = BuildCard(clade, onClick);
|
|
cardMap[clade.Id] = card;
|
|
grid.AddChild(card);
|
|
}
|
|
}
|
|
|
|
private void OnHybridToggled(bool pressed)
|
|
{
|
|
var patch = new Godot.Collections.Dictionary { { "is_hybrid", pressed } };
|
|
if (pressed)
|
|
{
|
|
patch["clade_id"] = "";
|
|
patch["species_id"] = "";
|
|
}
|
|
else
|
|
{
|
|
patch["sire_clade_id"] = "";
|
|
patch["sire_species_id"] = "";
|
|
patch["dam_clade_id"] = "";
|
|
patch["dam_species_id"] = "";
|
|
patch["sire_chosen_ability"] = "";
|
|
patch["dam_chosen_ability"] = "";
|
|
patch["sire_chosen_clade_traits"] = new Godot.Collections.Array<string>();
|
|
patch["dam_chosen_clade_traits"] = new Godot.Collections.Array<string>();
|
|
patch["sire_chosen_species_trait"] = "";
|
|
patch["dam_chosen_species_trait"] = "";
|
|
patch["sire_chosen_species_detriment"] = "";
|
|
patch["dam_chosen_species_detriment"] = "";
|
|
}
|
|
ClearBackgroundIfInvalidated(patch);
|
|
_draft.Patch(patch);
|
|
}
|
|
|
|
private void OnSexPicked(string sex) =>
|
|
_draft.Patch(new Godot.Collections.Dictionary { { "sex", sex } });
|
|
|
|
private void OnDominantSelected(long index)
|
|
{
|
|
string newDominant = index == 0 ? "sire" : "dam";
|
|
var patch = new Godot.Collections.Dictionary
|
|
{
|
|
{ "dominant_parent", newDominant },
|
|
};
|
|
|
|
// Trim clade-trait picks down to the new limit per side: 2 for the
|
|
// dominant lineage, 1 for the other. Only trims, never expands —
|
|
// user re-picks the missing slot manually.
|
|
TrimToLimit(patch, "sire", _draft.SireChosenCladeTraits, newDominant == "sire" ? 2 : 1);
|
|
TrimToLimit(patch, "dam", _draft.DamChosenCladeTraits, newDominant == "dam" ? 2 : 1);
|
|
|
|
_draft.Patch(patch);
|
|
}
|
|
|
|
private static void TrimToLimit(Godot.Collections.Dictionary patch, string lineage,
|
|
Godot.Collections.Array<string> current, int limit)
|
|
{
|
|
if (current.Count <= limit) return;
|
|
var trimmed = new Godot.Collections.Array<string>();
|
|
for (int i = 0; i < limit; i++) trimmed.Add(current[i]);
|
|
patch[lineage + "_chosen_clade_traits"] = trimmed;
|
|
}
|
|
|
|
private void Refresh()
|
|
{
|
|
if (_hybridToggle is null) return;
|
|
|
|
bool hybrid = _draft.IsHybrid;
|
|
if (_hybridToggle.ButtonPressed != hybrid) _hybridToggle.SetPressedNoSignal(hybrid);
|
|
_purebredSection.Visible = !hybrid;
|
|
_hybridSection.Visible = hybrid;
|
|
|
|
bool isMale = _draft.Sex == "male";
|
|
bool isFemale = _draft.Sex == "female";
|
|
if (_sexMaleBtn.ButtonPressed != isMale) _sexMaleBtn.SetPressedNoSignal(isMale);
|
|
if (_sexFemaleBtn.ButtonPressed != isFemale) _sexFemaleBtn.SetPressedNoSignal(isFemale);
|
|
|
|
UpdateSelection(_purebredCards, _draft.CladeId);
|
|
UpdateSelection(_sireCards, _draft.SireCladeId);
|
|
UpdateSelection(_damCards, _draft.DamCladeId);
|
|
|
|
int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0;
|
|
if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx);
|
|
|
|
if (hybrid)
|
|
{
|
|
RebuildBonusPickers();
|
|
RebuildTraitPickers();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Syncs the lineage-bonus button rows with the current sire/dam
|
|
/// clade picks. When the clade hasn't changed since the last build
|
|
/// we only update each button's ButtonPressed state — see field
|
|
/// comment above for why a full rebuild here is unsafe.
|
|
/// </summary>
|
|
private void RebuildBonusPickers()
|
|
{
|
|
SyncLineageBonusRow(_sireBonusRow, _sireBonusButtons, ref _sireBonusBuiltFor, "sire",
|
|
_draft.SireCladeId, _draft.SireChosenAbility);
|
|
SyncLineageBonusRow(_damBonusRow, _damBonusButtons, ref _damBonusBuiltFor, "dam",
|
|
_draft.DamCladeId, _draft.DamChosenAbility);
|
|
}
|
|
|
|
private void SyncLineageBonusRow(HBoxContainer row, Dictionary<string, Button> cache,
|
|
ref string builtFor, string lineage,
|
|
string cladeId, string chosen)
|
|
{
|
|
if (cladeId == builtFor)
|
|
{
|
|
foreach (var (ab, btn) in cache)
|
|
{
|
|
bool want = ab == chosen;
|
|
if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want);
|
|
}
|
|
return;
|
|
}
|
|
|
|
foreach (var c in row.GetChildren()) c.Free();
|
|
cache.Clear();
|
|
builtFor = cladeId;
|
|
|
|
var clade = CodexContent.Clade(cladeId);
|
|
if (clade is null)
|
|
{
|
|
row.AddChild(new Label { Text = "(pick a clade above)" });
|
|
return;
|
|
}
|
|
|
|
foreach (var (ab, value) in clade.AbilityMods)
|
|
{
|
|
string captured = ab;
|
|
var btn = new Button
|
|
{
|
|
Text = $"{ab} {(value >= 0 ? "+" : "")}{value}",
|
|
ToggleMode = true,
|
|
ButtonPressed = ab == chosen,
|
|
FocusMode = Control.FocusModeEnum.None,
|
|
};
|
|
btn.Pressed += () => OnLineageBonusPicked(lineage, captured);
|
|
row.AddChild(btn);
|
|
cache[ab] = btn;
|
|
}
|
|
}
|
|
|
|
private static void UpdateSelection(Dictionary<string, PanelContainer> cards, string selectedId)
|
|
{
|
|
foreach (var (id, card) in cards)
|
|
CodexCard.SetSelected(card, id == selectedId);
|
|
}
|
|
|
|
private void OnPurebredCladePicked(string cladeId)
|
|
{
|
|
string speciesId = _draft.SpeciesId;
|
|
var sp = CodexContent.SpeciesById(speciesId);
|
|
if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase))
|
|
speciesId = "";
|
|
var patch = new Godot.Collections.Dictionary
|
|
{
|
|
{ "clade_id", cladeId },
|
|
{ "species_id", speciesId },
|
|
};
|
|
ClearBackgroundIfInvalidated(patch);
|
|
_draft.Patch(patch);
|
|
}
|
|
|
|
private void OnLineageCladePicked(string lineage, string cladeId)
|
|
{
|
|
var patch = new Godot.Collections.Dictionary
|
|
{
|
|
{ lineage + "_clade_id", cladeId },
|
|
};
|
|
string currentSpecies = lineage == "sire" ? _draft.SireSpeciesId : _draft.DamSpeciesId;
|
|
var sp = CodexContent.SpeciesById(currentSpecies);
|
|
if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
patch[lineage + "_species_id"] = "";
|
|
patch[lineage + "_chosen_species_trait"] = "";
|
|
patch[lineage + "_chosen_species_detriment"] = "";
|
|
}
|
|
|
|
// Clade swap invalidates the previously-picked lineage bonus
|
|
// (different clade has different mod options) and the clade
|
|
// trait picks.
|
|
patch[lineage + "_chosen_ability"] = "";
|
|
patch[lineage + "_chosen_clade_traits"] = new Godot.Collections.Array<string>();
|
|
|
|
ClearBackgroundIfInvalidated(patch);
|
|
_draft.Patch(patch);
|
|
}
|
|
|
|
private void OnLineageBonusPicked(string lineage, string ability)
|
|
{
|
|
_draft.Patch(new Godot.Collections.Dictionary
|
|
{
|
|
{ lineage + "_chosen_ability", ability },
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Syncs both clade-trait columns. Header text reflects the current
|
|
/// limit (2 for dominant, 1 for other). Mutate-in-place same as
|
|
/// SyncLineageBonusRow — Free()-during-Pressed leaves stale buttons.
|
|
/// </summary>
|
|
private void RebuildTraitPickers()
|
|
{
|
|
SyncCladeTraitColumn(_sireTraitCol, _sireTraitHeader, _sireTraitButtons,
|
|
ref _sireTraitsBuiltFor, "sire", _draft.SireCladeId,
|
|
_draft.SireChosenCladeTraits, _draft.CladeTraitLimit("sire"));
|
|
SyncCladeTraitColumn(_damTraitCol, _damTraitHeader, _damTraitButtons,
|
|
ref _damTraitsBuiltFor, "dam", _draft.DamCladeId,
|
|
_draft.DamChosenCladeTraits, _draft.CladeTraitLimit("dam"));
|
|
}
|
|
|
|
private void SyncCladeTraitColumn(VBoxContainer col, Label header,
|
|
Dictionary<string, Button> cache,
|
|
ref string builtFor, string lineage,
|
|
string cladeId,
|
|
Godot.Collections.Array<string> chosen,
|
|
int limit)
|
|
{
|
|
string headerLabel = lineage.ToUpperInvariant() + (lineage == _draft.DominantParent ? " ★" : "");
|
|
header.Text = $"{headerLabel} ({chosen.Count}/{limit})";
|
|
|
|
if (cladeId == builtFor)
|
|
{
|
|
foreach (var (id, btn) in cache)
|
|
{
|
|
bool want = chosen.Contains(id);
|
|
if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want);
|
|
bool atCap = chosen.Count >= limit && !want;
|
|
btn.Disabled = atCap;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Remove old buttons but keep the header (index 0).
|
|
for (int i = col.GetChildCount() - 1; i >= 1; i--) col.GetChild(i).Free();
|
|
cache.Clear();
|
|
builtFor = cladeId;
|
|
|
|
var clade = CodexContent.Clade(cladeId);
|
|
if (clade is null)
|
|
{
|
|
col.AddChild(new Label { Text = "(pick a clade above)" });
|
|
return;
|
|
}
|
|
|
|
foreach (var t in clade.Traits)
|
|
{
|
|
string captured = t.Id;
|
|
string capturedName = t.Name;
|
|
string capturedDesc = t.Description;
|
|
bool isPicked = chosen.Contains(t.Id);
|
|
var btn = new Button
|
|
{
|
|
Text = t.Name,
|
|
ToggleMode = true,
|
|
ButtonPressed = isPicked,
|
|
FocusMode = Control.FocusModeEnum.None,
|
|
Disabled = !isPicked && chosen.Count >= limit,
|
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
|
};
|
|
btn.Toggled += pressed => OnCladeTraitToggled(lineage, captured, pressed);
|
|
var btnRef = btn;
|
|
btn.MouseEntered += () =>
|
|
PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, "trait", false);
|
|
btn.MouseExited += () =>
|
|
PopoverLayer.Instance?.ScheduleClose();
|
|
col.AddChild(btn);
|
|
cache[t.Id] = btn;
|
|
}
|
|
}
|
|
|
|
private void OnCladeTraitToggled(string lineage, string traitId, bool pressed)
|
|
{
|
|
var current = lineage == "sire" ? _draft.SireChosenCladeTraits : _draft.DamChosenCladeTraits;
|
|
int limit = _draft.CladeTraitLimit(lineage);
|
|
var next = new Godot.Collections.Array<string>(current);
|
|
if (pressed)
|
|
{
|
|
if (next.Contains(traitId)) return;
|
|
if (next.Count >= limit) return; // cap (UI also disables, defense-in-depth)
|
|
next.Add(traitId);
|
|
}
|
|
else
|
|
{
|
|
next.Remove(traitId);
|
|
}
|
|
_draft.Patch(new Godot.Collections.Dictionary
|
|
{
|
|
{ lineage + "_chosen_clade_traits", next },
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clade changes can make a previously-valid background unavailable
|
|
/// (e.g. picking Felidae while Pack-Raised is selected). Build the
|
|
/// hypothetical post-patch state via Resource.Duplicate, run it
|
|
/// through BackgroundAvailability, and clear background_id in the
|
|
/// patch if the rule no longer matches.
|
|
/// </summary>
|
|
private void ClearBackgroundIfInvalidated(Godot.Collections.Dictionary patch)
|
|
{
|
|
if (string.IsNullOrEmpty(_draft.BackgroundId)) return;
|
|
|
|
var future = (CharacterDraft)_draft.Duplicate();
|
|
// Apply the same patch we're about to commit, in-place on the copy.
|
|
future.Patch(patch);
|
|
if (!BackgroundAvailability.IsAvailable(_draft.BackgroundId, future))
|
|
patch["background_id"] = "";
|
|
}
|
|
|
|
private PanelContainer BuildCard(CladeDef clade, System.Action<string> onClick)
|
|
{
|
|
var card = CodexCard.Make();
|
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
card.GuiInput += (InputEvent e) =>
|
|
{
|
|
if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
|
onClick(clade.Id);
|
|
};
|
|
|
|
var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
|
|
box.AddThemeConstantOverride("separation", 6);
|
|
card.AddChild(box);
|
|
|
|
box.AddChild(new Label { Text = clade.Name, ThemeTypeVariation = "CardName" });
|
|
box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
|
|
|
|
if (!string.IsNullOrEmpty(clade.Description))
|
|
{
|
|
box.AddChild(new Label
|
|
{
|
|
Text = clade.Description,
|
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
|
MouseFilter = Control.MouseFilterEnum.Pass,
|
|
});
|
|
}
|
|
|
|
if (clade.AbilityMods.Count > 0)
|
|
{
|
|
var modsRow = new HBoxContainer();
|
|
modsRow.AddThemeConstantOverride("separation", 8);
|
|
box.AddChild(modsRow);
|
|
foreach (var (k, v) in clade.AbilityMods)
|
|
modsRow.AddChild(new Label { Text = $"{k} {(v >= 0 ? "+" : "")}{v}" });
|
|
}
|
|
|
|
if (clade.Traits.Length > 0 || clade.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 clade.Traits)
|
|
chips.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
|
foreach (var d in clade.Detriments)
|
|
chips.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
|
}
|
|
|
|
return card;
|
|
}
|
|
}
|