b451f83174
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>
887 lines
38 KiB
C#
887 lines
38 KiB
C#
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() { }
|
||
}
|