M6.8: Hybrid trait pickers (Phase B)
Per theriapolis-rpg-clades.md "Building a Hybrid": hybrids now pick two clade traits from the dominant parent + one from the other (2/1 split keyed off DominantParent), and one species trait + one species detriment from each parent. All clade detriments still inherit fully from both parents. Universal hybrid detriments unchanged. CharacterDraft gains six new fields (sire/dam clade-trait arrays, sire/dam species trait/detriment ids) and a CladeTraitLimit(lineage) helper. Step 0/1 validators enforce the picks; Aside renders only the chosen subset for hybrids. Cascading clears: clade swap clears that lineage's bonus + clade traits + (if species also invalidated) species pick; species swap clears that lineage's species trait/detriment; dominant flip trims overflow from the end (non-destructive when possible); hybrid-off clears all six new fields. Toggle buttons in both steps wire MouseEntered/Exited into PopoverLayer so the player can read each trait's description on hover (detriment buttons get the red-tinted "DETRIMENT" popover). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,13 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
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 = "";
|
||||
|
||||
public void Bind(CharacterDraft draft)
|
||||
{
|
||||
_draft = draft;
|
||||
@@ -67,6 +74,30 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -88,9 +119,14 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
if (hybrid)
|
||||
{
|
||||
RefreshGrid(_sireGrid, _draft.SireCladeId, _draft.SireSpeciesId,
|
||||
spId => _draft.Patch(new Godot.Collections.Dictionary { { "sire_species_id", spId } }));
|
||||
spId => OnLineageSpeciesPicked("sire", spId));
|
||||
RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId,
|
||||
spId => _draft.Patch(new Godot.Collections.Dictionary { { "dam_species_id", spId } }));
|
||||
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
|
||||
{
|
||||
@@ -99,6 +135,126 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
{ lineage + "_chosen_species_trait", "" },
|
||||
{ lineage + "_chosen_species_detriment", "" },
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutate-in-place sync for the species-pick column (one trait button
|
||||
/// group + one detriment button group, radio-style). 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);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user