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,250 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
using Theriapolis.Game.CodexUI.Screens;
using Theriapolis.Game.CodexUI.Widgets;
using Theriapolis.Game.UI;
namespace Theriapolis.Game.CodexUI.Steps;
/// <summary>
/// Step VII — Sign. Name input + summary panels for lineage / calling /
/// abilities / skills / starting kit. Each panel has an Edit link that
/// jumps the wizard back to the source step.
/// </summary>
public static class StepReview
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro(
"Folio VII — Of Names & Witness",
"Sign the Codex",
"Review your character. The name you sign here is the one the world will speak."));
// Name input
var nameBlock = new Column { Spacing = 4 };
nameBlock.Add(new CodexLabel("NAME", CodexFonts.MonoTag, CodexColors.InkMute));
var nameInput = new CodexTextBox(s.Name, atlas, fixedWidth: 480, onChanged: t => s.Name = t)
{
Placeholder = "Wanderer",
};
nameBlock.Add(nameInput);
col.Add(nameBlock);
// Lineage / Calling pair
var pair = new Grid { Columns = 2 };
pair.Add(BuildBlock(atlas, "Lineage", () => s.Step = 0, s.InvalidateLayout, BuildLineage(s)));
pair.Add(BuildBlock(atlas, "Calling & History", () => s.Step = 2, s.InvalidateLayout, BuildCalling(s)));
col.Add(pair);
// Final abilities
col.Add(BuildBlock(atlas, "Final Abilities", () => s.Step = 4, s.InvalidateLayout, new StatStrip(s, atlas)));
// Skills — HoverableChip so the player can re-read each skill's
// codex flavour text without bouncing back to the Skills folio.
var skillsBlock = new WrapRow();
if (s.Background is not null)
foreach (var sk in s.Background.SkillProficiencies)
skillsBlock.Add(new HoverableChip(atlas, popover,
CodexCopy.SkillName(sk),
CodexCopy.SkillName(sk),
CodexCopy.SkillDescription(sk),
"BACKGROUND",
ChipKind.SkillFromBg));
foreach (var sk in s.ChosenSkills.OrderBy(x => x.ToString()))
{
string id = CodexCopy.SkillIdToJson(sk);
skillsBlock.Add(new HoverableChip(atlas, popover,
CodexCopy.SkillName(id),
CodexCopy.SkillName(id),
CodexCopy.SkillDescription(id),
"CLASS",
ChipKind.SkillFromClass));
}
col.Add(BuildBlock(atlas, "Skills", () => s.Step = 5, s.InvalidateLayout, skillsBlock));
// Starting kit — each chip resolves the item def for description /
// properties / damage etc. and shows them on hover.
var kitBlock = new WrapRow();
if (s.Class?.StartingKit is not null)
foreach (var entry in s.Class.StartingKit)
kitBlock.Add(new KitItemWidget(atlas, popover, entry, s.Content));
col.Add(BuildBlock(atlas, "Starting Kit", () => s.Step = 2, s.InvalidateLayout, kitBlock));
return col;
}
private static CodexWidget BuildLineage(CodexCharacterCreationScreen s)
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel(s.Clade?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
col.Add(new CodexLabel(
(s.Species?.Name ?? "—") + (s.Species is not null ? $" · {CodexCopy.SizeLabel(s.Species.Size).ToUpperInvariant()}" : ""),
CodexFonts.SerifBody, CodexColors.InkSoft));
return col;
}
private static CodexWidget BuildCalling(CodexCharacterCreationScreen s)
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel(s.Class?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
col.Add(new CodexLabel(
(s.Class is not null ? $"D{s.Class.HitDie} · {string.Join("/", s.Class.PrimaryAbility)}" : ""),
CodexFonts.MonoTagSmall, CodexColors.InkMute));
col.Add(new CodexLabel(s.Background?.Name ?? "—", CodexFonts.SerifItalic, CodexColors.InkSoft));
return col;
}
private static CodexWidget BuildBlock(CodexAtlas atlas, string title, System.Action onEdit, System.Action onAfterEdit, CodexWidget body)
{
var inner = new Column { Spacing = 8 };
var head = new Row { Spacing = 8, VAlignChildren = VAlign.Middle };
head.Add(new CodexLabel(title, CodexFonts.DisplaySmall, CodexColors.Ink));
var spacerL = new HSpacer();
head.Add(spacerL);
head.Add(new EditLink(atlas, () => { onEdit(); onAfterEdit(); }));
inner.Add(head);
inner.Add(body);
return new CodexPanel(atlas, inner);
}
}
internal sealed class EditLink : CodexWidget
{
private readonly CodexAtlas _atlas;
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.MonoTagSmall;
private readonly System.Action _onClick;
private bool _hovered;
private const string Text = "EDIT ";
public EditLink(CodexAtlas atlas, System.Action onClick) { _atlas = atlas; _onClick = onClick; }
protected override Point MeasureCore(Point available)
{
var s = _font.MeasureString(Text);
return new Point((int)s.X + 6, (int)System.MathF.Ceiling(_font.LineHeight) + 4);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
_hovered = ContainsPoint(input.MousePosition);
if (_hovered && input.LeftJustReleased) _onClick();
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
var s = _font.MeasureString(Text);
var color = _hovered ? CodexColors.GildBright : CodexColors.Gild;
_font.DrawText(sb, Text, new Vector2(Bounds.X, Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f), color);
}
}
internal sealed class HSpacer : CodexWidget
{
protected override Point MeasureCore(Point available) => new(System.Math.Max(0, available.X - 80), 1);
protected override void ArrangeCore(Rectangle bounds) { }
}
internal sealed class KitItemWidget : CodexWidget
{
private readonly CodexAtlas _atlas;
private readonly CodexHoverPopover _popover;
private readonly Theriapolis.Core.Data.StartingKitItem _entry;
private readonly Theriapolis.Core.Data.ItemDef? _itemDef;
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.SerifBody;
private readonly FontStashSharp.SpriteFontBase _qtyFont = CodexFonts.MonoTagSmall;
private bool _hovered;
public KitItemWidget(CodexAtlas atlas, CodexHoverPopover popover,
Theriapolis.Core.Data.StartingKitItem entry,
Theriapolis.Core.Data.ContentResolver content)
{
_atlas = atlas;
_popover = popover;
_entry = entry;
// Item id may not resolve (forward-compat starting kits, missing
// entries) — gracefully fall back to a name-only popover.
content.Items.TryGetValue(entry.ItemId, out _itemDef);
}
protected override Point MeasureCore(Point available) => new(160, 48);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
_hovered = ContainsPoint(input.MousePosition);
if (_hovered) ShowPopover();
}
private void ShowPopover()
{
string title = CodexCopy.ItemName(_entry.ItemId);
string body = ComposePopoverBody();
string? tag = _itemDef?.Kind?.ToUpperInvariant();
_popover.Show(Bounds, title, body, tag);
}
/// <summary>Compose a multi-line description from the item's stats and
/// the auto-equip slot. Falls back to the codex flavor description
/// when no kind-specific stats are available.</summary>
private string ComposePopoverBody()
{
var sb = new System.Text.StringBuilder();
if (_entry.Qty > 1) sb.Append($"Quantity: ×{_entry.Qty}\n");
if (_entry.AutoEquip) sb.Append($"Equipped to: {_entry.EquipSlot.Replace('_', ' ')}\n");
if (_itemDef is not null)
{
switch (_itemDef.Kind)
{
case "weapon":
sb.Append($"Damage: {_itemDef.Damage}");
if (!string.IsNullOrEmpty(_itemDef.DamageType)) sb.Append(' ').Append(_itemDef.DamageType);
if (!string.IsNullOrEmpty(_itemDef.DamageVersatile))
sb.Append($" ({_itemDef.DamageVersatile} two-handed)");
sb.Append('\n');
break;
case "armor":
sb.Append($"AC {_itemDef.AcBase}");
if (_itemDef.AcMaxDex >= 0) sb.Append($" + DEX (max +{_itemDef.AcMaxDex})");
if (_itemDef.MinStr > 0) sb.Append($" · Min STR {_itemDef.MinStr}");
sb.Append('\n');
break;
case "shield":
sb.Append($"+{_itemDef.AcBase} AC\n");
break;
case "consumable":
if (!string.IsNullOrEmpty(_itemDef.Healing)) sb.Append($"Heals {_itemDef.Healing}\n");
break;
}
if (_itemDef.Properties.Length > 0)
sb.Append("Properties: ").Append(string.Join(", ", _itemDef.Properties)).Append('\n');
if (!string.IsNullOrEmpty(_itemDef.Description))
sb.Append(_itemDef.Description);
}
if (sb.Length == 0) sb.Append("(no description)");
return sb.ToString().TrimEnd('\n');
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
Color border = _hovered ? CodexColors.Gild
: _entry.AutoEquip ? CodexColors.Gild
: CodexColors.Rule;
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), border);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), border);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, 1, Bounds.Height), border);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.Right - 1, Bounds.Y, 1, Bounds.Height), border);
_font.DrawText(sb, CodexCopy.ItemName(_entry.ItemId),
new Vector2(Bounds.X + 8, Bounds.Y + 6), CodexColors.Ink);
_qtyFont.DrawText(sb, "×" + _entry.Qty,
new Vector2(Bounds.X + 8, Bounds.Y + 22), CodexColors.InkMute);
if (_entry.AutoEquip)
_qtyFont.DrawText(sb, _entry.EquipSlot.ToUpperInvariant(),
new Vector2(Bounds.X + 8, Bounds.Bottom - _qtyFont.LineHeight - 4),
CodexColors.Gild);
}
}