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