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,50 @@
using Theriapolis.Core.Data;
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 IV — Background. Two-column grid of background cards: name + flavor
/// paragraph + named feature chip + sealed-skill chips.
/// </summary>
public static class StepBackground
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro(
"Folio IV — Of Histories",
"Choose your Background",
"Where the clade gives you body and the calling gives you craft, the background gives you a past — debts, contacts, scars, the way you sleep."));
var grid = new Grid { Columns = 2 };
foreach (var b in s.Backgrounds) grid.Add(BuildCard(s, atlas, popover, b));
col.Add(grid);
return col;
}
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, BackgroundDef b)
{
var content = new Column { Spacing = 8 };
content.Add(new CodexLabel(b.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
if (!string.IsNullOrEmpty(b.Flavor))
content.Add(new CodexLabel(b.Flavor, CodexFonts.SerifItalic, CodexColors.InkSoft));
content.Add(new CodexLabel("FEATURE", CodexFonts.MonoTagSmall, CodexColors.InkMute));
var featRow = new WrapRow();
featRow.Add(new HoverableChip(atlas, popover, b.FeatureName, b.FeatureName, b.FeatureDescription, "FEATURE", ChipKind.BgFeature));
content.Add(featRow);
content.Add(new CodexLabel("SKILLS", CodexFonts.MonoTagSmall, CodexColors.InkMute));
var skills = new WrapRow();
foreach (var sk in b.SkillProficiencies)
skills.Add(new HoverableChip(atlas, popover, CodexCopy.SkillName(sk), CodexCopy.SkillName(sk), CodexCopy.SkillDescription(sk), "BACKGROUND", ChipKind.SkillFromBg));
content.Add(skills);
return new CodexCard(atlas, content, s.Background == b,
onClick: () => { s.Background = b; s.InvalidateLayout(); });
}
}
+211
View File
@@ -0,0 +1,211 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core.Data;
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 I — Clade. Two grouped grids (Predators / Prey) of clade cards,
/// each card showing the clade name + kind + ability mods + language chips
/// + trait chips. Selection swaps the species default to the first species
/// belonging to that clade.
/// </summary>
public static class StepClade
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro("Folio I — Of Bloodlines", "Choose your Clade",
"The seven great families of Theriapolis. Your clade is the body you were born to — the broad shape of your gait, the fall of your shadow, the words your scent carries before you speak."));
col.Add(new CodexLabel("PREDATORS", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(BuildGrid(s, atlas, popover, "predator"));
col.Add(new CodexLabel("PREY", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(BuildGrid(s, atlas, popover, "prey"));
return col;
}
private static CodexWidget BuildGrid(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, string kind)
{
var grid = new Grid { Columns = 3 };
foreach (var c in s.Clades)
{
if (c.Kind != kind) continue;
grid.Add(BuildCard(s, atlas, popover, c));
}
return grid;
}
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, CladeDef c)
{
var content = new Column { Spacing = 8 };
// Header: sigil + name/kind
var headerRow = new Row { Spacing = 12, VAlignChildren = VAlign.Top };
headerRow.Add(new SigilWidget(atlas, c.Id));
var titleCol = new Column { Spacing = 2 };
titleCol.Add(new CodexLabel(c.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
titleCol.Add(new CodexLabel(c.Kind.ToUpperInvariant(), CodexFonts.MonoTagSmall, CodexColors.InkMute));
headerRow.Add(titleCol);
content.Add(headerRow);
// Mods row
if (c.AbilityMods.Count > 0)
{
var mods = new WrapRow();
foreach (var kv in c.AbilityMods)
mods.Add(new ModChipMini(atlas, kv.Key, kv.Value));
content.Add(mods);
}
// Languages
content.Add(new CodexLabel("LANGUAGES", CodexFonts.MonoTagSmall, CodexColors.InkMute));
var langs = new WrapRow();
foreach (var l in c.Languages)
langs.Add(new HoverableChip(atlas, popover, CodexCopy.LanguageName(l), CodexCopy.LanguageName(l), CodexCopy.LanguageDescription(l), null, ChipKind.Language));
content.Add(langs);
// Traits
content.Add(new CodexLabel("TRAITS", CodexFonts.MonoTagSmall, CodexColors.InkMute));
var traits = new WrapRow();
foreach (var t in c.Traits)
traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
foreach (var t in c.Detriments)
traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
content.Add(traits);
bool isSelected = s.Clade == c;
var card = new CodexCard(atlas, content, isSelected,
onClick: () =>
{
s.Clade = c;
// If the previously-picked species belongs to a different
// clade, drop it — but never auto-pick a new species. The
// user must visit the Species folio explicitly so the
// Calling step stays locked behind that decision.
if (s.Species is not null && s.Species.CladeId != c.Id)
s.Species = null;
s.InvalidateLayout();
});
card.CornerSigil = atlas.SigilFor(c.Id);
return card;
}
}
/// <summary>Renders the clade sigil placeholder + a centred initial letter.</summary>
internal sealed class SigilWidget : CodexWidget
{
private readonly CodexAtlas _atlas;
private readonly string _cladeId;
public SigilWidget(CodexAtlas atlas, string cladeId) { _atlas = atlas; _cladeId = cladeId; }
protected override Point MeasureCore(Point available) => new(56, 56);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt)
{
sb.Draw(_atlas.SigilFor(_cladeId), Bounds, Color.White);
// Letter overlay so the placeholder is identifiable.
char ch = char.ToUpper(_cladeId.Length > 0 ? _cladeId[0] : '?');
var font = CodexFonts.DisplayMedium;
var s = font.MeasureString(ch.ToString());
font.DrawText(sb, ch.ToString(),
new Vector2(Bounds.X + (Bounds.Width - s.X) / 2f, Bounds.Y + (Bounds.Height - font.LineHeight) / 2f),
CodexColors.Ink);
}
}
/// <summary>Inline mod pill drawn as `STR +1` / `DEX -1` etc.</summary>
internal sealed class ModChipMini : CodexWidget
{
private readonly CodexAtlas _atlas;
private readonly string _label;
private readonly bool _positive;
private readonly SpriteFontBase _font = CodexFonts.MonoTagSmall;
public ModChipMini(CodexAtlas atlas, string ab, int v)
{
_atlas = atlas;
_label = $"{ab} {(v >= 0 ? "+" : "")}{v}";
_positive = v >= 0;
}
protected override Point MeasureCore(Point available)
{
var s = _font.MeasureString(_label);
return new Point((int)s.X + 14, (int)System.MathF.Ceiling(_font.LineHeight) + 6);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt)
{
var border = _positive ? CodexColors.Seal : CodexColors.InkMute;
var fill = CodexColors.Bg;
sb.Draw(_atlas.Pixel, Bounds, fill);
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);
var s = _font.MeasureString(_label);
_font.DrawText(sb, _label,
new Vector2(Bounds.X + (Bounds.Width - s.X) / 2f, Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f),
_positive ? CodexColors.Seal : CodexColors.InkSoft);
}
}
/// <summary>
/// Chip variant that drives the screen's popover when hovered. Acts as a
/// thin wrapper around <see cref="CodexChip"/> plus a popover-show side
/// effect during update.
/// </summary>
internal sealed class HoverableChip : CodexWidget
{
private readonly CodexChip _chip;
private readonly CodexHoverPopover _popover;
private readonly string _title, _body;
private readonly string? _tag;
private readonly bool _detriment;
public HoverableChip(CodexAtlas atlas, CodexHoverPopover popover, string text,
string popTitle, string popBody, string? popTag, ChipKind kind)
{
_chip = new CodexChip(text, kind, atlas, popTitle, popBody, popTag);
_popover = popover;
_title = popTitle;
_body = popBody;
_tag = popTag;
_detriment = kind == ChipKind.TraitDetriment;
}
public System.Action? OnClick { get => _chip.OnClick; set => _chip.OnClick = value; }
protected override Point MeasureCore(Point available) => _chip.Measure(available);
protected override void ArrangeCore(Rectangle bounds) => _chip.Arrange(bounds);
public override void Update(GameTime gt, CodexInput input)
{
_chip.Update(gt, input);
if (_chip.IsHovered && !string.IsNullOrEmpty(_body))
_popover.Show(_chip.Bounds, _title, _body, _tag, _detriment);
}
public override void Draw(SpriteBatch sb, GameTime gt) => _chip.Draw(sb, gt);
}
/// <summary>Reusable page-intro block: small mono eyebrow, large display title, body paragraph.</summary>
public static class StepCommon
{
public static CodexWidget PageIntro(string eyebrow, string title, string body)
{
var col = new Column { Spacing = 6 };
col.Add(new CodexLabel(eyebrow.ToUpperInvariant(), CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(new CodexLabel(title, CodexFonts.DisplayLarge, CodexColors.Ink));
col.Add(new CodexLabel(body, CodexFonts.SerifBody, CodexColors.InkSoft));
return new Padding(col, new Thickness(0, 0, 0, 14));
}
}
+117
View File
@@ -0,0 +1,117 @@
using Microsoft.Xna.Framework;
using Theriapolis.Core.Data;
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 III — Calling. Two-column grid of class cards. Cards recommended
/// for the chosen clade get a small "★ Suits Clade" badge. Level-1
/// features render as inline trait chips with hover popovers.
/// </summary>
public static class StepClass
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro(
"Folio III — Of Vocations",
"Choose your Calling",
"Eight callings exist within the Covenant. Each shapes how you fight, treat, parley, or unmake the world."));
var grid = new Grid { Columns = 2 };
foreach (var c in s.Classes) grid.Add(BuildCard(s, atlas, popover, c));
col.Add(grid);
return col;
}
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, ClassDef c)
{
var content = new Column { Spacing = 8 };
var titleRow = new Row { Spacing = 8, VAlignChildren = VAlign.Middle };
titleRow.Add(new CodexLabel(c.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
bool suits = s.Clade is not null && CodexCopy.IsSuited(c.Id, s.Clade.Id);
if (suits) titleRow.Add(new RecBadge(atlas));
content.Add(titleRow);
content.Add(new CodexLabel(
$"D{c.HitDie} · PRIMARY {string.Join("/", c.PrimaryAbility)} · SAVES {string.Join("/", c.Saves)}",
CodexFonts.MonoTagSmall, CodexColors.InkMute));
// Level-1 features as trait chips
var lvl1 = System.Array.Find(c.LevelTable, e => e.Level == 1);
if (lvl1 is not null)
{
var chips = new WrapRow();
foreach (var k in lvl1.Features)
{
if (k == "asi" || k == "subclass_select" || k == "subclass_feature") continue;
if (!c.FeatureDefinitions.TryGetValue(k, out var fd)) continue;
chips.Add(new HoverableChip(atlas, popover, fd.Name, fd.Name, fd.Description, fd.Kind?.ToUpperInvariant(), ChipKind.Trait));
}
content.Add(chips);
}
content.Add(new CodexLabel(
$"PICKS {c.SkillsChoose} SKILL{(c.SkillsChoose > 1 ? "S" : "")} · ARMOR: {string.Join(", ", c.ArmorProficiencies)}",
CodexFonts.MonoTagSmall, CodexColors.InkMute));
return new CodexCard(atlas, content, s.Class == c,
onClick: () =>
{
// If switching class, drop any previously-picked skills
// that aren't on the new class's option list — but never
// auto-pick. The Sign step must stay locked until the
// user explicitly visits the Skills folio.
if (s.Class != c)
{
s.Class = c;
var allowed = new System.Collections.Generic.HashSet<string>(c.SkillOptions, System.StringComparer.OrdinalIgnoreCase);
var bgLocked = new System.Collections.Generic.HashSet<string>(
s.Background?.SkillProficiencies ?? System.Array.Empty<string>(),
System.StringComparer.OrdinalIgnoreCase);
s.ChosenSkills.RemoveWhere(sk =>
{
string raw = sk.ToString().ToLowerInvariant();
return !allowed.Contains(raw) || bgLocked.Contains(raw);
});
}
s.InvalidateLayout();
});
}
}
internal sealed class RecBadge : CodexWidget
{
private readonly CodexAtlas _atlas;
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.MonoTagSmall;
private const string Text = "★ SUITS CLADE";
public RecBadge(CodexAtlas atlas) { _atlas = atlas; }
protected override Point MeasureCore(Point available)
{
var s = _font.MeasureString(Text);
return new Point((int)s.X + 12, (int)System.MathF.Ceiling(_font.LineHeight) + 6);
}
protected override void ArrangeCore(Microsoft.Xna.Framework.Rectangle bounds) { }
public override void Draw(Microsoft.Xna.Framework.Graphics.SpriteBatch sb, Microsoft.Xna.Framework.GameTime gt)
{
var fill = new Microsoft.Xna.Framework.Color(CodexColors.Gild.R, CodexColors.Gild.G, CodexColors.Gild.B, (byte)20);
sb.Draw(_atlas.Pixel, Bounds, fill);
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), CodexColors.Gild);
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), CodexColors.Gild);
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.X, Bounds.Y, 1, Bounds.Height), CodexColors.Gild);
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.Right - 1, Bounds.Y, 1, Bounds.Height), CodexColors.Gild);
var s = _font.MeasureString(Text);
_font.DrawText(sb, Text,
new Microsoft.Xna.Framework.Vector2(Bounds.X + (Bounds.Width - s.X) / 2f,
Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f),
CodexColors.Gild);
}
}
@@ -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);
}
}
@@ -0,0 +1,94 @@
using Theriapolis.Core.Rules.Stats;
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 VI — Skills. Two-column grid of <see cref="SkillGroupPanel"/>, one
/// per ability. Each panel lists every skill governed by that ability with
/// its current <see cref="CheckboxState"/>. Background-sealed skills are
/// pre-checked and locked; class-pickable skills are toggleable up to the
/// class's <c>SkillsChoose</c> count.
/// </summary>
public static class StepSkills
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro(
"Folio VI — Of Trained Hands",
"Choose your Skills",
$"Your background grants {s.Background?.SkillProficiencies.Length ?? 0} skill(s) automatically (sealed). From your calling's offered list, choose {s.Class?.SkillsChoose ?? 0} more."));
// Meta line
var meta = new Row { Spacing = 16, VAlignChildren = VAlign.Middle, Padding = new Thickness(14, 12, 14, 12) };
meta.Add(new CodexLabel($"{s.ChosenSkills.Count} / {s.Class?.SkillsChoose ?? 0} CHOSEN", CodexFonts.DisplaySmall, CodexColors.Ink));
meta.Add(new CodexLabel($"+ {s.Background?.SkillProficiencies.Length ?? 0} SEALED BY BACKGROUND",
CodexFonts.MonoTagSmall, CodexColors.InkMute));
col.Add(new CodexPanel(atlas, meta));
// Skill groups by ability
var bgLocked = new System.Collections.Generic.HashSet<string>(
s.Background?.SkillProficiencies ?? System.Array.Empty<string>(),
System.StringComparer.OrdinalIgnoreCase);
var classOpts = new System.Collections.Generic.HashSet<string>(
s.Class?.SkillOptions ?? System.Array.Empty<string>(),
System.StringComparer.OrdinalIgnoreCase);
var groupedByAbility = new System.Collections.Generic.Dictionary<AbilityId, System.Collections.Generic.List<string>>();
foreach (var ab in CodexCopy.AbilityOrder) groupedByAbility[ab] = new();
foreach (var skillId in CodexCharacterCreationScreen.AllSkillIds())
groupedByAbility[CodexCopy.SkillAbility(skillId)].Add(skillId);
var grid = new Grid { Columns = 2 };
foreach (var ab in CodexCopy.AbilityOrder)
grid.Add(BuildGroup(s, atlas, popover, ab, groupedByAbility[ab], bgLocked, classOpts));
col.Add(grid);
return col;
}
private static CodexWidget BuildGroup(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover,
AbilityId ab, System.Collections.Generic.List<string> skillIds,
System.Collections.Generic.HashSet<string> bgLocked,
System.Collections.Generic.HashSet<string> classOpts)
{
var inner = new Column { Spacing = 4 };
inner.Add(new CodexLabel(CodexCopy.AbilityLabels[ab].ToUpperInvariant(),
CodexFonts.DisplaySmall, CodexColors.Ink));
foreach (var skillId in skillIds)
{
bool fromBg = bgLocked.Contains(skillId);
bool fromClass = classOpts.Contains(skillId);
bool checkedNow;
try { checkedNow = s.ChosenSkills.Contains(SkillIdExtensions.FromJson(skillId)); }
catch { checkedNow = false; }
var state = fromBg ? CheckboxState.LockedFromBg
: checkedNow ? CheckboxState.Checked
: !fromClass ? CheckboxState.Unavailable
: CheckboxState.Default;
string sourceTag = fromBg ? "BACKGROUND" : (fromClass ? "CLASS" : "—");
var row = new CodexCheckboxRow(CodexCopy.SkillName(skillId), sourceTag, state, atlas);
string sid = skillId;
row.OnClick = () =>
{
SkillId enumId;
try { enumId = SkillIdExtensions.FromJson(sid); } catch { return; }
if (s.ChosenSkills.Contains(enumId)) s.ChosenSkills.Remove(enumId);
else if (s.ChosenSkills.Count < (s.Class?.SkillsChoose ?? 0)) s.ChosenSkills.Add(enumId);
s.InvalidateLayout();
};
row.OnHover = () =>
popover.Show(row.Bounds,
CodexCopy.SkillName(sid),
CodexCopy.SkillDescription(sid),
CodexCopy.SkillAbility(sid).ToString());
inner.Add(row);
}
return new CodexPanel(atlas, inner) { Inset = new Thickness(14, 12, 16, 12) };
}
}
@@ -0,0 +1,55 @@
using Theriapolis.Core.Data;
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 II — Species. Card grid filtered to the selected clade. Each card
/// shows name + size + base speed + ability mods + traits.
/// </summary>
public static class StepSpecies
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro(
$"Folio II — Of Lineage within {s.Clade?.Name ?? ""}",
"Choose your Species",
"Within every clade are kindreds — different statures, ranges, and inheritances. The species refines what the clade began."));
var grid = new Grid { Columns = 3 };
foreach (var sp in s.AllSpecies.Where(x => s.Clade is null || x.CladeId == s.Clade.Id))
grid.Add(BuildCard(s, atlas, popover, sp));
col.Add(grid);
return col;
}
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, SpeciesDef sp)
{
var content = new Column { Spacing = 8 };
content.Add(new CodexLabel(sp.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
content.Add(new CodexLabel($"{CodexCopy.SizeLabel(sp.Size).ToUpperInvariant()} · {sp.BaseSpeedFt} FT.",
CodexFonts.MonoTagSmall, CodexColors.InkMute));
if (sp.AbilityMods.Count > 0)
{
var mods = new WrapRow();
foreach (var kv in sp.AbilityMods) mods.Add(new ModChipMini(atlas, kv.Key, kv.Value));
content.Add(mods);
}
if (sp.Traits.Length > 0 || sp.Detriments.Length > 0)
{
var traits = new WrapRow();
foreach (var t in sp.Traits) traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
foreach (var t in sp.Detriments) traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
content.Add(traits);
}
return new CodexCard(atlas, content, s.Species == sp,
onClick: () => { s.Species = sp; s.InvalidateLayout(); });
}
}
+269
View File
@@ -0,0 +1,269 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Game.CodexUI.Core;
using Theriapolis.Game.CodexUI.Drag;
using Theriapolis.Game.CodexUI.Widgets;
using Theriapolis.Game.CodexUI.Screens;
using Theriapolis.Game.UI;
namespace Theriapolis.Game.CodexUI.Steps;
/// <summary>
/// Step V — Abilities. Method tabs at the top, dashed-bordered pool of
/// draggable value tiles below, six ability rows (drop targets) below
/// that. The right side of the pool row hosts the inline action buttons:
/// Reroll (roll mode only), Auto-assign, Clear.
/// </summary>
public static class StepStats
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, DragDropController drag)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro(
"Folio V — Of Aptitudes",
"Set your Abilities",
"Six numbers describe what your body and mind can do. Drag values from the pool onto the abilities — or click a value, then click an ability."));
// Method tabs
var tabs = new Row { Spacing = 0 };
tabs.Add(new MethodTab("Standard Array", !s.UseRoll, atlas,
() => { s.UseRoll = false; s.InitStandardArrayPool(); s.InvalidateLayout(); }));
tabs.Add(new MethodTab("Roll 4d6 — drop lowest", s.UseRoll, atlas,
() => { s.UseRoll = true; s.RollAndPool(); s.InvalidateLayout(); }));
col.Add(tabs);
// Pool row (with action buttons)
col.Add(new PoolBox(s, atlas, drag));
// Roll history
if (s.UseRoll && s.StatHistory.Count > 1)
{
string hist = string.Join(" ", s.StatHistory.Take(s.StatHistory.Count - 1).TakeLast(3).Select(h => "[" + string.Join(", ", h) + "]"));
col.Add(new CodexLabel("Previous rolls: " + hist, CodexFonts.MonoTagSmall, CodexColors.InkMute));
}
// Six ability rows
foreach (var ab in CodexCopy.AbilityOrder)
{
var row = new CodexAbilityRow(ab, atlas, drag)
{
Assigned = s.StatAssign.TryGetValue(ab, out var v) ? v : (int?)null,
Bonus = s.TotalBonus(ab),
LongName = CodexCopy.AbilityLabels[ab],
IsPrimary = s.IsPrimary(ab),
BonusSourceText = ComposeBonusSourceText(s, ab),
};
row.OnSlotClick = () =>
{
if (row.Assigned is int existing)
{
s.StatPool.Add(existing);
s.StatAssign.Remove(ab);
s.PendingPoolIdx = null;
s.InvalidateLayout();
}
else if (s.PendingPoolIdx is int pidx && pidx < s.StatPool.Count)
{
s.StatAssign[ab] = s.StatPool[pidx];
s.StatPool.RemoveAt(pidx);
s.PendingPoolIdx = null;
s.InvalidateLayout();
}
};
row.OnDragStart = payload => drag.BeginDrag(payload, (sb, p) => DrawDieGhost(sb, atlas, p, payload.Value));
col.Add(row);
}
return col;
}
private static string ComposeBonusSourceText(CodexCharacterCreationScreen s, AbilityId ab)
{
var parts = new System.Collections.Generic.List<string>();
int cm = s.CladeMod(ab);
int sm = s.SpeciesMod(ab);
if (cm != 0) parts.Add($"{s.Clade?.Name ?? "Clade"} {(cm >= 0 ? "+" : "")}{cm}");
if (sm != 0) parts.Add($"{s.Species?.Name ?? "Species"} {(sm >= 0 ? "+" : "")}{sm}");
return string.Join(" · ", parts);
}
private static void DrawDieGhost(SpriteBatch sb, CodexAtlas atlas, Point cursor, int value)
{
var rect = new Rectangle(cursor.X - 28, cursor.Y - 28, 56, 56);
var border = CodexColors.Gild;
sb.Draw(atlas.Pixel, rect, new Color(CodexColors.Bg.R, CodexColors.Bg.G, CodexColors.Bg.B, (byte)200));
sb.Draw(atlas.Pixel, new Rectangle(rect.X, rect.Y, rect.Width, 1), border);
sb.Draw(atlas.Pixel, new Rectangle(rect.X, rect.Bottom - 1, rect.Width, 1), border);
sb.Draw(atlas.Pixel, new Rectangle(rect.X, rect.Y, 1, rect.Height), border);
sb.Draw(atlas.Pixel, new Rectangle(rect.Right - 1, rect.Y, 1, rect.Height), border);
var font = CodexFonts.DisplayMedium;
string label = value.ToString();
var sz = font.MeasureString(label);
font.DrawText(sb, label, new Vector2(rect.X + (rect.Width - sz.X) / 2f, rect.Y + (rect.Height - font.LineHeight) / 2f), CodexColors.Ink);
}
}
internal sealed class MethodTab : CodexWidget
{
private readonly string _label;
private readonly bool _active;
private readonly System.Action _onClick;
private readonly CodexAtlas _atlas;
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.DisplaySmall;
private bool _hovered;
public MethodTab(string label, bool active, CodexAtlas atlas, System.Action onClick)
{
_label = label;
_active = active;
_atlas = atlas;
_onClick = onClick;
}
protected override Point MeasureCore(Point available)
{
var s = _font.MeasureString(_label);
return new Point((int)s.X + 36, (int)System.MathF.Ceiling(_font.LineHeight) + 20);
}
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)
{
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), CodexColors.Rule);
if (_active)
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 2, Bounds.Width, 2), CodexColors.Gild);
var color = _active ? CodexColors.Ink : (_hovered ? CodexColors.Gild : CodexColors.InkMute);
var s = _font.MeasureString(_label);
_font.DrawText(sb, _label, new Vector2(Bounds.X + (Bounds.Width - s.X) / 2f,
Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f - 2),
color);
}
}
/// <summary>
/// Pool widget that holds the draggable value tiles + the inline action
/// buttons. Acts as a drop target so values dragged out of a slot can land
/// back in the pool.
/// </summary>
internal sealed class PoolBox : CodexWidget
{
private readonly CodexCharacterCreationScreen _s;
private readonly CodexAtlas _atlas;
private readonly DragDropController _drag;
private readonly System.Collections.Generic.List<CodexPoolDie> _dice = new();
private readonly System.Collections.Generic.List<CodexButton> _actions = new();
public PoolBox(CodexCharacterCreationScreen s, CodexAtlas atlas, DragDropController drag)
{
_s = s;
_atlas = atlas;
_drag = drag;
}
private void Rebuild()
{
_dice.Clear();
for (int i = 0; i < _s.StatPool.Count; i++)
{
int idx = i;
int v = _s.StatPool[i];
var die = new CodexPoolDie(v, idx, _atlas, _drag)
{
IsSelected = _s.PendingPoolIdx == idx,
};
die.OnDragStart = _ => { /* drag begin handled inside the die */ };
die.OnClick = () => { _s.PendingPoolIdx = (_s.PendingPoolIdx == idx ? null : (int?)idx); _s.InvalidateLayout(); };
_dice.Add(die);
}
_actions.Clear();
if (_s.UseRoll)
_actions.Add(new CodexButton("Reroll", _atlas, CodexButtonVariant.Small,
onClick: () => { _s.RollAndPool(); _s.InvalidateLayout(); }));
var auto = new CodexButton("Auto-assign", _atlas, CodexButtonVariant.Small,
onClick: () => { _s.AutoAssignByClassPriority(); _s.InvalidateLayout(); });
auto.Enabled = _s.StatPool.Count > 0;
_actions.Add(auto);
var clear = new CodexButton("Clear", _atlas, CodexButtonVariant.Small,
onClick: () => { _s.ClearAssignments(); _s.InvalidateLayout(); });
clear.Enabled = _s.StatAssign.Count > 0;
_actions.Add(clear);
}
protected override Point MeasureCore(Point available)
{
Rebuild();
return new Point(available.X, 90);
}
protected override void ArrangeCore(Rectangle bounds)
{
// Pool dice on the left, actions on the right.
int x = bounds.X + 14;
int y = bounds.Y + (bounds.Height - 56) / 2;
foreach (var d in _dice)
{
var s = d.Measure(new Point(56, 56));
d.Arrange(new Rectangle(x, y, s.X, s.Y));
x += s.X + CodexDensity.ColGap;
}
// Right-aligned action stack.
int rightX = bounds.X + bounds.Width - 14;
for (int i = _actions.Count - 1; i >= 0; i--)
{
var a = _actions[i];
var s = a.Measure(new Point(160, 32));
rightX -= s.X;
a.Arrange(new Rectangle(rightX, y + (56 - s.Y) / 2, s.X, s.Y));
rightX -= 8;
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var d in _dice) d.Update(gt, input);
foreach (var a in _actions) a.Update(gt, input);
if (_drag.IsDragging) _drag.RegisterTarget("pool", Bounds);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
bool over = _drag.IsDragging && Bounds.Contains(_drag.CursorPosition);
var fill = over
? new Color(CodexColors.Seal.R, CodexColors.Seal.G, CodexColors.Seal.B, (byte)20)
: new Color(CodexColors.Gild.R, CodexColors.Gild.G, CodexColors.Gild.B, (byte)8);
sb.Draw(_atlas.Pixel, Bounds, fill);
// Dashed border (4-px dashes / 3-px gaps)
var border = over ? CodexColors.Seal : CodexColors.Rule;
for (int x = Bounds.X; x < Bounds.Right; x += 7)
{
int w = System.Math.Min(4, Bounds.Right - x);
sb.Draw(_atlas.Pixel, new Rectangle(x, Bounds.Y, w, 1), border);
sb.Draw(_atlas.Pixel, new Rectangle(x, Bounds.Bottom - 1, w, 1), border);
}
for (int y = Bounds.Y; y < Bounds.Bottom; y += 7)
{
int h = System.Math.Min(4, Bounds.Bottom - y);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, y, 1, h), border);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.Right - 1, y, 1, h), border);
}
if (_dice.Count == 0)
{
var font = CodexFonts.MonoTagSmall;
string msg = "ALL VALUES ASSIGNED. DRAG FROM A SLOT TO RETURN.";
var s = font.MeasureString(msg);
font.DrawText(sb, msg,
new Vector2(Bounds.X + 14, Bounds.Y + (Bounds.Height - font.LineHeight) / 2f),
CodexColors.InkMute);
}
foreach (var d in _dice) d.Draw(sb, gt);
foreach (var a in _actions) a.Draw(sb, gt);
}
}