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() { }
}