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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,595 @@
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);
}
}