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; /// /// 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 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. /// 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 _statPool = new(); private readonly Dictionary _statAssign = new(); private readonly List _statHistory = new(); private int? _pendingPoolIdx; // index in _statPool of currently-selected value (click-pick-place) // Skill state private readonly HashSet _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 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(_background?.SkillProficiencies ?? System.Array.Empty(), System.StringComparer.OrdinalIgnoreCase); var classOpts = new HashSet(_class?.SkillOptions ?? System.Array.Empty(), System.StringComparer.OrdinalIgnoreCase); // Group by ability var grouped = new Dictionary>(); foreach (var ab in CodexCopy.AbilityOrder) grouped[ab] = new List(); 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(); var order = new List(); 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(); 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(); 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(); 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? 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 AllSkillIds() => new[] { "athletics", "acrobatics", "sleight_of_hand", "stealth", "arcana", "history", "investigation", "nature", "religion", "animal_handling", "insight", "medicine", "perception", "survival", "deception", "intimidation", "performance", "persuasion", }; /// Soft word-wrap for the detail-panel body. Splits on spaces; crude but adequate. 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() { } }