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

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

596 lines
24 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Theriapolis.Game.CodexUI.Core;
using Theriapolis.Game.CodexUI.Drag;
using Theriapolis.Game.CodexUI.Steps;
using Theriapolis.Game.CodexUI.Widgets;
using Theriapolis.Game.Screens;
using Theriapolis.Game.UI;
namespace Theriapolis.Game.CodexUI.Screens;
/// <summary>
/// Custom-rendered character-creation wizard. State model is a verbatim port
/// of <see cref="CharacterCreationScreen"/> (the Myra version); only the
/// rendering swaps to CodexUI primitives. Layout: codex header, 7-step
/// stepper, two-column body (page main + aside summary), nav bar at the
/// bottom. Each step is a separate file under <c>Steps/</c> that builds
/// its own widget subtree against this screen's mutable state.
/// </summary>
public sealed class CodexCharacterCreationScreen : CodexScreen
{
public ulong Seed { get; }
// Loaded content
public ContentResolver Content { get; private set; } = null!;
public CladeDef[] Clades { get; private set; } = null!;
public SpeciesDef[] AllSpecies { get; private set; } = null!;
public ClassDef[] Classes { get; private set; } = null!;
public BackgroundDef[] Backgrounds { get; private set; } = null!;
// Wizard state
public int Step;
public CladeDef? Clade;
public SpeciesDef? Species;
public ClassDef? Class;
public BackgroundDef? Background;
public string Name = "Wanderer";
// Stat assignment state
public bool UseRoll;
public readonly System.Collections.Generic.List<int> StatPool = new();
public readonly System.Collections.Generic.Dictionary<AbilityId, int> StatAssign = new();
public readonly System.Collections.Generic.List<int[]> StatHistory = new();
public int? PendingPoolIdx;
// Skill state
public readonly System.Collections.Generic.HashSet<SkillId> ChosenSkills = new();
// Scroll position for the body's scroll panel. Survives any
// InvalidateLayout-triggered rebuild within the same step (so
// selecting a clade or dropping an ability die doesn't bounce the
// page back to the top); reset to zero on step change.
private int _bodyScrollOffset;
// Same idea for the right-column aside summary, which can grow taller
// than the viewport once enough trait/feature/skill chips appear.
// Persisted across rebuilds; not reset on step change because the
// aside content keeps growing as more folios are completed.
private int _asideScrollOffset;
/// <summary>
/// Popover layer for hover trigger widgets in the right-column aside.
/// Exposed so <see cref="CodexAside"/> can attach hover triggers to
/// the same parchment-and-gilt popover the page-main cards use.
/// </summary>
public CodexHoverPopover AsidePopover => Popover ?? throw new System.InvalidOperationException("AsidePopover accessed before BuildRoot.");
// Stat-roll seeding (Phase 5 plan §4.2)
private readonly long _gameStartMs;
private long _msAtScreenOpen;
public static readonly string[] StepNames = new[]
{
"Clade", "Species", "Calling", "History", "Abilities", "Skills", "Sign",
};
public CodexCharacterCreationScreen(ulong seed)
{
Seed = seed;
_gameStartMs = System.Environment.TickCount64;
}
public override void Initialize(Game1 game)
{
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();
_msAtScreenOpen = System.Environment.TickCount64 - _gameStartMs;
// No pre-filled clade / species / class / background. Defaults
// would let the player jump straight to the review step without
// ever interacting with the earlier folios; explicit selection
// gates each step via ValidateStep + the stepper's lock logic.
// Stat pool is initialised so the abilities folio has values to
// drag from once the player reaches it.
InitStandardArrayPool();
DragDrop.OnDrop += HandleDrop;
DragDrop.OnDropAnywhere += HandleDropAnywhere;
DragDrop.OnCancel += _ => { };
base.Initialize(game);
}
// ── Layout ───────────────────────────────────────────────────────────
protected override CodexWidget BuildRoot()
{
Popover ??= new CodexHoverPopover(Atlas);
Popover.UpdateViewport(Game.GraphicsDevice.Viewport.Bounds);
// Header
var headerRow = new Row { Spacing = 16, VAlignChildren = VAlign.Bottom, Padding = new Thickness(36, 22, 36, 18) };
headerRow.Add(new CodexLabel("THERIAPOLIS — Codex of Becoming", CodexFonts.DisplayLarge, CodexColors.Ink));
headerRow.Add(new CodexLabel($"FOLIO {Romanize(Step + 1)} OF VII · SEED 0x{Seed:X}",
CodexFonts.MonoTagSmall, CodexColors.InkMute));
// Stepper — locked steps are anything beyond the first incomplete folio.
// We deliberately ignore the player's current `Step` here: the lock
// depends only on whether earlier folios are valid. Pre-filled defaults
// would make every step pass validation without interaction; removing
// them in Initialize is what makes this gate actually gate.
var stepper = new CodexStepper(StepNames, Atlas) { Current = Step };
int firstIncomplete = -1;
for (int j = 0; j < StepNames.Length; j++)
if (ValidateStep(j) is not null) { firstIncomplete = j; break; }
for (int i = 0; i < StepNames.Length; i++)
{
stepper.Complete[i] = ValidateStep(i) is null && i != Step;
stepper.Locked[i] = firstIncomplete != -1 && firstIncomplete < i;
}
stepper.OnPick = NavigateTo;
// Body — two-column layout, both columns are independently
// scrollable. The body offsets are restored from saved state so
// an interaction that triggers InvalidateLayout (selecting a
// clade, dropping a die into an ability slot) doesn't bounce
// the user to the top of either column.
//
// Bottom padding is zero on purpose: the ScrollPanel's mouse clip
// matches its own bounds, so any column-padding gap below the
// panel becomes a region where chips can render visibly (no
// scissor) but reject hover (cursor outside the clip). With the
// panel reaching all the way down to the body's bottom edge —
// which sits exactly atop the nav bar's hairline rule — chips
// that scroll past it disappear under the nav bar's opaque mask
// instead of leaking into a padding strip.
var body = new TwoColumn(Atlas) { LeftPad = new Thickness(36, 28, 36, 0), RightPad = new Thickness(28, 28, 28, 0) };
var leftScroll = new ScrollPanel(Atlas, BuildCurrentStep());
leftScroll.SetInitialScroll(_bodyScrollOffset);
leftScroll.OnScrollChanged = o => _bodyScrollOffset = o;
body.Left = leftScroll;
var rightScroll = new ScrollPanel(Atlas, new CodexAside(this, Atlas).Build());
rightScroll.SetInitialScroll(_asideScrollOffset);
rightScroll.OnScrollChanged = o => _asideScrollOffset = o;
body.Right = rightScroll;
// Nav bar
var navBar = BuildNavBar();
// Wrap everything in a custom root that gives the body whatever
// height is left after header + stepper + nav. Without this the
// body's measured height was its full content height (often >2×
// the viewport), so cards in row 2/3 sat below the visible window.
return new CodexRootLayout(Atlas, headerRow, stepper, body, navBar);
}
private CodexWidget BuildCurrentStep() => Step switch
{
0 => StepClade.Build(this, Atlas, Popover!),
1 => StepSpecies.Build(this, Atlas, Popover!),
2 => StepClass.Build(this, Atlas, Popover!),
3 => StepBackground.Build(this, Atlas, Popover!),
4 => StepStats.Build(this, Atlas, Popover!, DragDrop),
5 => StepSkills.Build(this, Atlas, Popover!),
6 => StepReview.Build(this, Atlas, Popover!),
_ => new CodexLabel("(unknown step)", CodexFonts.SerifBody),
};
private CodexWidget BuildNavBar()
{
var row = new Row
{
Spacing = 16,
Padding = new Thickness(36, 16, 36, 16),
VAlignChildren = VAlign.Middle,
};
var back = new CodexButton(" Back", Atlas, CodexButtonVariant.Ghost,
onClick: () => NavigateTo(System.Math.Max(0, Step - 1)),
fixedWidth: 120);
back.Enabled = Step > 0;
row.Add(back);
// Status label
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"));
Color statusColor = stepError is not null ? CodexColors.Seal : CodexColors.InkMute;
var statusLabel = new CodexLabel(status, CodexFonts.MonoTag, statusColor) { HAlign = HAlign.Center };
// Make the label expand by wrapping in a stretched Padding
var spacer = new Padding(statusLabel, new Thickness(60, 0, 60, 0));
row.Add(spacer);
if (Step < StepNames.Length - 1)
{
var next = new CodexButton("Next ", Atlas, CodexButtonVariant.Ghost,
onClick: () => NavigateTo(Step + 1), fixedWidth: 140);
next.Enabled = stepError is null;
row.Add(next);
}
else
{
var confirm = new CodexButton("Confirm & Begin", Atlas, CodexButtonVariant.Primary,
onClick: OnConfirm, fixedWidth: 220);
confirm.Enabled = allValid;
row.Add(confirm);
}
return row;
}
private void NavigateTo(int s)
{
Step = s;
_bodyScrollOffset = 0; // each folio starts at the top
InvalidateLayout();
}
public override void Update(GameTime gameTime)
{
base.Update(gameTime);
if (Input.KeyJustPressed(Keys.Escape)) Game.Screens.Pop();
}
// ── State helpers ────────────────────────────────────────────────────
public void InitStandardArrayPool()
{
StatPool.Clear();
foreach (int v in AbilityScores.StandardArray) StatPool.Add(v);
StatAssign.Clear();
PendingPoolIdx = null;
}
public 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;
}
public void AutoAssignByClassPriority()
{
var primary = Class?.PrimaryAbility ?? System.Array.Empty<string>();
var order = new System.Collections.Generic.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();
var emptyAbilities = new System.Collections.Generic.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];
StatPool.Clear();
for (int i = emptyAbilities.Count; i < available.Count; i++) StatPool.Add(available[i]);
PendingPoolIdx = null;
}
public void ClearAssignments()
{
foreach (var v in StatAssign.Values) StatPool.Add(v);
StatAssign.Clear();
PendingPoolIdx = null;
}
private void HandleDrop(object payload, string targetId)
{
if (payload is not StatPoolPayload p) return;
if (targetId.StartsWith("ability:"))
{
string abStr = targetId.Substring("ability:".Length);
if (!System.Enum.TryParse<AbilityId>(abStr, out var dest)) return;
if (p.Source == "pool" && p.PoolIdx is int idx && idx < StatPool.Count)
{
if (StatAssign.TryGetValue(dest, out var existing))
StatPool.Add(existing);
StatPool.RemoveAt(idx);
StatAssign[dest] = p.Value;
}
else if (p.Source == "slot" && p.Ability is AbilityId src)
{
if (src == dest) return;
int srcVal = p.Value;
if (StatAssign.TryGetValue(dest, out var destVal))
{
StatAssign[dest] = srcVal;
StatAssign[src] = destVal;
}
else
{
StatAssign[dest] = srcVal;
StatAssign.Remove(src);
}
}
InvalidateLayout();
}
else if (targetId == "pool")
{
if (p.Source == "slot" && p.Ability is AbilityId src && StatAssign.TryGetValue(src, out var v))
{
StatPool.Add(v);
StatAssign.Remove(src);
InvalidateLayout();
}
}
}
private void HandleDropAnywhere(object payload, Point screenPos)
{
// No-op — payload silently bounces back when dropped outside any registered target.
}
// ── Validation ───────────────────────────────────────────────────────
public 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;
}
public bool AllStepsValid()
{
for (int i = 0; i < StepNames.Length; i++) if (ValidateStep(i) is not null) return false;
return true;
}
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));
}
// Helpers
public int CladeMod(AbilityId ab) => ModFromDict(Clade?.AbilityMods, ab);
public int SpeciesMod(AbilityId ab) => ModFromDict(Species?.AbilityMods, ab);
public int TotalBonus(AbilityId ab) => CladeMod(ab) + SpeciesMod(ab);
public bool IsPrimary(AbilityId ab) => Class?.PrimaryAbility.Contains(ab.ToString()) == true;
public 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;
}
}
public 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",
};
public static string Romanize(int n) => CodexCopy.Romanize(n);
}
/// <summary>
/// Two-column body layout: page-main (flex) on the left, fixed-width aside
/// panel on the right. Mirrors the React design's <c>.page</c> grid. Takes
/// whatever height the parent assigns (so the page-main scroll panel
/// scrolls within a viewport-bounded region) rather than expanding to its
/// content's natural height.
/// </summary>
internal sealed class TwoColumn : CodexWidget
{
public CodexWidget? Left;
public CodexWidget? Right;
public Thickness LeftPad;
public Thickness RightPad;
private readonly CodexAtlas _atlas;
public TwoColumn(CodexAtlas atlas) { _atlas = atlas; }
protected override Point MeasureCore(Point available)
{
int rightW = CodexDensity.AsideWidth;
int leftW = available.X - rightW;
Left?.Measure(new Point(leftW - LeftPad.HorizontalSum(), available.Y - LeftPad.VerticalSum()));
Right?.Measure(new Point(rightW - RightPad.HorizontalSum(), available.Y - RightPad.VerticalSum()));
return new Point(available.X, available.Y);
}
protected override void ArrangeCore(Rectangle bounds)
{
int rightW = CodexDensity.AsideWidth;
int leftW = bounds.Width - rightW;
Left?.Arrange(new Rectangle(
bounds.X + LeftPad.Left,
bounds.Y + LeftPad.Top,
leftW - LeftPad.HorizontalSum(),
bounds.Height - LeftPad.VerticalSum()));
Right?.Arrange(new Rectangle(
bounds.X + leftW + RightPad.Left,
bounds.Y + RightPad.Top,
rightW - RightPad.HorizontalSum(),
bounds.Height - RightPad.VerticalSum()));
}
public override void Update(GameTime gt, CodexInput input)
{
Left?.Update(gt, input);
Right?.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
// Body fill — paint the lighter parchment Bg behind both columns
// so cards (Bg2) sit on a flat surface with clear contrast,
// independent of the screen's BgDeep clear.
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg);
// Vertical rule between left and right
int rightW = CodexDensity.AsideWidth;
int leftW = Bounds.Width - rightW;
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X + leftW, Bounds.Y, 1, Bounds.Height), CodexColors.Rule);
Left?.Draw(sb, gt);
Right?.Draw(sb, gt);
}
}
internal sealed class RuleLine : CodexWidget
{
private readonly CodexAtlas _atlas;
public RuleLine(CodexAtlas atlas) { _atlas = atlas; }
protected override Point MeasureCore(Point available) => new(available.X, 1);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt) => sb.Draw(_atlas.Pixel, Bounds, CodexColors.Rule);
}
/// <summary>
/// Wizard-shaped root layout: header at the top, stepper under it, body
/// fills the middle, nav bar at the bottom. Header / stepper / nav take
/// their measured size; body gets whatever height is left over so it
/// always fits the viewport (instead of overflowing when its content is
/// taller than the window). The stepper and nav bar paint opaque
/// parchment backgrounds that mask any scroll-panel overflow above /
/// below the body's clipped region — cheaper than configuring a scissor-
/// enabled rasterizer and re-Begin-ing the SpriteBatch.
/// </summary>
internal sealed class CodexRootLayout : CodexWidget
{
private readonly CodexAtlas _atlas;
public CodexWidget Header;
public CodexWidget Stepper;
public CodexWidget Body;
public CodexWidget NavBar;
public CodexRootLayout(CodexAtlas atlas, CodexWidget header, CodexWidget stepper, CodexWidget body, CodexWidget navBar)
{
_atlas = atlas;
Header = header;
Stepper = stepper;
Body = body;
NavBar = navBar;
Header.Parent = this; Stepper.Parent = this; Body.Parent = this; NavBar.Parent = this;
}
protected override Point MeasureCore(Point available)
{
var hs = Header.Measure(available);
var ss = Stepper.Measure(available);
var ns = NavBar.Measure(available);
int bodyH = System.Math.Max(0, available.Y - hs.Y - ss.Y - ns.Y - 2); // 2 = top/bottom rules
Body.Measure(new Point(available.X, bodyH));
return new Point(available.X, available.Y);
}
protected override void ArrangeCore(Rectangle bounds)
{
int y = bounds.Y;
Header.Arrange(new Rectangle(bounds.X, y, bounds.Width, Header.DesiredSize.Y));
y += Header.DesiredSize.Y;
// Header bottom rule
y += 1;
Stepper.Arrange(new Rectangle(bounds.X, y, bounds.Width, Stepper.DesiredSize.Y));
y += Stepper.DesiredSize.Y;
int navY = bounds.Bottom - NavBar.DesiredSize.Y;
Body.Arrange(new Rectangle(bounds.X, y, bounds.Width, navY - y - 1));
// Nav top rule on (navY - 1).
NavBar.Arrange(new Rectangle(bounds.X, navY, bounds.Width, NavBar.DesiredSize.Y));
}
public override void Update(GameTime gt, CodexInput input)
{
// Clip the body's mouse hit-testing to its own rectangle so cards
// scrolled under the stepper / nav bar don't receive clicks that
// visually land on the chrome above or below them. Without this,
// clicking a stepper bullet would also fire the OnClick of any
// card whose bounds (at their scroll-offset position) happen to
// intersect the cursor — even though the card is masked from view.
input.SetMouseClip(Body.Bounds);
Body.Update(gt, input);
input.ClearMouseClip();
Header.Update(gt, input);
Stepper.Update(gt, input);
NavBar.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
// Body draws first; chrome paints over any scroll overflow.
Body.Draw(sb, gt);
// Header: opaque parchment band + bottom hairline rule + text.
var headerRect = new Rectangle(Header.Bounds.X, Header.Bounds.Y, Header.Bounds.Width, Header.Bounds.Height);
sb.Draw(_atlas.Pixel, headerRect, CodexColors.Bg);
sb.Draw(_atlas.Pixel, new Rectangle(headerRect.X, headerRect.Bottom, headerRect.Width, 1), CodexColors.Rule);
Header.Draw(sb, gt);
// Stepper paints its own opaque parchment background.
Stepper.Draw(sb, gt);
// Nav bar: opaque parchment band + top hairline rule + buttons.
sb.Draw(_atlas.Pixel, new Rectangle(NavBar.Bounds.X, NavBar.Bounds.Y - 1, NavBar.Bounds.Width, 1), CodexColors.Rule);
sb.Draw(_atlas.Pixel, NavBar.Bounds, CodexColors.Bg);
NavBar.Draw(sb, gt);
}
}