Files
TheriapolisV3/Theriapolis.Game/Screens/CharacterCreationScreen.cs
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

887 lines
38 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Theriapolis.Game.UI;
namespace Theriapolis.Game.Screens;
/// <summary>
/// Phase 5 M5 character-creation wizard. 7-step illuminated-codex flow per
/// the Claude Design handoff (`_design_handoff/character_creation/`):
/// Clade → Species → Calling → History → Abilities → Skills → Sign.
///
/// The right-hand aside summarises the character as it builds; an aborted
/// run from the back button returns to the title without committing. The
/// final Confirm button calls <see cref="CharacterBuilder.Build"/> with the
/// resolver's items table so the new character arrives with their starting
/// kit equipped.
///
/// Differences from the React design:
/// - Drag-and-drop stat assignment → click-pick-then-click-place, since
/// Myra doesn't ship native drag-drop. The pool highlights the selected
/// value; the next ability slot click consumes it. Click a filled slot
/// to return its value to the pool.
/// - Hover popovers with full trait descriptions → "Selected" detail line
/// at the bottom of the aside panel that updates on click.
/// - Illuminated-codex visual styling → semi-transparent dark panel with
/// Myra's default fonts. Full art-direction port (parchment background,
/// gilded accents, serif display fonts) is M6+ theme work.
/// </summary>
public sealed class CharacterCreationScreen : IScreen
{
private readonly ulong _seed;
private Game1 _game = null!;
private Desktop _desktop = null!;
private VerticalStackPanel _root = null!;
// Loaded content
private ContentResolver _content = null!;
private CladeDef[] _clades = null!;
private SpeciesDef[] _allSpecies = null!;
private ClassDef[] _classes = null!;
private BackgroundDef[] _backgrounds = null!;
// Wizard state
private int _step;
private CladeDef? _clade;
private SpeciesDef? _species;
private ClassDef? _class;
private BackgroundDef? _background;
private string _name = "Wanderer";
// Stat assignment state
private bool _useRoll;
private readonly List<int> _statPool = new();
private readonly Dictionary<AbilityId, int> _statAssign = new();
private readonly List<int[]> _statHistory = new();
private int? _pendingPoolIdx; // index in _statPool of currently-selected value (click-pick-place)
// Skill state
private readonly HashSet<SkillId> _chosenSkills = new();
// Stat-roll seeding (per the Phase 5 plan §4.2 / DESIGN_INTENT lock).
private readonly long _gameStartMs;
private long _msAtScreenOpen;
// Detail panel (replaces hover popovers) — last clicked trait/skill/feature.
private string _detailTitle = "";
private string _detailBody = "";
private static readonly string[] StepNames = new[]
{
"Clade", "Species", "Calling", "History", "Abilities", "Skills", "Sign",
};
public CharacterCreationScreen(ulong seed)
{
_seed = seed;
_gameStartMs = System.Environment.TickCount64;
}
public void Initialize(Game1 game)
{
_game = game;
_msAtScreenOpen = System.Environment.TickCount64 - _gameStartMs;
var loader = new ContentLoader(_game.ContentDataDirectory);
_content = new ContentResolver(loader);
_clades = _content.Clades.Values.OrderBy(c => c.Id).ToArray();
_allSpecies = _content.Species.Values.OrderBy(s => s.Id).ToArray();
_classes = _content.Classes.Values.OrderBy(c => c.Id).ToArray();
_backgrounds = _content.Backgrounds.Values.OrderBy(b => b.Id).ToArray();
// Defaults so the player can press Confirm immediately.
_clade = _clades.FirstOrDefault(c => c.Id == "canidae") ?? _clades[0];
_species = _allSpecies.FirstOrDefault(s => s.CladeId == _clade.Id);
_class = _classes.FirstOrDefault(c => c.Id == "fangsworn") ?? _classes[0];
_background = _backgrounds.FirstOrDefault(b => b.Id == "pack_raised") ?? _backgrounds[0];
InitStandardArrayPool();
AutoPickSkills();
BuildUI();
}
// ── Layout ───────────────────────────────────────────────────────────
private void BuildUI()
{
_root = new VerticalStackPanel
{
Spacing = 6,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Padding = new Thickness(16, 12, 16, 12),
};
// Header
_root.Widgets.Add(new Label
{
Text = $"THERIAPOLIS — Codex of Becoming · Folio {CodexCopy.Romanize(_step + 1)} of VII — {StepNames[_step]}",
HorizontalAlignment = HorizontalAlignment.Center,
});
_root.Widgets.Add(new Label
{
Text = $"Seed 0x{_seed:X}",
HorizontalAlignment = HorizontalAlignment.Center,
});
_root.Widgets.Add(new Label { Text = " " });
// Stepper
_root.Widgets.Add(BuildStepper());
_root.Widgets.Add(new Label { Text = " " });
// Two-column main area: page-main + aside.
var twoCol = new HorizontalStackPanel
{
Spacing = 18,
HorizontalAlignment = HorizontalAlignment.Stretch,
};
twoCol.Widgets.Add(BuildCurrentStep());
twoCol.Widgets.Add(BuildAside());
_root.Widgets.Add(twoCol);
// Nav bar
_root.Widgets.Add(new Label { Text = " " });
_root.Widgets.Add(BuildNav());
_desktop = new Desktop { Root = _root };
}
private void Rebuild() => BuildUI();
private Widget BuildStepper()
{
var row = new HorizontalStackPanel { Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center };
int firstIncomplete = -1;
for (int i = 0; i < StepNames.Length; i++) if (ValidateStep(i) is not null) { firstIncomplete = i; break; }
for (int i = 0; i < StepNames.Length; i++)
{
bool isCurrent = i == _step;
bool isComplete = ValidateStep(i) is null && !isCurrent;
bool locked = i > _step && firstIncomplete != -1 && firstIncomplete < i;
string mark = locked ? "✕" : (isComplete ? "✓" : CodexCopy.Romanize(i + 1));
string label = $"{mark} {StepNames[i]}";
int idx = i;
var btn = new TextButton
{
Text = isCurrent ? "→ " + label : " " + label,
Padding = new Thickness(8, 4, 8, 4),
Enabled = !locked,
};
if (!locked) btn.Click += (_, _) => { _step = idx; Rebuild(); };
row.Widgets.Add(btn);
}
return row;
}
private Widget BuildCurrentStep()
{
var panel = new VerticalStackPanel
{
Spacing = 6,
Width = 720,
Padding = new Thickness(12, 10, 12, 10),
Background = new SolidBrush(new Color(15, 15, 25, 220)),
};
switch (_step)
{
case 0: BuildStepClade(panel); break;
case 1: BuildStepSpecies(panel); break;
case 2: BuildStepClass(panel); break;
case 3: BuildStepBackground(panel); break;
case 4: BuildStepStats(panel); break;
case 5: BuildStepSkills(panel); break;
case 6: BuildStepReview(panel); break;
}
return panel;
}
private Widget BuildAside()
{
var col = new VerticalStackPanel
{
Spacing = 6,
Width = 320,
Padding = new Thickness(12, 10, 12, 10),
Background = new SolidBrush(new Color(8, 8, 16, 230)),
HorizontalAlignment = HorizontalAlignment.Right,
};
col.Widgets.Add(new Label { Text = "— THE SUBJECT —", HorizontalAlignment = HorizontalAlignment.Center });
col.Widgets.Add(new Label { Text = " " });
col.Widgets.Add(new Label { Text = "Name" });
col.Widgets.Add(new Label
{
Text = string.IsNullOrWhiteSpace(_name) ? " (unnamed)" : " " + _name,
});
col.Widgets.Add(new Label { Text = " " });
col.Widgets.Add(new Label { Text = "Lineage" });
col.Widgets.Add(new Label
{
Text = $" {_species?.Name ?? ""} ({_clade?.Name ?? ""} · {CodexCopy.SizeLabel(_species?.Size ?? "")})",
});
col.Widgets.Add(new Label { Text = " " });
col.Widgets.Add(new Label { Text = "Calling & History" });
col.Widgets.Add(new Label
{
Text = $" {_class?.Name ?? ""} (d{_class?.HitDie ?? 0})\n {_background?.Name ?? ""}",
});
col.Widgets.Add(new Label { Text = " " });
col.Widgets.Add(new Label { Text = "Abilities" });
col.Widgets.Add(new Label { Text = FormatAbilityStrip() });
col.Widgets.Add(new Label { Text = " " });
int totalSkills = _chosenSkills.Count + (_background?.SkillProficiencies.Length ?? 0);
col.Widgets.Add(new Label { Text = $"Skills · {totalSkills}" });
col.Widgets.Add(new Label { Text = " " + FormatSkillSummary() });
if (!string.IsNullOrEmpty(_detailTitle))
{
col.Widgets.Add(new Label { Text = " " });
col.Widgets.Add(new Label { Text = "— Selected —" });
col.Widgets.Add(new Label { Text = " " + _detailTitle });
col.Widgets.Add(new Label { Text = WordWrap(_detailBody, 38) });
}
return col;
}
private Widget BuildNav()
{
var row = new HorizontalStackPanel
{
Spacing = 16,
HorizontalAlignment = HorizontalAlignment.Center,
};
var back = new TextButton { Text = "← Back", Width = 120, Enabled = _step > 0 };
back.Click += (_, _) => { _step--; Rebuild(); };
row.Widgets.Add(back);
var stepError = ValidateStep(_step);
bool allValid = AllStepsValid();
string status = stepError is not null
? "✘ " + stepError
: (_step < 6 ? "✓ Folio complete" : (allValid ? "✓ Ready to sign" : "✘ Some folios remain"));
row.Widgets.Add(new Label { Text = $" {status} " });
if (_step < StepNames.Length - 1)
{
var next = new TextButton { Text = "Next ", Width = 120, Enabled = stepError is null };
next.Click += (_, _) => { _step++; Rebuild(); };
row.Widgets.Add(next);
}
else
{
var confirm = new TextButton { Text = "Confirm & Begin", Width = 200, Enabled = allValid };
confirm.Click += (_, _) => OnConfirm();
row.Widgets.Add(confirm);
}
return row;
}
// ── Step builders ────────────────────────────────────────────────────
private void BuildStepClade(VerticalStackPanel page)
{
page.Widgets.Add(new Label { Text = "Folio I — Of Bloodlines" });
page.Widgets.Add(new Label { Text = "Choose your Clade. The body you were born to — the broad shape of your gait,\nthe fall of your shadow, the words your scent carries before you speak." });
page.Widgets.Add(new Label { Text = " " });
// Group predator / prey for visual scan.
page.Widgets.Add(new Label { Text = "── Predators ──" });
AddCladeRow(page, _clades.Where(c => c.Kind == "predator"));
page.Widgets.Add(new Label { Text = " " });
page.Widgets.Add(new Label { Text = "── Prey ──" });
AddCladeRow(page, _clades.Where(c => c.Kind == "prey"));
}
private void AddCladeRow(VerticalStackPanel page, System.Collections.Generic.IEnumerable<CladeDef> clades)
{
var row = new HorizontalStackPanel { Spacing = 6 };
foreach (var c in clades)
{
string mods = string.Join(" ", c.AbilityMods.Select(kv => $"{kv.Key} {CodexCopy.Signed(kv.Value)}"));
string langs = string.Join(", ", c.Languages.Select(CodexCopy.LanguageName));
string label = (_clade == c ? "→ " : " ") + c.Name + "\n " + mods + "\n langs: " + langs;
var btn = new TextButton { Text = label, Width = 220, Padding = new Thickness(6, 4, 6, 4) };
var clade = c;
btn.Click += (_, _) =>
{
_clade = clade;
if (_species is null || _species.CladeId != clade.Id)
_species = _allSpecies.FirstOrDefault(s => s.CladeId == clade.Id);
if (clade.Traits.Length > 0) ShowDetail(clade.Traits[0].Name, clade.Traits[0].Description);
Rebuild();
};
row.Widgets.Add(btn);
}
page.Widgets.Add(row);
}
private void BuildStepSpecies(VerticalStackPanel page)
{
page.Widgets.Add(new Label { Text = $"Folio II — Of Lineage within {_clade?.Name ?? ""}" });
page.Widgets.Add(new Label { Text = "Choose your Species. The species refines what the clade began —\ndifferent statures, ranges, and inheritances." });
page.Widgets.Add(new Label { Text = " " });
var filtered = _allSpecies.Where(s => _clade is null || s.CladeId == _clade.Id).ToArray();
// Render in rows of 3.
for (int i = 0; i < filtered.Length; i += 3)
{
var row = new HorizontalStackPanel { Spacing = 6 };
for (int j = i; j < System.Math.Min(filtered.Length, i + 3); j++)
{
var s = filtered[j];
string mods = string.Join(" ", s.AbilityMods.Select(kv => $"{kv.Key} {CodexCopy.Signed(kv.Value)}"));
string traitNames = string.Join(", ", s.Traits.Take(2).Select(t => t.Name));
string label = (_species == s ? "→ " : " ") + s.Name + "\n " +
$"{CodexCopy.SizeLabel(s.Size)} · {s.BaseSpeedFt} ft\n " +
(string.IsNullOrEmpty(mods) ? "(no mods)" : mods) + "\n " +
(string.IsNullOrEmpty(traitNames) ? "" : traitNames);
var btn = new TextButton { Text = label, Width = 230, Padding = new Thickness(6, 4, 6, 4) };
var sp = s;
btn.Click += (_, _) =>
{
_species = sp;
if (sp.Traits.Length > 0) ShowDetail(sp.Traits[0].Name, sp.Traits[0].Description);
Rebuild();
};
row.Widgets.Add(btn);
}
page.Widgets.Add(row);
}
}
private void BuildStepClass(VerticalStackPanel page)
{
page.Widgets.Add(new Label { Text = "Folio III — Of Vocations" });
page.Widgets.Add(new Label { Text = "Choose your Calling. Each shapes how you fight, treat, parley, or unmake the world.\n★ Suits Clade marks callings recommended for your chosen clade." });
page.Widgets.Add(new Label { Text = " " });
for (int i = 0; i < _classes.Length; i += 2)
{
var row = new HorizontalStackPanel { Spacing = 6 };
for (int j = i; j < System.Math.Min(_classes.Length, i + 2); j++)
{
var c = _classes[j];
bool suits = _clade is not null && CodexCopy.IsSuited(c.Id, _clade.Id);
string suitTag = suits ? " ★" : "";
var lvl1 = System.Array.Find(c.LevelTable, e => e.Level == 1);
string features = lvl1 is null ? "" : string.Join(", ",
lvl1.Features.Where(f => f != "asi" && f != "subclass_select" && f != "subclass_feature")
.Select(f => c.FeatureDefinitions.TryGetValue(f, out var fd) ? fd.Name : f));
string label = (_class == c ? "→ " : " ") + c.Name + suitTag + "\n " +
$"d{c.HitDie} · {string.Join("/", c.PrimaryAbility)} · saves {string.Join("/", c.Saves)}\n " +
$"Picks {c.SkillsChoose} skill(s)\n " + features;
var btn = new TextButton { Text = label, Width = 350, Padding = new Thickness(6, 4, 6, 4) };
var cls = c;
btn.Click += (_, _) =>
{
_class = cls;
AutoPickSkills();
if (lvl1 is not null && lvl1.Features.Length > 0)
{
var firstReal = lvl1.Features.FirstOrDefault(f => f != "asi" && f != "subclass_select" && f != "subclass_feature");
if (firstReal is not null && cls.FeatureDefinitions.TryGetValue(firstReal, out var fd))
ShowDetail(fd.Name, fd.Description);
}
Rebuild();
};
row.Widgets.Add(btn);
}
page.Widgets.Add(row);
}
}
private void BuildStepBackground(VerticalStackPanel page)
{
page.Widgets.Add(new Label { Text = "Folio IV — Of Histories" });
page.Widgets.Add(new Label { Text = "Choose your Background. The clade gives you body, the calling gives you craft;\nbackground gives you a past — debts, contacts, scars, the way you sleep." });
page.Widgets.Add(new Label { Text = " " });
for (int i = 0; i < _backgrounds.Length; i += 2)
{
var row = new HorizontalStackPanel { Spacing = 6 };
for (int j = i; j < System.Math.Min(_backgrounds.Length, i + 2); j++)
{
var b = _backgrounds[j];
string skills = string.Join(", ", b.SkillProficiencies.Select(CodexCopy.SkillName));
string label = (_background == b ? "→ " : " ") + b.Name + "\n " +
b.FeatureName + "\n Skills: " + skills;
var btn = new TextButton { Text = label, Width = 350, Padding = new Thickness(6, 4, 6, 4) };
var bg = b;
btn.Click += (_, _) =>
{
_background = bg;
ShowDetail(bg.FeatureName, bg.FeatureDescription);
Rebuild();
};
row.Widgets.Add(btn);
}
page.Widgets.Add(row);
}
}
private void BuildStepStats(VerticalStackPanel page)
{
page.Widgets.Add(new Label { Text = "Folio V — Of Aptitudes" });
page.Widgets.Add(new Label { Text = "Set your Abilities. Click a value in the pool to select it,\nthen click an ability to assign. Click a filled slot to return its value to the pool." });
page.Widgets.Add(new Label { Text = " " });
// Method tabs
var tabs = new HorizontalStackPanel { Spacing = 8 };
var arrayBtn = new TextButton { Text = (!_useRoll ? "→ " : " ") + "Standard Array", Width = 220 };
arrayBtn.Click += (_, _) => { _useRoll = false; InitStandardArrayPool(); Rebuild(); };
tabs.Widgets.Add(arrayBtn);
var rollBtn = new TextButton { Text = (_useRoll ? "→ " : " ") + "Roll 4d6 — drop lowest", Width = 260 };
rollBtn.Click += (_, _) => { _useRoll = true; RollAndPool(); Rebuild(); };
tabs.Widgets.Add(rollBtn);
page.Widgets.Add(tabs);
page.Widgets.Add(new Label { Text = " " });
// Pool
page.Widgets.Add(new Label { Text = "Pool (click to select):" });
var poolRow = new HorizontalStackPanel { Spacing = 6 };
if (_statPool.Count == 0)
poolRow.Widgets.Add(new Label { Text = " (all values assigned — click a slot to return its value)" });
for (int i = 0; i < _statPool.Count; i++)
{
int idx = i;
int v = _statPool[i];
bool selected = _pendingPoolIdx == idx;
var dieBtn = new TextButton
{
Text = (selected ? "[" + v + "]" : " " + v + " "),
Padding = new Thickness(8, 4, 8, 4),
};
dieBtn.Click += (_, _) => { _pendingPoolIdx = (selected ? null : (int?)idx); Rebuild(); };
poolRow.Widgets.Add(dieBtn);
}
// Inline action buttons
if (_useRoll)
{
var reroll = new TextButton { Text = "Reroll", Padding = new Thickness(6, 4, 6, 4) };
reroll.Click += (_, _) => { RollAndPool(); Rebuild(); };
poolRow.Widgets.Add(reroll);
}
var auto = new TextButton { Text = "Auto-assign", Padding = new Thickness(6, 4, 6, 4), Enabled = _statPool.Count > 0 };
auto.Click += (_, _) => { AutoAssignByClassPriority(); Rebuild(); };
poolRow.Widgets.Add(auto);
var clear = new TextButton { Text = "Clear", Padding = new Thickness(6, 4, 6, 4), Enabled = _statAssign.Count > 0 };
clear.Click += (_, _) => { ClearAssignments(); Rebuild(); };
poolRow.Widgets.Add(clear);
page.Widgets.Add(poolRow);
page.Widgets.Add(new Label { Text = " " });
// Roll history (last 3 prior rolls)
if (_useRoll && _statHistory.Count > 1)
{
var prev = _statHistory.Take(_statHistory.Count - 1).TakeLast(3);
string hist = string.Join(" ", prev.Select(h => "[" + string.Join(", ", h) + "]"));
page.Widgets.Add(new Label { Text = "Previous rolls: " + hist });
page.Widgets.Add(new Label { Text = " " });
}
// Ability rows
foreach (var ab in CodexCopy.AbilityOrder)
{
int? assigned = _statAssign.TryGetValue(ab, out var v) ? v : null;
int cladeMod = ModFromDict(_clade?.AbilityMods, ab);
int speciesMod = ModFromDict(_species?.AbilityMods, ab);
int totalBonus = cladeMod + speciesMod;
int finalScore = (assigned ?? 0) + totalBonus;
int finalMod = AbilityScores.Mod(finalScore);
bool isPrimary = _class?.PrimaryAbility.Contains(ab.ToString()) == true;
string primaryTag = isPrimary ? " *" : "";
string bonusTag = totalBonus == 0 ? "" : $" ({CodexCopy.Signed(totalBonus)} from clade+species)";
string slotText = assigned is null ? " [ — ] " : $" [ {assigned} ] ";
string fullText = $"{ab}{primaryTag} {CodexCopy.AbilityLabels[ab]}\n" +
$"{slotText}{bonusTag}\n" +
(assigned is null ? "" : $" Final: {finalScore} ({CodexCopy.Signed(finalMod)})");
var rowBtn = new TextButton { Text = fullText, Width = 660, Padding = new Thickness(6, 4, 6, 4) };
var ability = ab;
var assignedSnap = assigned;
rowBtn.Click += (_, _) =>
{
if (assignedSnap is null && _pendingPoolIdx is int pidx)
{
int val = _statPool[pidx];
_statPool.RemoveAt(pidx);
_statAssign[ability] = val;
_pendingPoolIdx = null;
}
else if (assignedSnap is int v2)
{
_statPool.Add(v2);
_statAssign.Remove(ability);
}
Rebuild();
};
page.Widgets.Add(rowBtn);
}
}
private void BuildStepSkills(VerticalStackPanel page)
{
page.Widgets.Add(new Label { Text = "Folio VI — Of Trained Hands" });
page.Widgets.Add(new Label
{
Text = $"Choose your Skills. Background grants {_background?.SkillProficiencies.Length ?? 0} sealed; class lets you pick {_class?.SkillsChoose ?? 0} more.",
});
page.Widgets.Add(new Label
{
Text = $"Chosen: {_chosenSkills.Count} / {_class?.SkillsChoose ?? 0} · Sealed by background: {_background?.SkillProficiencies.Length ?? 0}",
});
page.Widgets.Add(new Label { Text = " " });
var bgLocked = new HashSet<string>(_background?.SkillProficiencies ?? System.Array.Empty<string>(), System.StringComparer.OrdinalIgnoreCase);
var classOpts = new HashSet<string>(_class?.SkillOptions ?? System.Array.Empty<string>(), System.StringComparer.OrdinalIgnoreCase);
// Group by ability
var grouped = new Dictionary<AbilityId, List<string>>();
foreach (var ab in CodexCopy.AbilityOrder) grouped[ab] = new List<string>();
foreach (var skillId in AllSkillIds())
{
var ab = CodexCopy.SkillAbility(skillId);
grouped[ab].Add(skillId);
}
foreach (var ab in CodexCopy.AbilityOrder)
{
page.Widgets.Add(new Label { Text = $"── {CodexCopy.AbilityLabels[ab]} ({ab}) ──" });
foreach (var skillId in grouped[ab])
{
bool fromBg = bgLocked.Contains(skillId);
bool fromClass = classOpts.Contains(skillId);
bool checkedNow;
try { checkedNow = _chosenSkills.Contains(SkillIdExtensions.FromJson(skillId)); }
catch { checkedNow = false; }
string mark = fromBg ? "[BG]" : (checkedNow ? "[✓]" : (fromClass ? "[ ]" : "[—]"));
string label = $" {mark} {CodexCopy.SkillName(skillId)}" +
(fromBg ? " (sealed by background)" : (fromClass ? "" : " (not offered by class)"));
var btn = new TextButton
{
Text = label,
Width = 600,
Padding = new Thickness(4, 2, 4, 2),
Enabled = fromClass && !fromBg,
};
if (fromClass && !fromBg)
{
var sid = skillId;
btn.Click += (_, _) =>
{
SkillId enumId;
try { enumId = SkillIdExtensions.FromJson(sid); }
catch { return; }
if (_chosenSkills.Contains(enumId))
{
_chosenSkills.Remove(enumId);
}
else if (_chosenSkills.Count < (_class?.SkillsChoose ?? 0))
{
_chosenSkills.Add(enumId);
}
ShowDetail(CodexCopy.SkillName(sid), CodexCopy.SkillDescription(sid));
Rebuild();
};
}
page.Widgets.Add(btn);
}
}
}
private void BuildStepReview(VerticalStackPanel page)
{
page.Widgets.Add(new Label { Text = "Folio VII — Of Names & Witness" });
page.Widgets.Add(new Label { Text = "Sign the Codex. The name you sign here is the one the world will speak." });
page.Widgets.Add(new Label { Text = " " });
// Name input
var nameRow = new HorizontalStackPanel { Spacing = 8 };
nameRow.Widgets.Add(new Label { Text = "Name:", VerticalAlignment = VerticalAlignment.Center });
var nameInput = new TextBox { Text = _name, Width = 360 };
nameInput.TextChanged += (_, _) => _name = nameInput.Text ?? "";
nameRow.Widgets.Add(nameInput);
page.Widgets.Add(nameRow);
page.Widgets.Add(new Label { Text = " " });
// Lineage block
page.Widgets.Add(new Label { Text = "── Lineage ──" });
page.Widgets.Add(new Label
{
Text = $" {_clade?.Name ?? ""} / {_species?.Name ?? ""} ({CodexCopy.SizeLabel(_species?.Size ?? "")})",
});
page.Widgets.Add(MakeEditLink("Edit ", 0));
// Calling+History
page.Widgets.Add(new Label { Text = " " });
page.Widgets.Add(new Label { Text = "── Calling & History ──" });
page.Widgets.Add(new Label
{
Text = $" {_class?.Name ?? ""} (d{_class?.HitDie ?? 0} · {string.Join("/", _class?.PrimaryAbility ?? new string[0])})\n Background: {_background?.Name ?? ""}",
});
page.Widgets.Add(MakeEditLink("Edit Calling ", 2));
// Final abilities
page.Widgets.Add(new Label { Text = " " });
page.Widgets.Add(new Label { Text = "── Final Abilities ──" });
page.Widgets.Add(new Label { Text = " " + FormatAbilityStrip() });
page.Widgets.Add(MakeEditLink("Edit Abilities ", 4));
// Skills
page.Widgets.Add(new Label { Text = " " });
page.Widgets.Add(new Label { Text = "── Skills ──" });
page.Widgets.Add(new Label { Text = " " + FormatSkillSummary() });
page.Widgets.Add(MakeEditLink("Edit Skills ", 5));
// Starting kit
page.Widgets.Add(new Label { Text = " " });
page.Widgets.Add(new Label { Text = "── Starting Kit ──" });
if (_class?.StartingKit is null || _class.StartingKit.Length == 0)
page.Widgets.Add(new Label { Text = " (no kit configured)" });
else
{
foreach (var entry in _class.StartingKit)
{
string equipTag = entry.AutoEquip ? $" [equipped: {entry.EquipSlot}]" : "";
string qtyTag = entry.Qty > 1 ? $" ×{entry.Qty}" : "";
page.Widgets.Add(new Label { Text = $" • {CodexCopy.ItemName(entry.ItemId)}{qtyTag}{equipTag}" });
}
}
}
private TextButton MakeEditLink(string text, int targetStep)
{
var btn = new TextButton { Text = text, Padding = new Thickness(6, 2, 6, 2) };
btn.Click += (_, _) => { _step = targetStep; Rebuild(); };
return btn;
}
// ── State helpers ────────────────────────────────────────────────────
private void InitStandardArrayPool()
{
_statPool.Clear();
foreach (int v in AbilityScores.StandardArray) _statPool.Add(v);
_statAssign.Clear();
_pendingPoolIdx = null;
}
private void RollAndPool()
{
ulong msNow = (ulong)(System.Environment.TickCount64 - _gameStartMs);
var rng = SeededRng.ForSubsystem(_seed, C.RNG_STAT_ROLL ^ msNow);
var vals = new int[6];
for (int i = 0; i < 6; i++) vals[i] = CharacterBuilder.Roll4d6DropLowest(rng);
_statHistory.Add(vals);
_statPool.Clear();
foreach (var v in vals) _statPool.Add(v);
_statAssign.Clear();
_pendingPoolIdx = null;
}
private void AutoAssignByClassPriority()
{
var primary = _class?.PrimaryAbility ?? System.Array.Empty<string>();
var order = new List<string>();
foreach (var p in primary) order.Add(p.ToUpperInvariant());
foreach (var a in new[] { "CON", "DEX", "STR", "WIS", "INT", "CHA" })
if (!order.Contains(a)) order.Add(a);
var available = _statPool.OrderByDescending(x => x).ToList();
// Honour any already-pinned abilities; fill the rest from the pool.
var emptyAbilities = new List<AbilityId>();
foreach (var s in order)
{
if (TryParseAbility(s, out var ab) && !_statAssign.ContainsKey(ab))
emptyAbilities.Add(ab);
}
for (int i = 0; i < emptyAbilities.Count && i < available.Count; i++)
{
_statAssign[emptyAbilities[i]] = available[i];
}
// Rebuild the pool from leftovers.
_statPool.Clear();
for (int i = emptyAbilities.Count; i < available.Count; i++) _statPool.Add(available[i]);
_pendingPoolIdx = null;
}
private void ClearAssignments()
{
foreach (var v in _statAssign.Values) _statPool.Add(v);
_statAssign.Clear();
_pendingPoolIdx = null;
}
private void AutoPickSkills()
{
_chosenSkills.Clear();
if (_class is null) return;
int n = _class.SkillsChoose;
foreach (var raw in _class.SkillOptions)
{
if (_chosenSkills.Count >= n) break;
try { _chosenSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { /* unknown */ }
}
}
private void ShowDetail(string title, string body)
{
_detailTitle = title ?? "";
_detailBody = body ?? "";
}
// ── Validation ───────────────────────────────────────────────────────
private string? ValidateStep(int i)
{
if (i == 0) return _clade is null ? "Pick a clade." : null;
if (i == 1) return _species is null ? "Pick a species." : null;
if (i == 2) return _class is null ? "Pick a calling." : null;
if (i == 3) return _background is null ? "Pick a background." : null;
if (i == 4) return _statAssign.Count == 6 ? null : $"Assign all six abilities ({_statAssign.Count}/6).";
if (i == 5)
{
int need = _class?.SkillsChoose ?? 0;
return _chosenSkills.Count == need ? null : $"Pick exactly {need} skill(s) ({_chosenSkills.Count}/{need}).";
}
if (i == 6) return string.IsNullOrWhiteSpace(_name) ? "Enter a name." : null;
return null;
}
private bool AllStepsValid()
{
for (int i = 0; i < StepNames.Length; i++) if (ValidateStep(i) is not null) return false;
return true;
}
// ── Confirm ──────────────────────────────────────────────────────────
private void OnConfirm()
{
if (!AllStepsValid()) return;
var b = new CharacterBuilder
{
Clade = _clade,
Species = _species,
ClassDef = _class,
Background = _background,
BaseAbilities = new AbilityScores(
_statAssign.GetValueOrDefault(AbilityId.STR),
_statAssign.GetValueOrDefault(AbilityId.DEX),
_statAssign.GetValueOrDefault(AbilityId.CON),
_statAssign.GetValueOrDefault(AbilityId.INT),
_statAssign.GetValueOrDefault(AbilityId.WIS),
_statAssign.GetValueOrDefault(AbilityId.CHA)),
Name = _name,
};
foreach (var s in _chosenSkills) b.ChooseSkill(s);
var character = b.Build(_content.Items);
_game.Screens.Pop();
_game.Screens.Push(new WorldGenProgressScreen(_seed, pendingCharacter: character, pendingName: _name));
}
// ── Formatters ───────────────────────────────────────────────────────
private string FormatAbilityStrip()
{
var parts = new List<string>();
foreach (var ab in CodexCopy.AbilityOrder)
{
int? assigned = _statAssign.TryGetValue(ab, out var v) ? v : null;
int cladeMod = ModFromDict(_clade?.AbilityMods, ab);
int speciesMod = ModFromDict(_species?.AbilityMods, ab);
if (assigned is null) { parts.Add($"{ab} —"); continue; }
int finalScore = assigned.Value + cladeMod + speciesMod;
parts.Add($"{ab} {finalScore}({CodexCopy.Signed(AbilityScores.Mod(finalScore))})");
}
return string.Join(" ", parts);
}
private string FormatSkillSummary()
{
var skills = new List<string>();
if (_background is not null)
foreach (var s in _background.SkillProficiencies) skills.Add(CodexCopy.SkillName(s) + "*");
foreach (var s in _chosenSkills.OrderBy(x => x.ToString())) skills.Add(s.ToString());
if (skills.Count == 0) return "(none yet)";
return string.Join(", ", skills) + " (* = sealed by background)";
}
private static int ModFromDict(System.Collections.Generic.IReadOnlyDictionary<string, int>? d, AbilityId ab)
{
if (d is null) return 0;
return d.TryGetValue(ab.ToString(), out var v) ? v : 0;
}
private static bool TryParseAbility(string raw, out AbilityId id)
{
switch (raw.ToUpperInvariant())
{
case "STR": id = AbilityId.STR; return true;
case "DEX": id = AbilityId.DEX; return true;
case "CON": id = AbilityId.CON; return true;
case "INT": id = AbilityId.INT; return true;
case "WIS": id = AbilityId.WIS; return true;
case "CHA": id = AbilityId.CHA; return true;
default: id = AbilityId.STR; return false;
}
}
private static System.Collections.Generic.IEnumerable<string> AllSkillIds() => new[]
{
"athletics", "acrobatics", "sleight_of_hand", "stealth",
"arcana", "history", "investigation", "nature", "religion",
"animal_handling", "insight", "medicine", "perception", "survival",
"deception", "intimidation", "performance", "persuasion",
};
/// <summary>Soft word-wrap for the detail-panel body. Splits on spaces; crude but adequate.</summary>
private static string WordWrap(string text, int maxCols)
{
if (string.IsNullOrEmpty(text)) return "";
var sb = new System.Text.StringBuilder();
int col = 0;
foreach (var word in text.Split(' '))
{
if (col + word.Length + 1 > maxCols) { sb.Append('\n'); sb.Append(" "); col = 2; }
else if (col > 2) { sb.Append(' '); col++; }
else if (col == 0) { sb.Append(" "); col = 2; }
sb.Append(word);
col += word.Length;
}
return sb.ToString();
}
// ── Lifecycle ────────────────────────────────────────────────────────
public void Update(GameTime gt)
{
if (Keyboard.GetState().IsKeyDown(Keys.Escape)) _game.Screens.Pop();
}
public void Draw(GameTime gt, SpriteBatch sb)
{
_game.GraphicsDevice.Clear(new Color(15, 15, 25));
_desktop.Render();
}
public void Deactivate() { }
public void Reactivate() { }
}