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
+336
View File
@@ -0,0 +1,336 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Theriapolis.Game.CodexUI.Core;
/// <summary>
/// Strongly-typed asset table for the codex aesthetic. Each property is a
/// texture used by one or more widgets — see §6 of the implementation plan
/// for the full list. <see cref="LoadAll"/> looks for PNGs under
/// <c>Content/Gfx/codex/</c> first; any missing slots fall back to a
/// procedural placeholder generated in code so the screen still renders
/// before art is authored.
///
/// Procedural placeholders aim to capture *structure* (9-slice shape,
/// corner/edge sizes, broad colour) so layout work can proceed against
/// them. Final art replaces them one by one.
/// </summary>
public sealed class CodexAtlas
{
public Texture2D Pixel = null!; // 1×1 white — flat-fill helper
public Texture2D ParchmentBg = null!; // 256×256 tileable
public Texture2D ParchmentCard = null!; // 96×96 9-slice (24 px corners)
public Texture2D GildFrame = null!; // 64×64 9-slice card border
public Texture2D GildFrameSelected = null!; // 64×64 9-slice — heavier gild
public Texture2D GildButtonPrimary = null!; // 96×32 9-slice
public Texture2D InkButtonGhost = null!; // 96×32 9-slice
public Texture2D WaxSeal = null!; // 64×64 sprite
public Texture2D OrnamentDiamond = null!; // 16×16 sprite
public Texture2D StepperLocked = null!;
public Texture2D StepperActive = null!;
public Texture2D StepperDone = null!;
public Texture2D ChipTrait = null!;
public Texture2D ChipSkillBg = null!;
public Texture2D ChipSkillClass = null!;
public Texture2D ChipLanguage = null!;
public Texture2D PoolDie = null!;
public Texture2D SlotEmpty = null!;
public Texture2D SlotFilled = null!;
public Texture2D BarTrack = null!;
public Texture2D BarFill = null!;
public Texture2D PopoverBg = null!;
public Texture2D CladeSigilCanidae = null!;
public Texture2D CladeSigilFelidae = null!;
public Texture2D CladeSigilMustelidae = null!;
public Texture2D CladeSigilUrsidae = null!;
public Texture2D CladeSigilCervidae = null!;
public Texture2D CladeSigilBovidae = null!;
public Texture2D CladeSigilLeporidae = null!;
public void LoadAll(GraphicsDevice gd, string contentRoot)
{
Pixel = new Texture2D(gd, 1, 1);
Pixel.SetData(new[] { Color.White });
string codexDir = System.IO.Path.Combine(contentRoot, "codex");
ParchmentBg = LoadOrFallback(gd, codexDir, "parchment_bg.png", () => MakeParchmentTile(gd, 256, 256));
ParchmentCard = LoadOrFallback(gd, codexDir, "parchment_card.png", () => MakeParchmentCard(gd, 96, 96));
GildFrame = LoadOrFallback(gd, codexDir, "gild_frame.png", () => MakeGildFrame(gd, 64, 64, selected: false));
GildFrameSelected = LoadOrFallback(gd, codexDir, "gild_frame_selected.png",() => MakeGildFrame(gd, 64, 64, selected: true));
GildButtonPrimary = LoadOrFallback(gd, codexDir, "gild_button_primary.png",() => MakeButton(gd, 96, 32, fill: CodexColors.Seal, border: CodexColors.Seal2));
InkButtonGhost = LoadOrFallback(gd, codexDir, "ink_button_ghost.png", () => MakeButton(gd, 96, 32, fill: CodexColors.Bg, border: CodexColors.Ink));
WaxSeal = LoadOrFallback(gd, codexDir, "wax_seal.png", () => MakeWaxSeal(gd, 64, 64));
OrnamentDiamond = LoadOrFallback(gd, codexDir, "ornament_diamond.png", () => MakeOrnamentDiamond(gd, 16, 16));
StepperLocked = LoadOrFallback(gd, codexDir, "stepper_bullet_locked.png", () => MakeStepperBullet(gd, 24, 24, CodexColors.InkMute, CodexColors.Bg));
StepperActive = LoadOrFallback(gd, codexDir, "stepper_bullet_active.png", () => MakeStepperBullet(gd, 24, 24, CodexColors.Gild, CodexColors.Bg));
StepperDone = LoadOrFallback(gd, codexDir, "stepper_bullet_done.png", () => MakeStepperBullet(gd, 24, 24, CodexColors.Seal, CodexColors.Bg));
ChipTrait = LoadOrFallback(gd, codexDir, "chip_trait.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Gild));
ChipSkillBg = LoadOrFallback(gd, codexDir, "chip_skill_bg.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Gild));
ChipSkillClass = LoadOrFallback(gd, codexDir, "chip_skill_class.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Seal));
ChipLanguage = LoadOrFallback(gd, codexDir, "chip_language.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Rule));
PoolDie = LoadOrFallback(gd, codexDir, "pool_die.png", () => MakeButton(gd, 56, 56, CodexColors.Bg, CodexColors.Rule));
SlotEmpty = LoadOrFallback(gd, codexDir, "slot_empty.png", () => MakeSlot(gd, 64, 44, dashed: true));
SlotFilled = LoadOrFallback(gd, codexDir, "slot_filled.png",() => MakeSlot(gd, 64, 44, dashed: false));
BarTrack = LoadOrFallback(gd, codexDir, "bar_track.png", () => MakeBar(gd, 16, 8, fill: false));
BarFill = LoadOrFallback(gd, codexDir, "bar_fill.png", () => MakeBar(gd, 16, 8, fill: true));
PopoverBg = LoadOrFallback(gd, codexDir, "popover_bg.png", () => MakePopoverBg(gd, 96, 96));
// Clade sigils — placeholder = circular badge with stylised initial
CladeSigilCanidae = LoadOrFallback(gd, codexDir, "clade_sigil_canidae.png", () => MakeSigil(gd, 48, 'C'));
CladeSigilFelidae = LoadOrFallback(gd, codexDir, "clade_sigil_felidae.png", () => MakeSigil(gd, 48, 'F'));
CladeSigilMustelidae = LoadOrFallback(gd, codexDir, "clade_sigil_mustelidae.png", () => MakeSigil(gd, 48, 'M'));
CladeSigilUrsidae = LoadOrFallback(gd, codexDir, "clade_sigil_ursidae.png", () => MakeSigil(gd, 48, 'U'));
CladeSigilCervidae = LoadOrFallback(gd, codexDir, "clade_sigil_cervidae.png", () => MakeSigil(gd, 48, 'D'));
CladeSigilBovidae = LoadOrFallback(gd, codexDir, "clade_sigil_bovidae.png", () => MakeSigil(gd, 48, 'B'));
CladeSigilLeporidae = LoadOrFallback(gd, codexDir, "clade_sigil_leporidae.png", () => MakeSigil(gd, 48, 'L'));
}
public Texture2D SigilFor(string cladeId) => cladeId switch
{
"canidae" => CladeSigilCanidae,
"felidae" => CladeSigilFelidae,
"mustelidae" => CladeSigilMustelidae,
"ursidae" => CladeSigilUrsidae,
"cervidae" => CladeSigilCervidae,
"bovidae" => CladeSigilBovidae,
"leporidae" => CladeSigilLeporidae,
_ => CladeSigilCanidae,
};
private static Texture2D LoadOrFallback(GraphicsDevice gd, string dir, string name, System.Func<Texture2D> fallback)
{
string path = System.IO.Path.Combine(dir, name);
if (System.IO.File.Exists(path))
{
using var fs = System.IO.File.OpenRead(path);
return Texture2D.FromStream(gd, fs);
}
return fallback();
}
// ── Procedural placeholder generators ────────────────────────────────
// Each returns a Texture2D the matching widget can use. They aim for
// structural correctness (right size, right 9-slice insets) rather
// than the final illuminated-codex aesthetic.
private static Texture2D MakeParchmentTile(GraphicsDevice gd, int w, int h)
{
var pixels = new Color[w * h];
var rng = new System.Random(0xC0DE);
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
// Two layered radial gradients + grain noise for parchment feel.
float dx1 = (x - w * 0.3f) / (w * 0.6f);
float dy1 = (y - h * 0.2f) / (h * 0.4f);
float light = MathHelper.Clamp(1f - (dx1 * dx1 + dy1 * dy1), 0f, 1f) * 0.18f;
float dx2 = (x - w * 0.8f) / (w * 0.45f);
float dy2 = (y - h * 0.8f) / (h * 0.35f);
float shade = MathHelper.Clamp(1f - (dx2 * dx2 + dy2 * dy2), 0f, 1f) * 0.10f;
float grain = (float)(rng.NextDouble() - 0.5) * 0.04f;
float r = CodexColors.Bg.R / 255f + light - shade + grain;
float g = CodexColors.Bg.G / 255f + light - shade + grain;
float b = CodexColors.Bg.B / 255f + light - shade * 1.3f + grain;
pixels[y * w + x] = new Color(MathHelper.Clamp(r, 0f, 1f), MathHelper.Clamp(g, 0f, 1f), MathHelper.Clamp(b, 0f, 1f));
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeParchmentCard(GraphicsDevice gd, int w, int h)
{
var pixels = new Color[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
// Slight top-down lighten as in `linear-gradient(180deg, rgba(255,250,235,0.05), transparent 30%)`.
float topLift = MathHelper.Clamp(1f - y / (h * 0.3f), 0f, 1f) * 0.04f;
var c = CodexColors.Bg2;
pixels[y * w + x] = new Color(
MathHelper.Clamp(c.R / 255f + topLift, 0f, 1f),
MathHelper.Clamp(c.G / 255f + topLift, 0f, 1f),
MathHelper.Clamp(c.B / 255f + topLift, 0f, 1f));
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeGildFrame(GraphicsDevice gd, int w, int h, bool selected)
{
var pixels = new Color[w * h];
var border = selected ? CodexColors.Seal : CodexColors.Rule;
var glow = selected ? CodexColors.CardSelectedHalo : CodexColors.CardHoverHalo;
int thickness = selected ? 2 : 1;
// 1-px border around the edge, plus an inner glow stripe when selected.
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
if (distFromEdge < thickness) pixels[y * w + x] = border;
else if (selected && distFromEdge < thickness + 2) pixels[y * w + x] = glow;
else pixels[y * w + x] = Color.Transparent;
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeButton(GraphicsDevice gd, int w, int h, Color fill, Color border)
{
var pixels = new Color[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
pixels[y * w + x] = distFromEdge < 1 ? border : fill;
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeWaxSeal(GraphicsDevice gd, int w, int h)
{
var pixels = new Color[w * h];
float cx = w / 2f, cy = h / 2f, r = w / 2f - 2;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
float dx = x - cx, dy = y - cy;
float d = (float)System.Math.Sqrt(dx * dx + dy * dy);
if (d > r) pixels[y * w + x] = Color.Transparent;
else if (d > r - 1.5) pixels[y * w + x] = CodexColors.Seal2;
else pixels[y * w + x] = Color.Lerp(CodexColors.Seal, CodexColors.Seal2, d / r);
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeOrnamentDiamond(GraphicsDevice gd, int w, int h)
{
var pixels = new Color[w * h];
float cx = w / 2f, cy = h / 2f;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
float manhat = System.Math.Abs(x - cx) + System.Math.Abs(y - cy);
pixels[y * w + x] = manhat <= cx ? CodexColors.Gild : Color.Transparent;
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeStepperBullet(GraphicsDevice gd, int size, int _, Color border, Color fill)
{
var pixels = new Color[size * size];
float cx = size / 2f, cy = size / 2f, r = size / 2f - 1;
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
float dx = x - cx, dy = y - cy;
float d = (float)System.Math.Sqrt(dx * dx + dy * dy);
if (d > r) pixels[y * size + x] = Color.Transparent;
else if (d > r - 1.4) pixels[y * size + x] = border;
else pixels[y * size + x] = fill;
}
var tex = new Texture2D(gd, size, size);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeChip(GraphicsDevice gd, int w, int h, Color fill, Color border)
=> MakeButton(gd, w, h, fill, border);
private static Texture2D MakeSlot(GraphicsDevice gd, int w, int h, bool dashed)
{
var pixels = new Color[w * h];
var border = dashed ? CodexColors.InkMute : CodexColors.InkSoft;
var fill = dashed ? Color.Transparent : new Color(180, 138, 60, 16);
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
bool onEdge = distFromEdge < 1;
if (onEdge)
{
if (dashed)
{
int along = (x == 0 || x == w - 1) ? y : x;
pixels[y * w + x] = (along / 3) % 2 == 0 ? border : Color.Transparent;
}
else
{
pixels[y * w + x] = border;
}
}
else pixels[y * w + x] = fill;
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeBar(GraphicsDevice gd, int w, int h, bool fill)
{
var pixels = new Color[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
if (distFromEdge < 1) pixels[y * w + x] = CodexColors.Rule;
else pixels[y * w + x] = fill ? CodexColors.Gild : CodexColors.Bg;
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakePopoverBg(GraphicsDevice gd, int w, int h)
{
var pixels = new Color[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
if (distFromEdge < 1) pixels[y * w + x] = CodexColors.Gild;
else pixels[y * w + x] = CodexColors.Bg2;
}
var tex = new Texture2D(gd, w, h);
tex.SetData(pixels);
return tex;
}
private static Texture2D MakeSigil(GraphicsDevice gd, int size, char letter)
{
var pixels = new Color[size * size];
float cx = size / 2f, cy = size / 2f, r = size / 2f - 1;
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
float dx = x - cx, dy = y - cy;
float d = (float)System.Math.Sqrt(dx * dx + dy * dy);
if (d > r) pixels[y * size + x] = Color.Transparent;
else if (d > r - 1.5) pixels[y * size + x] = CodexColors.Rule;
else pixels[y * size + x] = Color.Lerp(CodexColors.Bg, CodexColors.Bg2, d / r);
}
var tex = new Texture2D(gd, size, size);
tex.SetData(pixels);
// Note: the letter glyph itself is drawn by the widget on top of this circle (CodexCard).
return tex;
}
}
@@ -0,0 +1,82 @@
using FontStashSharp;
using Microsoft.Xna.Framework.Graphics;
namespace Theriapolis.Game.CodexUI.Core;
/// <summary>
/// Static font registry for CodexUI. Loaded once at game startup via
/// <see cref="LoadAll"/>; widgets read from these fields. The slots map to
/// the design's typographic ladder:
/// - DisplayLarge / Medium / Small → Cinzel-Bold (fallback: Georgia Bold) — all-caps headings
/// - SerifBody / SerifItalic → Cormorant Garamond (fallback: Georgia / Georgia Italic)
/// - MonoTag / MonoTagSmall → JetBrains Mono (fallback: Consolas)
///
/// FontStashSharp loads TTF files into a runtime atlas. We try
/// <c>Content/Fonts/</c> first (project-supplied fonts), then fall back to
/// Windows system fonts so the game runs without bundling Cinzel/Cormorant.
/// </summary>
public static class CodexFonts
{
public static SpriteFontBase DisplayLarge = null!; // ~32 px, codex header
public static SpriteFontBase DisplayMedium = null!; // ~22 px, h2
public static SpriteFontBase DisplaySmall = null!; // ~16 px, h3
public static SpriteFontBase SerifBody = null!; // ~16 px, body text
public static SpriteFontBase SerifItalic = null!; // ~14 px, italic body / trait names
public static SpriteFontBase MonoTag = null!; // ~10 px, eyebrow + tag text
public static SpriteFontBase MonoTagSmall = null!; // ~9 px, sub-tags
private static FontSystem? _serif;
private static FontSystem? _italic;
private static FontSystem? _mono;
/// <summary>
/// Load every font slot. Throws on failure — fonts are required for the
/// codex screen to render anything legible.
/// </summary>
public static void LoadAll(GraphicsDevice gd, string contentRoot)
{
_serif = LoadFontSystem(contentRoot, new[] { "Fonts/Cinzel-Bold.ttf", "Fonts/CormorantGaramond-Bold.ttf" },
new[] { @"C:\Windows\Fonts\georgiab.ttf", @"C:\Windows\Fonts\timesbd.ttf" });
_italic = LoadFontSystem(contentRoot, new[] { "Fonts/CormorantGaramond-Italic.ttf", "Fonts/CormorantGaramond-Regular.ttf" },
new[] { @"C:\Windows\Fonts\georgiai.ttf", @"C:\Windows\Fonts\timesi.ttf", @"C:\Windows\Fonts\georgia.ttf" });
_mono = LoadFontSystem(contentRoot, new[] { "Fonts/JetBrainsMono-Medium.ttf" },
new[] { @"C:\Windows\Fonts\consola.ttf", @"C:\Windows\Fonts\consolab.ttf", @"C:\Windows\Fonts\cour.ttf" });
DisplayLarge = _serif.GetFont(32f);
DisplayMedium = _serif.GetFont(22f);
DisplaySmall = _serif.GetFont(16f);
SerifBody = _italic.GetFont(15f);
SerifItalic = _italic.GetFont(14f);
MonoTag = _mono.GetFont(11f);
MonoTagSmall = _mono.GetFont(9f);
}
private static FontSystem LoadFontSystem(string contentRoot, string[] preferred, string[] fallbacks)
{
var fs = new FontSystem();
// Try project-supplied fonts first (Content/Fonts/*).
foreach (var rel in preferred)
{
string path = System.IO.Path.Combine(contentRoot, rel);
if (System.IO.File.Exists(path))
{
fs.AddFont(System.IO.File.ReadAllBytes(path));
return fs;
}
}
// Fall back to a system font — guaranteed available on the target
// platforms (Windows Georgia/Times/Consolas; future: macOS / Linux
// would need their own fallback list).
foreach (var path in fallbacks)
{
if (System.IO.File.Exists(path))
{
fs.AddFont(System.IO.File.ReadAllBytes(path));
return fs;
}
}
throw new System.IO.FileNotFoundException(
"CodexFonts: no font found in either project Content/Fonts/ or system fallback locations. " +
$"Preferred: {string.Join(", ", preferred)}; fallbacks: {string.Join(", ", fallbacks)}");
}
}
+116
View File
@@ -0,0 +1,116 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
namespace Theriapolis.Game.CodexUI.Core;
/// <summary>
/// Per-frame input snapshot used by every CodexUI widget. <see cref="Tick"/>
/// is called once at the top of <see cref="CodexScreen.Update"/>; widgets
/// read the resulting edge-detected event flags during their own Update pass.
///
/// Text input piggybacks on MonoGame's <see cref="GameWindow.TextInput"/>
/// event — the screen subscribes during Initialize and pushes characters
/// into <see cref="TextEnteredThisFrame"/>. Backspace, Enter and arrow keys
/// route through <see cref="KeyJustPressed"/> instead.
///
/// <para>
/// Mouse clipping: a parent widget can set <see cref="SetMouseClip"/> before
/// updating a region of the tree, which causes <see cref="MousePosition"/>
/// to report off-screen for any cursor outside the clip rect. Children that
/// rely on <c>Bounds.Contains(input.MousePosition)</c> to detect hover/click
/// then ignore events that visually land in some other layer (e.g. a
/// stepper bar painted over a scrolled card). Without this, both the
/// clipped widget and the chrome above it would handle the same click.
/// </para>
/// </summary>
public sealed class CodexInput
{
public Point MousePosition { get; private set; }
public Point PreviousMousePosition { get; private set; }
public Point MouseDelta { get; private set; }
public bool LeftButtonDown { get; private set; }
public bool LeftJustPressed { get; private set; }
public bool LeftJustReleased { get; private set; }
public bool RightJustPressed { get; private set; }
public int ScrollDelta { get; private set; }
private MouseState _prev;
private KeyboardState _prevKb;
private KeyboardState _curKb;
private Rectangle? _mouseClip;
private Point _rawMouse;
/// <summary>Characters typed this frame (collected from the window's TextInput event).</summary>
public string TextEnteredThisFrame { get; private set; } = "";
private System.Text.StringBuilder _textBuf = new();
public void OnTextInput(char c) => _textBuf.Append(c);
public void Tick()
{
var m = Mouse.GetState();
var kb = Keyboard.GetState();
PreviousMousePosition = _rawMouse;
_rawMouse = new Point(m.X, m.Y);
MouseDelta = _rawMouse - PreviousMousePosition;
ScrollDelta = m.ScrollWheelValue - _prev.ScrollWheelValue;
bool prevLeft = _prev.LeftButton == ButtonState.Pressed;
bool curLeft = m.LeftButton == ButtonState.Pressed;
LeftButtonDown = curLeft;
LeftJustPressed = !prevLeft && curLeft;
LeftJustReleased = prevLeft && !curLeft;
bool prevRight = _prev.RightButton == ButtonState.Pressed;
bool curRight = m.RightButton == ButtonState.Pressed;
RightJustPressed = !prevRight && curRight;
_prev = m;
_prevKb = _curKb;
_curKb = kb;
TextEnteredThisFrame = _textBuf.ToString();
_textBuf.Clear();
ApplyClip();
}
/// <summary>
/// Restrict <see cref="MousePosition"/> reads to <paramref name="clipRect"/>.
/// Outside the rect the position is reported far off-screen so widgets
/// that hit-test by <c>Bounds.Contains</c> stop registering hover/click.
/// Pair with <see cref="ClearMouseClip"/> after the clipped subtree's
/// Update completes.
/// </summary>
public void SetMouseClip(Rectangle clipRect)
{
_mouseClip = clipRect;
ApplyClip();
}
public void ClearMouseClip()
{
_mouseClip = null;
ApplyClip();
}
/// <summary>Read the current clip rectangle, if any. Used by widgets that
/// nest a tighter clip and need to restore the outer one after their
/// child subtree has finished updating.</summary>
public Rectangle? GetMouseClip() => _mouseClip;
private void ApplyClip()
{
if (_mouseClip is Rectangle r && !r.Contains(_rawMouse))
MousePosition = new Point(-99999, -99999);
else
MousePosition = _rawMouse;
}
public bool KeyDown(Keys k) => _curKb.IsKeyDown(k);
public bool KeyJustPressed(Keys k) => _curKb.IsKeyDown(k) && !_prevKb.IsKeyDown(k);
public bool KeyJustReleased(Keys k) => !_curKb.IsKeyDown(k) && _prevKb.IsKeyDown(k);
}
@@ -0,0 +1,290 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Theriapolis.Game.CodexUI.Core;
public enum HAlign { Left, Center, Right, Stretch }
public enum VAlign { Top, Middle, Bottom, Stretch }
/// <summary>
/// Container widget that stacks its children vertically with <see cref="Spacing"/>
/// between them. Children are laid out top-to-bottom; their horizontal alignment
/// is governed by <see cref="HAlignChildren"/> (defaults to <see cref="HAlign.Stretch"/>).
/// </summary>
public sealed class Column : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int Spacing { get; set; } = CodexDensity.RowGap;
public Thickness Padding { get; set; }
public HAlign HAlignChildren { get; set; } = HAlign.Stretch;
public Column Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int innerW = available.X - Padding.HorizontalSum();
int innerH = available.Y - Padding.VerticalSum();
int totalH = 0, maxW = 0;
for (int i = 0; i < Children.Count; i++)
{
if (!Children[i].Visible) continue;
var s = Children[i].Measure(new Point(innerW, innerH));
totalH += s.Y;
if (i < Children.Count - 1) totalH += Spacing;
if (s.X > maxW) maxW = s.X;
}
return new Point(maxW + Padding.HorizontalSum(), totalH + Padding.VerticalSum());
}
protected override void ArrangeCore(Rectangle bounds)
{
int x = bounds.X + Padding.Left;
int y = bounds.Y + Padding.Top;
int innerW = bounds.Width - Padding.HorizontalSum();
foreach (var c in Children)
{
if (!c.Visible) continue;
int cw = HAlignChildren == HAlign.Stretch ? innerW : System.Math.Min(c.DesiredSize.X, innerW);
int cx = HAlignChildren switch
{
HAlign.Center => x + (innerW - cw) / 2,
HAlign.Right => x + innerW - cw,
_ => x,
};
c.Arrange(new Rectangle(cx, y, cw, c.DesiredSize.Y));
y += c.DesiredSize.Y + Spacing;
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Container widget that stacks its children horizontally. Mirrors <see cref="Column"/>
/// along the perpendicular axis; vertical alignment via <see cref="VAlignChildren"/>.
/// </summary>
public sealed class Row : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int Spacing { get; set; } = CodexDensity.ColGap;
public Thickness Padding { get; set; }
public VAlign VAlignChildren { get; set; } = VAlign.Top;
public Row Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int innerW = available.X - Padding.HorizontalSum();
int innerH = available.Y - Padding.VerticalSum();
int totalW = 0, maxH = 0;
for (int i = 0; i < Children.Count; i++)
{
if (!Children[i].Visible) continue;
var s = Children[i].Measure(new Point(innerW, innerH));
totalW += s.X;
if (i < Children.Count - 1) totalW += Spacing;
if (s.Y > maxH) maxH = s.Y;
}
return new Point(totalW + Padding.HorizontalSum(), maxH + Padding.VerticalSum());
}
protected override void ArrangeCore(Rectangle bounds)
{
int x = bounds.X + Padding.Left;
int y = bounds.Y + Padding.Top;
int innerH = bounds.Height - Padding.VerticalSum();
foreach (var c in Children)
{
if (!c.Visible) continue;
int cy = VAlignChildren switch
{
VAlign.Middle => y + (innerH - c.DesiredSize.Y) / 2,
VAlign.Bottom => y + innerH - c.DesiredSize.Y,
VAlign.Stretch => y,
_ => y,
};
int ch = VAlignChildren == VAlign.Stretch ? innerH : c.DesiredSize.Y;
c.Arrange(new Rectangle(x, cy, c.DesiredSize.X, ch));
x += c.DesiredSize.X + Spacing;
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Wrap-flow layout: lays children left-to-right, breaking to a new row when
/// the running width would exceed the container. Used for chip rows and the
/// review screen's starting-kit grid.
/// </summary>
public sealed class WrapRow : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int HSpacing { get; set; } = CodexDensity.ColGap;
public int VSpacing { get; set; } = CodexDensity.RowGap;
public WrapRow Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int innerW = available.X;
int x = 0, y = 0, rowMaxH = 0, totalW = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
var s = c.Measure(new Point(innerW, available.Y));
if (x > 0 && x + s.X > innerW) { x = 0; y += rowMaxH + VSpacing; rowMaxH = 0; }
x += s.X + HSpacing;
if (s.Y > rowMaxH) rowMaxH = s.Y;
if (x > totalW) totalW = x;
}
y += rowMaxH;
return new Point(System.Math.Min(totalW, innerW), y);
}
protected override void ArrangeCore(Rectangle bounds)
{
int x = bounds.X, y = bounds.Y, rowMaxH = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
if (x > bounds.X && x + c.DesiredSize.X > bounds.X + bounds.Width)
{
x = bounds.X;
y += rowMaxH + VSpacing;
rowMaxH = 0;
}
c.Arrange(new Rectangle(x, y, c.DesiredSize.X, c.DesiredSize.Y));
x += c.DesiredSize.X + HSpacing;
if (c.DesiredSize.Y > rowMaxH) rowMaxH = c.DesiredSize.Y;
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Fixed-column grid: lays children left-to-right then wraps. Each cell gets
/// (innerWidth - colGap*(columns-1)) / columns of horizontal space. Used for
/// the card grid on Clade / Species / Class / Background steps.
/// </summary>
public sealed class Grid : CodexWidget
{
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
public int Columns { get; set; } = 2;
public int HSpacing { get; set; } = CodexDensity.CardGap;
public int VSpacing { get; set; } = CodexDensity.CardGap;
public Grid Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
protected override Point MeasureCore(Point available)
{
int cols = System.Math.Max(1, Columns);
int cellW = (available.X - HSpacing * (cols - 1)) / cols;
int rowH = 0, totalH = 0, col = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
var s = c.Measure(new Point(cellW, available.Y));
if (s.Y > rowH) rowH = s.Y;
col++;
if (col >= cols) { totalH += rowH + VSpacing; rowH = 0; col = 0; }
}
if (col > 0) totalH += rowH;
else if (totalH >= VSpacing) totalH -= VSpacing;
return new Point(available.X, totalH);
}
protected override void ArrangeCore(Rectangle bounds)
{
int cols = System.Math.Max(1, Columns);
int cellW = (bounds.Width - HSpacing * (cols - 1)) / cols;
int x = bounds.X, y = bounds.Y, col = 0, rowH = 0;
foreach (var c in Children)
{
if (!c.Visible) continue;
int ch = c.DesiredSize.Y;
c.Arrange(new Rectangle(x, y, cellW, ch));
if (ch > rowH) rowH = ch;
col++;
if (col >= cols) { y += rowH + VSpacing; x = bounds.X; col = 0; rowH = 0; }
else { x += cellW + HSpacing; }
}
}
public override void Update(GameTime gt, CodexInput input)
{
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
}
}
/// <summary>
/// Single-child decorator that adds breathing room. Combine with any widget
/// that doesn't expose its own padding field.
/// </summary>
public sealed class Padding : CodexWidget
{
public CodexWidget? Child { get; set; }
public Thickness Inset { get; set; }
public Padding(CodexWidget child, Thickness inset) { Child = child; if (child is not null) child.Parent = this; Inset = inset; }
protected override Point MeasureCore(Point available)
{
if (Child is null) return new Point(Inset.HorizontalSum(), Inset.VerticalSum());
var inner = new Point(available.X - Inset.HorizontalSum(), available.Y - Inset.VerticalSum());
var s = Child.Measure(inner);
return new Point(s.X + Inset.HorizontalSum(), s.Y + Inset.VerticalSum());
}
protected override void ArrangeCore(Rectangle bounds)
{
Child?.Arrange(new Rectangle(
bounds.X + Inset.Left,
bounds.Y + Inset.Top,
bounds.Width - Inset.HorizontalSum(),
bounds.Height - Inset.VerticalSum()));
}
public override void Update(GameTime gt, CodexInput input) => Child?.Update(gt, input);
public override void Draw(SpriteBatch sb, GameTime gt) => Child?.Draw(sb, gt);
}
/// <summary>Box-model insets: independent left/top/right/bottom in pixels.</summary>
public readonly struct Thickness
{
public readonly int Left, Top, Right, Bottom;
public Thickness(int uniform) : this(uniform, uniform, uniform, uniform) { }
public Thickness(int l, int t, int r, int b) { Left = l; Top = t; Right = r; Bottom = b; }
public int HorizontalSum() => Left + Right;
public int VerticalSum() => Top + Bottom;
}
@@ -0,0 +1,109 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Drag;
using Theriapolis.Game.Screens;
namespace Theriapolis.Game.CodexUI.Core;
/// <summary>
/// Base class for screens implemented in CodexUI. Owns the input snapshot,
/// the root widget, the parchment background tiling, the drag-drop
/// controller, and the popover layer. Concrete screens override
/// <see cref="BuildRoot"/> to populate the widget tree.
///
/// Implements <see cref="IScreen"/> so the existing <c>ScreenManager</c>
/// can treat CodexUI screens identically to Myra-based ones.
/// </summary>
public abstract class CodexScreen : IScreen
{
protected Game1 Game = null!;
protected CodexInput Input { get; } = new();
protected CodexWidget? Root;
protected CodexAtlas Atlas { get; private set; } = null!;
protected DragDropController DragDrop { get; } = new();
/// <summary>The popover layer is painted last so floating panels stay above the page.</summary>
protected Widgets.CodexHoverPopover? Popover { get; set; }
private System.EventHandler<TextInputEventArgs>? _textInputHandler;
private bool _layoutDirty = true;
public virtual void Initialize(Game1 game)
{
Game = game;
Atlas = game.CodexAtlas;
if (CodexFonts.DisplayLarge is null)
throw new System.InvalidOperationException("CodexFonts.LoadAll must be called before any CodexScreen is initialized.");
_textInputHandler = (_, e) =>
{
// Filter out non-printable controls so backspace etc. routes via
// KeyJustPressed instead of pushing into the text buffer.
if (e.Character >= 32 && e.Character != 127) Input.OnTextInput(e.Character);
};
game.Window.TextInput += _textInputHandler;
Root = BuildRoot();
_layoutDirty = true;
}
public virtual void Deactivate()
{
if (_textInputHandler is not null) Game.Window.TextInput -= _textInputHandler;
}
public virtual void Reactivate() { _layoutDirty = true; }
/// <summary>Concrete screens build the entire widget tree here. Called once after Initialize.</summary>
protected abstract CodexWidget BuildRoot();
/// <summary>Force a re-measure on next frame (e.g. step changed).</summary>
public void InvalidateLayout()
{
Root = BuildRoot();
_layoutDirty = true;
}
public virtual void Update(GameTime gameTime)
{
Input.Tick();
if (Root is null) return;
if (_layoutDirty)
{
var vp = Game.GraphicsDevice.Viewport;
Root.Measure(new Point(vp.Width, vp.Height));
Root.Arrange(new Rectangle(0, 0, vp.Width, vp.Height));
_layoutDirty = false;
}
Root.Update(gameTime, Input);
Popover?.Update(gameTime, Input);
DragDrop.Update(gameTime, Input);
}
public virtual void Draw(GameTime gameTime, SpriteBatch sb)
{
Game.GraphicsDevice.Clear(CodexColors.BgDeep);
sb.Begin();
DrawBackground(sb);
Root?.Draw(sb, gameTime);
Popover?.Draw(sb, gameTime);
DragDrop.Draw(sb);
sb.End();
}
/// <summary>
/// Paint a flat <see cref="CodexColors.BgDeep"/> across the full viewport.
/// The body widget paints its own lighter <see cref="CodexColors.Bg"/>
/// fill on top so cards (which use the slightly-darker
/// <see cref="CodexColors.Bg2"/>) read clearly against their immediate
/// background. We don't tile a parchment-grain texture: a 256-px tile
/// produces visible seams, and the procedural radial gradients made
/// some areas brighter than the cards on top of them.
/// </summary>
private void DrawBackground(SpriteBatch sb)
{
var vp = Game.GraphicsDevice.Viewport;
sb.Draw(Atlas.Pixel, vp.Bounds, CodexColors.BgDeep);
}
}
@@ -0,0 +1,55 @@
using Microsoft.Xna.Framework;
namespace Theriapolis.Game.CodexUI.Core;
/// <summary>
/// Color tokens for the illuminated-codex aesthetic. Values extracted from
/// the React design at <c>_design_handoff/character_creation/from_design/index.html</c>
/// `:root` CSS variable block. Single source of truth for every paint colour
/// in CodexUI; widgets must read from here rather than hardcoding.
/// </summary>
public static class CodexColors
{
// Backgrounds — parchment family
public static readonly Color Bg = HexColor(0xE8DCC0); // parchment
public static readonly Color BgDeep = HexColor(0xC7B48B); // worn parchment shadow
public static readonly Color Bg2 = HexColor(0xD9C9A6); // card body fill
// Inks — primary text gradient
public static readonly Color Ink = HexColor(0x2B1D10); // primary serif text
public static readonly Color InkSoft = HexColor(0x5A4527); // secondary text
public static readonly Color InkMute = HexColor(0x8A6F48); // tertiary / placeholder
// Accents
public static readonly Color Gild = HexColor(0xB48A3C); // gilded borders + select halo
public static readonly Color GildBright = HexColor(0xD4A23E); // hover halo / highlight
public static readonly Color Seal = HexColor(0x7A1F12); // wax-red, danger / negative bonus
public static readonly Color Seal2 = HexColor(0x5A160C); // dark wax (selected accent)
public static readonly Color Rule = HexColor(0x8A6F48); // hairline rule color
// Transparent / overlay variants
public static readonly Color CardHoverHalo = new(180, 138, 60, 28);
public static readonly Color CardSelectedHalo = new(122, 31, 18, 64);
public static readonly Color PoolDieBg = new(180, 138, 60, 12);
private static Color HexColor(uint rgb) =>
new((byte)((rgb >> 16) & 0xFF), (byte)((rgb >> 8) & 0xFF), (byte)(rgb & 0xFF), (byte)0xFF);
}
/// <summary>
/// Density / spacing tokens. Mirror the React design's <c>--gap</c>, <c>--pad</c>
/// and per-widget spacing constants. Widgets pull these instead of hardcoding
/// magic numbers so a future "compact density" toggle can resize the whole UI.
/// </summary>
public static class CodexDensity
{
public const int RowGap = 6;
public const int ColGap = 8;
public const int CardPad = 18;
public const int PanelPad = 28;
public const int ChipPad = 6;
public const int ButtonPad = 10;
public const int CardWidth = 240; // minimum card width (matches design's grid-template min)
public const int CardGap = 24;
public const int AsideWidth = 380;
}
@@ -0,0 +1,47 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Theriapolis.Game.CodexUI.Core;
/// <summary>
/// Base class for every CodexUI widget. Layout is two-pass:
/// 1. <see cref="Measure"/> — child reports a desired size given an
/// available size envelope.
/// 2. <see cref="Arrange"/> — parent places the child by writing into
/// <see cref="Bounds"/>; the child propagates to its own children.
///
/// Update + Draw run after layout. Hit-testing uses screen-space
/// <see cref="Bounds"/> directly via <see cref="ContainsPoint"/>.
/// </summary>
public abstract class CodexWidget
{
public Rectangle Bounds { get; protected set; }
public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true;
public CodexWidget? Parent { get; internal set; }
public Point DesiredSize { get; protected set; }
/// <summary>Child reports its preferred size; parent decides whether to honour it.</summary>
public Point Measure(Point available)
{
DesiredSize = MeasureCore(available);
return DesiredSize;
}
/// <summary>Parent commits a final rectangle; child propagates to grandchildren.</summary>
public void Arrange(Rectangle bounds)
{
Bounds = bounds;
ArrangeCore(bounds);
}
protected abstract Point MeasureCore(Point available);
protected abstract void ArrangeCore(Rectangle bounds);
public virtual void Update(GameTime gt, CodexInput input) { }
public virtual void Draw(SpriteBatch sb, GameTime gt) { }
/// <summary>True if a screen-space point lies inside our bounds. Hover-/click-test helper.</summary>
public bool ContainsPoint(Point p) => Bounds.Contains(p);
}
@@ -0,0 +1,63 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Theriapolis.Game.CodexUI.Core;
/// <summary>
/// Renders a 9-slice texture into an arbitrary destination rectangle: the
/// four corners stay at their authored size, the four edges stretch along
/// one axis, and the centre stretches both axes. Used for parchment cards,
/// gilded borders, button backgrounds, and slot frames.
///
/// Per-side inset values describe how many source-texture pixels the
/// fixed-size corner+edge bands take. They must add up to less than the
/// source texture's width/height in each axis or the centre band collapses.
/// </summary>
public readonly struct NineSliceInsets
{
public readonly int Left, Top, Right, Bottom;
public NineSliceInsets(int uniform) : this(uniform, uniform, uniform, uniform) { }
public NineSliceInsets(int l, int t, int r, int b) { Left = l; Top = t; Right = r; Bottom = b; }
}
public static class NineSlice
{
/// <summary>
/// Draw a 9-slice <paramref name="texture"/> tinted with <paramref name="tint"/>
/// into <paramref name="dest"/> using <paramref name="insets"/> as the corner sizes.
/// </summary>
public static void Draw(SpriteBatch sb, Texture2D texture, Rectangle dest,
NineSliceInsets insets, Color tint)
{
int sw = texture.Width;
int sh = texture.Height;
int l = insets.Left, t = insets.Top, r = insets.Right, b = insets.Bottom;
int cw = sw - l - r; // source center width
int ch = sh - t - b; // source center height
if (cw < 1) cw = 1;
if (ch < 1) ch = 1;
int dl = dest.X;
int dt = dest.Y;
int dcw = System.Math.Max(0, dest.Width - l - r);
int dch = System.Math.Max(0, dest.Height - t - b);
int dr = dest.X + dest.Width - r;
int db = dest.Y + dest.Height - b;
// Corners (1× scale)
sb.Draw(texture, new Rectangle(dl, dt, l, t), new Rectangle(0, 0, l, t), tint);
sb.Draw(texture, new Rectangle(dr, dt, r, t), new Rectangle(sw - r, 0, r, t), tint);
sb.Draw(texture, new Rectangle(dl, db, l, b), new Rectangle(0, sh - b, l, b), tint);
sb.Draw(texture, new Rectangle(dr, db, r, b), new Rectangle(sw - r, sh - b, r, b), tint);
// Edges (stretched along their long axis)
sb.Draw(texture, new Rectangle(dl + l, dt, dcw, t), new Rectangle(l, 0, cw, t), tint);
sb.Draw(texture, new Rectangle(dl + l, db, dcw, b), new Rectangle(l, sh - b, cw, b), tint);
sb.Draw(texture, new Rectangle(dl, dt + t, l, dch), new Rectangle(0, t, l, ch), tint);
sb.Draw(texture, new Rectangle(dr, dt + t, r, dch), new Rectangle(sw - r, t, r, ch), tint);
// Centre (stretched both axes)
sb.Draw(texture, new Rectangle(dl + l, dt + t, dcw, dch), new Rectangle(l, t, cw, ch), tint);
}
}
@@ -0,0 +1,99 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Drag;
/// <summary>
/// Screen-level coordinator for click-drag interactions. Source widgets call
/// <see cref="BeginDrag"/> on left-mouse-down within their bounds and supply
/// (a) an arbitrary payload object — game-specific state, e.g. <c>StatPoolPayload</c> —
/// and (b) a ghost callback that paints a small follow-the-cursor visual.
///
/// Drop targets register via <see cref="RegisterTarget"/>; on left-mouse-up
/// the controller hit-tests in registration order and fires <see cref="OnDrop"/>
/// with the matching target's id. Pressing <see cref="Keys.Escape"/> mid-drag
/// cancels and fires <see cref="OnCancel"/>.
///
/// One-controller-per-screen; concrete screens own the instance and pass it
/// down to widgets that participate in drag-drop.
/// </summary>
public sealed class DragDropController
{
public bool IsDragging => _payload is not null;
public object? Payload => _payload;
public Point CursorPosition { get; private set; }
private object? _payload;
private System.Action<SpriteBatch, Point>? _ghost;
private readonly System.Collections.Generic.List<DropTarget> _targets = new();
public event System.Action<object, string>? OnDrop; // (payload, targetId)
public event System.Action<object>? OnCancel;
public event System.Action<object, Point>? OnDropAnywhere; // fired when drop lands outside any registered target
public void BeginDrag(object payload, System.Action<SpriteBatch, Point> ghost)
{
_payload = payload;
_ghost = ghost;
}
public void RegisterTarget(string id, Rectangle bounds)
=> _targets.Add(new DropTarget(id, bounds));
public void ClearTargets() => _targets.Clear();
public void Update(GameTime gt, CodexInput input)
{
CursorPosition = input.MousePosition;
if (!IsDragging) { _targets.Clear(); return; }
if (input.KeyJustPressed(Keys.Escape))
{
OnCancel?.Invoke(_payload!);
_payload = null;
_ghost = null;
_targets.Clear();
return;
}
if (input.LeftJustReleased)
{
string? hit = null;
foreach (var t in _targets)
if (t.Bounds.Contains(input.MousePosition)) { hit = t.Id; break; }
if (hit is not null) OnDrop?.Invoke(_payload!, hit);
else OnDropAnywhere?.Invoke(_payload!, input.MousePosition);
_payload = null;
_ghost = null;
_targets.Clear();
}
}
public void Draw(SpriteBatch sb)
{
if (_payload is null || _ghost is null) return;
_ghost(sb, CursorPosition);
}
private readonly struct DropTarget
{
public readonly string Id;
public readonly Rectangle Bounds;
public DropTarget(string id, Rectangle bounds) { Id = id; Bounds = bounds; }
}
}
/// <summary>
/// Payload type for the stat-assignment drag-drop dance. Mirrors the React
/// design's <c>{from, value, idx, ability}</c> object so behavior ports verbatim.
/// </summary>
public sealed class StatPoolPayload
{
public required string Source { get; init; } // "pool" or "slot"
public required int Value { get; init; }
public int? PoolIdx { get; init; } // index in pool list when Source == "pool"
public Theriapolis.Core.Rules.Stats.AbilityId? Ability { get; init; } // when Source == "slot"
}
@@ -0,0 +1,231 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Game.CodexUI.Core;
using Theriapolis.Game.CodexUI.Steps;
using Theriapolis.Game.CodexUI.Widgets;
using Theriapolis.Game.UI;
namespace Theriapolis.Game.CodexUI.Screens;
/// <summary>
/// Right-column live summary. Reads from <see cref="CodexCharacterCreationScreen"/>'s
/// state and renders five blocks (Name, Lineage, Calling+History, Abilities,
/// Skills) plus a stat strip. Mirrors the React <c>&lt;Aside /&gt;</c>
/// component in <c>app.jsx</c>.
/// </summary>
public sealed class CodexAside
{
private readonly CodexCharacterCreationScreen _s;
private readonly CodexAtlas _atlas;
public CodexAside(CodexCharacterCreationScreen s, CodexAtlas atlas)
{
_s = s;
_atlas = atlas;
}
public CodexWidget Build()
{
// Aside lives next to the page main and shares the screen's
// popover layer — hovering a chip here pops the same parchment-
// and-gilt popover the cards on the left side use.
var popover = _s.AsidePopover;
var col = new Column { Spacing = 14, HAlignChildren = HAlign.Stretch };
col.Add(new CodexLabel("THE SUBJECT", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(NameBlock());
col.Add(LineageBlock(popover));
col.Add(CallingBlock(popover));
col.Add(HistoryBlock(popover));
col.Add(BuildStatStrip());
col.Add(SkillsBlock(popover));
return col;
}
private CodexWidget NameBlock()
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel("NAME", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(new CodexLabel(string.IsNullOrWhiteSpace(_s.Name) ? "(unnamed)" : _s.Name,
CodexFonts.DisplayMedium, CodexColors.Ink));
return col;
}
private CodexWidget LineageBlock(CodexHoverPopover popover)
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel("LINEAGE", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(new CodexLabel(_s.Species?.Name ?? "—",
CodexFonts.DisplayMedium, CodexColors.Ink));
if (_s.Clade is not null || _s.Species is not null)
{
string sub = (_s.Clade?.Name ?? "—").ToUpperInvariant()
+ (_s.Clade is not null ? " · " + _s.Clade.Kind.ToUpperInvariant() : "")
+ (_s.Species is not null ? " · " + CodexCopy.SizeLabel(_s.Species.Size).ToUpperInvariant() : "");
col.Add(new CodexLabel(sub, CodexFonts.MonoTagSmall, CodexColors.InkMute));
}
// Trait chips — clade traits + species traits (each hover-popover'd).
var chips = new WrapRow();
if (_s.Clade is not null)
{
foreach (var t in _s.Clade.Traits)
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
foreach (var t in _s.Clade.Detriments)
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
}
if (_s.Species is not null)
{
foreach (var t in _s.Species.Traits)
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
foreach (var t in _s.Species.Detriments)
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
}
if (chips.Children.Count > 0) col.Add(chips);
return col;
}
private CodexWidget CallingBlock(CodexHoverPopover popover)
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel("CALLING", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(new CodexLabel(_s.Class?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
if (_s.Class is not null)
col.Add(new CodexLabel($"D{_s.Class.HitDie} · {string.Join("/", _s.Class.PrimaryAbility)}",
CodexFonts.MonoTagSmall, CodexColors.InkMute));
// Level-1 feature chips for the chosen class (hover surfaces description).
if (_s.Class is not null)
{
var lvl1 = System.Array.Find(_s.Class.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 (!_s.Class.FeatureDefinitions.TryGetValue(k, out var fd)) continue;
chips.Add(new HoverableChip(_atlas, popover, fd.Name, fd.Name, fd.Description, fd.Kind?.ToUpperInvariant(), ChipKind.Trait));
}
if (chips.Children.Count > 0) col.Add(chips);
}
}
return col;
}
private CodexWidget HistoryBlock(CodexHoverPopover popover)
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel("HISTORY", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(new CodexLabel(_s.Background?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
if (_s.Background is not null && !string.IsNullOrEmpty(_s.Background.FeatureName))
{
var chips = new WrapRow();
chips.Add(new HoverableChip(_atlas, popover,
_s.Background.FeatureName, _s.Background.FeatureName, _s.Background.FeatureDescription,
"FEATURE", ChipKind.BgFeature));
col.Add(chips);
}
return col;
}
private CodexWidget SkillsBlock(CodexHoverPopover popover)
{
int total = _s.ChosenSkills.Count + (_s.Background?.SkillProficiencies.Length ?? 0);
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel("SKILLS · " + total, CodexFonts.MonoTag, CodexColors.InkMute));
var chips = new WrapRow();
if (_s.Background is not null)
foreach (var s in _s.Background.SkillProficiencies)
chips.Add(new HoverableChip(_atlas, popover,
CodexCopy.SkillName(s),
CodexCopy.SkillName(s),
CodexCopy.SkillDescription(s),
"BACKGROUND",
ChipKind.SkillFromBg));
foreach (var s in _s.ChosenSkills.OrderBy(x => x.ToString()))
{
// Map enum → snake_case JSON id; otherwise SleightOfHand /
// AnimalHandling lose their hover descriptions because the
// CodexCopy switch is keyed on the JSON form.
string id = CodexCopy.SkillIdToJson(s);
chips.Add(new HoverableChip(_atlas, popover,
CodexCopy.SkillName(id),
CodexCopy.SkillName(id),
CodexCopy.SkillDescription(id),
"CLASS",
ChipKind.SkillFromClass));
}
col.Add(chips);
return col;
}
private CodexWidget BuildStatStrip()
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel("ABILITIES", CodexFonts.MonoTag, CodexColors.InkMute));
col.Add(new StatStrip(_s, _atlas));
return col;
}
}
/// <summary>
/// Six-cell strip (one per ability) with name, final score, and signed
/// modifier. Used in the aside panel and on the review step.
/// </summary>
internal sealed class StatStrip : CodexWidget
{
private readonly CodexCharacterCreationScreen _s;
private readonly CodexAtlas _atlas;
public StatStrip(CodexCharacterCreationScreen s, CodexAtlas atlas) { _s = s; _atlas = atlas; }
protected override Point MeasureCore(Point available) => new(available.X, 50);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt)
{
var abFont = CodexFonts.MonoTagSmall;
var scoreFont = CodexFonts.DisplayMedium;
var modFont = CodexFonts.MonoTagSmall;
int cellW = (Bounds.Width - 5 * 4) / 6;
for (int i = 0; i < CodexCopy.AbilityOrder.Length; i++)
{
var ab = CodexCopy.AbilityOrder[i];
int x = Bounds.X + i * (cellW + 4);
var rect = new Rectangle(x, Bounds.Y, cellW, Bounds.Height);
sb.Draw(_atlas.Pixel, rect, CodexColors.Bg);
DrawBorder(sb, rect, CodexColors.Rule, 1);
string ablab = ab.ToString();
var s1 = abFont.MeasureString(ablab);
abFont.DrawText(sb, ablab, new Vector2(rect.X + (rect.Width - s1.X) / 2f, rect.Y + 4), CodexColors.InkMute);
int? base_ = _s.StatAssign.TryGetValue(ab, out var v) ? v : (int?)null;
int total = (base_ ?? 0) + _s.TotalBonus(ab);
int mod = AbilityScores.Mod(total);
string scoreText = base_ is null ? "—" : total.ToString();
string modText = base_ is null ? "" : ((mod >= 0 ? "+" : "") + mod);
var s2 = scoreFont.MeasureString(scoreText);
scoreFont.DrawText(sb, scoreText, new Vector2(rect.X + (rect.Width - s2.X) / 2f, rect.Y + 16), CodexColors.Ink);
if (modText.Length > 0)
{
var s3 = modFont.MeasureString(modText);
modFont.DrawText(sb, modText, new Vector2(rect.X + (rect.Width - s3.X) / 2f, rect.Bottom - modFont.LineHeight - 4),
mod >= 0 ? CodexColors.Seal : CodexColors.InkMute);
}
}
}
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
{
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,270 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
using Theriapolis.Game.CodexUI.Drag;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// One row of the stat-assignment grid. Composite layout:
/// [ Ability name + bonus pill ] [ Slot ] [ formula ] [ Final + mod ] [ progress bar ]
/// The slot is both a drop target (for pool dice) and a drag source (when
/// filled — drag the value back to the pool, or onto another slot to swap).
/// Click on a filled slot also returns to pool, mirroring the React design.
/// </summary>
public sealed class CodexAbilityRow : CodexWidget
{
public AbilityId Ability { get; }
public string LongName { get; set; } = "";
public int? Assigned { get; set; }
public int Bonus { get; set; } // total clade + species mod
public string BonusSourceText { get; set; } = "";
public bool IsPrimary { get; set; }
public System.Action? OnSlotClick { get; set; }
public System.Action<StatPoolPayload>? OnDragStart { get; set; }
private readonly CodexAtlas _atlas;
private readonly DragDropController _drag;
private readonly SpriteFontBase _nameFont = CodexFonts.DisplaySmall;
private readonly SpriteFontBase _tagFont = CodexFonts.MonoTagSmall;
private readonly SpriteFontBase _slotFont = CodexFonts.DisplayMedium;
private readonly SpriteFontBase _modFont = CodexFonts.MonoTag;
private CodexBonusPill? _bonusPill;
public CodexAbilityRow(AbilityId ability, CodexAtlas atlas, DragDropController drag)
{
Ability = ability;
_atlas = atlas;
_drag = drag;
}
public CodexBonusPill? BonusPillRef => _bonusPill;
protected override Point MeasureCore(Point available) => new(available.X, 56);
protected override void ArrangeCore(Rectangle bounds)
{
// Lay out the bonus pill within the name column so we can hover-test it.
if (Bonus != 0)
{
_bonusPill ??= new CodexBonusPill(Bonus, _atlas);
var pillSize = _bonusPill.Measure(new Point(60, bounds.Height));
_bonusPill.Arrange(new Rectangle(
bounds.X + 60,
bounds.Y + (bounds.Height - pillSize.Y) / 2,
pillSize.X, pillSize.Y));
}
else _bonusPill = null;
}
public override void Update(GameTime gt, CodexInput input)
{
var slotRect = SlotRect();
// Dragging a value out of a filled slot.
if (Assigned is int v && slotRect.Contains(input.MousePosition) && input.LeftJustPressed && !_drag.IsDragging)
{
OnDragStart?.Invoke(new StatPoolPayload { Source = "slot", Value = v, Ability = Ability });
return;
}
// Click-to-return (no drag) when a slot is filled and the pool is not currently active.
if (Assigned is not null && slotRect.Contains(input.MousePosition) && input.LeftJustReleased && !_drag.IsDragging)
{
OnSlotClick?.Invoke();
}
// Empty slot click — also fires OnSlotClick so the screen can use click-to-place semantics if drag isn't active.
else if (Assigned is null && slotRect.Contains(input.MousePosition) && input.LeftJustReleased && !_drag.IsDragging)
{
OnSlotClick?.Invoke();
}
// Register the slot as a drop target.
if (_drag.IsDragging)
_drag.RegisterTarget("ability:" + Ability, slotRect);
_bonusPill?.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
// Bottom rule
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1),
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)100));
// Name column
_nameFont.DrawText(sb, Ability.ToString(), new Vector2(Bounds.X, Bounds.Y + 8), CodexColors.Ink);
string sub = LongName + (IsPrimary ? " · primary" : "");
_tagFont.DrawText(sb, sub.ToUpperInvariant(), new Vector2(Bounds.X, Bounds.Y + 8 + _nameFont.LineHeight), CodexColors.InkMute);
_bonusPill?.Draw(sb, gt);
// Slot
var slotRect = SlotRect();
bool dragHover = _drag.IsDragging && slotRect.Contains(_drag.CursorPosition);
bool filled = Assigned is not null;
Color slotBorder = dragHover ? CodexColors.Seal : (filled ? CodexColors.InkSoft : CodexColors.InkMute);
Color slotFill = dragHover
? new Color(CodexColors.Seal.R, CodexColors.Seal.G, CodexColors.Seal.B, (byte)28)
: (filled ? new Color(180, 138, 60, 22) : CodexColors.Bg);
sb.Draw(_atlas.Pixel, slotRect, slotFill);
DrawBorder(sb, slotRect, slotBorder, filled ? 1 : 1, dashed: !filled);
if (filled)
{
string label = Assigned!.Value.ToString();
var s = _slotFont.MeasureString(label);
_slotFont.DrawText(sb, label, new Vector2(slotRect.X + (slotRect.Width - s.X) / 2f,
slotRect.Y + (slotRect.Height - _slotFont.LineHeight) / 2f),
CodexColors.Ink);
}
else
{
string dash = "—";
var s = _slotFont.MeasureString(dash);
_slotFont.DrawText(sb, dash, new Vector2(slotRect.X + (slotRect.Width - s.X) / 2f,
slotRect.Y + (slotRect.Height - _slotFont.LineHeight) / 2f),
CodexColors.InkMute);
}
// Formula column ("base 13" when bonus is non-zero)
var formulaRect = new Rectangle(slotRect.Right + 14, Bounds.Y, 80, Bounds.Height);
if (filled && Bonus != 0)
{
string text = "base " + Assigned!.Value;
_modFont.DrawText(sb, text, new Vector2(formulaRect.X, formulaRect.Y + (formulaRect.Height - _modFont.LineHeight) / 2f), CodexColors.InkMute);
}
// Final score + modifier
var finalRect = new Rectangle(formulaRect.Right + 8, Bounds.Y, 90, Bounds.Height);
if (filled)
{
int final = Assigned!.Value + Bonus;
int mod = AbilityScores.Mod(final);
string fLabel = final.ToString();
string mLabel = (mod >= 0 ? "+" : "") + mod;
var fSize = _slotFont.MeasureString(fLabel);
_slotFont.DrawText(sb, fLabel, new Vector2(finalRect.X, finalRect.Y + 4), CodexColors.Ink);
_modFont.DrawText(sb, mLabel, new Vector2(finalRect.X + fSize.X + 6, finalRect.Y + 14),
mod >= 0 ? CodexColors.Seal : CodexColors.InkMute);
}
// Progress bar
var barRect = new Rectangle(finalRect.Right + 12, Bounds.Y + 24, Bounds.Right - finalRect.Right - 16, 6);
sb.Draw(_atlas.Pixel, barRect, CodexColors.Bg);
DrawBorder(sb, barRect, CodexColors.Rule, 1, dashed: false);
if (filled)
{
int final = System.Math.Clamp(Assigned!.Value + Bonus, 0, 20);
int fillW = (int)(barRect.Width * (final / 20f));
sb.Draw(_atlas.Pixel, new Rectangle(barRect.X, barRect.Y, fillW, barRect.Height), CodexColors.Gild);
}
}
private Rectangle SlotRect() => new(Bounds.X + 160, Bounds.Y + 6, 60, Bounds.Height - 12);
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t, bool dashed)
{
if (!dashed)
{
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
return;
}
// Dashed: draw 4-px dashes with 3-px gaps
for (int x = r.X; x < r.Right; x += 7)
{
int w = System.Math.Min(4, r.Right - x);
sb.Draw(_atlas.Pixel, new Rectangle(x, r.Y, w, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(x, r.Bottom - t, w, t), c);
}
for (int y = r.Y; y < r.Bottom; y += 7)
{
int h = System.Math.Min(4, r.Bottom - y);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, y, t, h), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, y, t, h), c);
}
}
}
/// <summary>
/// One draggable value in the stat pool. Renders a small parchment tile
/// with the rolled / standard-array number. Drag begins on left-mouse-down;
/// the drag-drop controller then takes over the visual via its ghost
/// callback.
/// </summary>
public sealed class CodexPoolDie : CodexWidget
{
public int Value { get; }
public int IndexInPool { get; set; }
public bool IsSelected { get; set; }
public System.Action<StatPoolPayload>? OnDragStart { get; set; }
public System.Action? OnClick { get; set; }
private readonly CodexAtlas _atlas;
private readonly DragDropController _drag;
private readonly SpriteFontBase _font = CodexFonts.DisplayMedium;
public CodexPoolDie(int value, int indexInPool, CodexAtlas atlas, DragDropController drag)
{
Value = value;
IndexInPool = indexInPool;
_atlas = atlas;
_drag = drag;
}
protected override Point MeasureCore(Point available) => new(56, 56);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
if (input.LeftJustPressed && ContainsPoint(input.MousePosition) && !_drag.IsDragging)
{
// Begin drag.
OnDragStart?.Invoke(new StatPoolPayload { Source = "pool", Value = Value, PoolIdx = IndexInPool });
_drag.BeginDrag(
new StatPoolPayload { Source = "pool", Value = Value, PoolIdx = IndexInPool },
(sb, p) => DrawGhost(sb, p));
}
if (input.LeftJustReleased && ContainsPoint(input.MousePosition) && !_drag.IsDragging)
OnClick?.Invoke();
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
Color border = IsSelected ? 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);
string label = Value.ToString();
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),
CodexColors.Ink);
}
private void DrawGhost(SpriteBatch sb, Point cursor)
{
var rect = new Rectangle(cursor.X - 28, cursor.Y - 28, 56, 56);
var c = new Color(180, 138, 60, 200);
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), c);
sb.Draw(_atlas.Pixel, new Rectangle(rect.X, rect.Bottom - 1, rect.Width, 1), c);
sb.Draw(_atlas.Pixel, new Rectangle(rect.X, rect.Y, 1, rect.Height), c);
sb.Draw(_atlas.Pixel, new Rectangle(rect.Right - 1, rect.Y, 1, rect.Height), c);
string label = Value.ToString();
var s = _font.MeasureString(label);
_font.DrawText(sb, label, new Vector2(rect.X + (rect.Width - s.X) / 2f,
rect.Y + (rect.Height - _font.LineHeight) / 2f),
CodexColors.Ink);
}
}
@@ -0,0 +1,111 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
public enum CodexButtonVariant { Primary, Ghost, Small }
/// <summary>
/// Codex-styled push button. The three variants match the React design's
/// <c>.btn.primary / .btn.ghost / .btn.small</c>:
/// - Primary: gilded fill on parchment, used for Confirm + Next-style actions.
/// - Ghost: ink border, transparent fill, used for Back + secondary actions.
/// - Small: smaller padding/font, used inline (Reroll / Auto-assign / Clear).
/// </summary>
public sealed class CodexButton : CodexWidget
{
public string Text { get; set; }
public CodexButtonVariant Variant { get; set; }
public System.Action? OnClick { get; set; }
public int? FixedWidth { get; set; }
private readonly CodexAtlas _atlas;
private readonly SpriteFontBase _font;
private bool _hovered;
private bool _pressed;
public CodexButton(string text, CodexAtlas atlas, CodexButtonVariant variant = CodexButtonVariant.Ghost,
System.Action? onClick = null, int? fixedWidth = null)
{
Text = text;
_atlas = atlas;
Variant = variant;
OnClick = onClick;
FixedWidth = fixedWidth;
_font = variant == CodexButtonVariant.Small ? CodexFonts.MonoTag : CodexFonts.DisplaySmall;
}
protected override Point MeasureCore(Point available)
{
var s = _font.MeasureString(Text);
int padX = Variant == CodexButtonVariant.Small ? 12 : 22;
int padY = Variant == CodexButtonVariant.Small ? 6 : 10;
int w = FixedWidth ?? ((int)s.X + padX * 2);
int h = (int)System.MathF.Ceiling(_font.LineHeight) + padY * 2;
return new Point(System.Math.Min(w, available.X), h);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
bool wasHovered = _hovered;
_hovered = ContainsPoint(input.MousePosition);
if (_hovered && input.LeftJustPressed) _pressed = true;
if (input.LeftJustReleased)
{
if (_pressed && _hovered && Enabled) OnClick?.Invoke();
_pressed = false;
}
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
Color fill, border, textColor;
switch (Variant)
{
case CodexButtonVariant.Primary:
fill = _hovered ? CodexColors.Seal2 : CodexColors.Seal;
border = CodexColors.Seal2;
textColor = CodexColors.Bg;
break;
case CodexButtonVariant.Ghost:
fill = _hovered ? CodexColors.Ink : CodexColors.Bg;
border = CodexColors.Ink;
textColor = _hovered ? CodexColors.Bg : CodexColors.Ink;
break;
default:
fill = _hovered ? CodexColors.Bg2 : CodexColors.Bg;
border = CodexColors.Rule;
textColor = CodexColors.InkSoft;
break;
}
if (!Enabled)
{
// 40% opacity per .btn[disabled]
fill = new Color(fill.R, fill.G, fill.B, (byte)(fill.A * 0.4f));
textColor = new Color(textColor.R, textColor.G, textColor.B, (byte)(textColor.A * 0.6f));
}
// Body fill
sb.Draw(_atlas.Pixel, Bounds, fill);
// 1-px border outline
DrawBorder(sb, Bounds, border, 1);
var s = _font.MeasureString(Text);
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
_font.DrawText(sb, Text, new Vector2(tx, ty), textColor);
}
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
{
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
}
}
@@ -0,0 +1,189 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Selectable card. The clade / species / class / background pickers each
/// render a grid of these. Visual states track the React design:
/// - Default: rule-coloured 1-px border, parchment fill.
/// - Hover: gilded border, subtle gild halo overlay.
/// - Selected: seal-red border + inner glow + corner wax-seal accent.
///
/// Click toggles selection by calling <see cref="OnClick"/>; the parent step
/// owns the "what is selected" state and rebuilds. The card's content tree
/// is whatever <see cref="Content"/> child is supplied — typically a small
/// Column with name + meta + chips.
/// </summary>
public sealed class CodexCard : CodexWidget
{
public CodexWidget? Content { get; set; }
public bool IsSelected { get; set; }
public System.Action? OnClick { get; set; }
public Texture2D? CornerSigil { get; set; }
public string? CornerLetter { get; set; } // overlay glyph drawn on the sigil placeholder
private readonly CodexAtlas _atlas;
private bool _hovered;
private const int Pad = CodexDensity.CardPad;
public CodexCard(CodexAtlas atlas, CodexWidget? content = null, bool selected = false, System.Action? onClick = null)
{
_atlas = atlas;
Content = content;
if (content is not null) content.Parent = this;
IsSelected = selected;
OnClick = onClick;
}
protected override Point MeasureCore(Point available)
{
if (Content is null) return new Point(System.Math.Min(CodexDensity.CardWidth, available.X), 80);
var inner = new Point(available.X - Pad * 2, available.Y - Pad * 2);
var s = Content.Measure(inner);
return new Point(s.X + Pad * 2, s.Y + Pad * 2);
}
protected override void ArrangeCore(Rectangle bounds)
{
Content?.Arrange(new Rectangle(
bounds.X + Pad, bounds.Y + Pad,
bounds.Width - Pad * 2,
bounds.Height - Pad * 2));
}
public override void Update(GameTime gt, CodexInput input)
{
_hovered = ContainsPoint(input.MousePosition);
if (_hovered && input.LeftJustReleased) OnClick?.Invoke();
Content?.Update(gt, input);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
// Body fill — parchment shade
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg2);
// Subtle top-down lift overlay (2-px gradient strip) to match `linear-gradient(180deg, rgba(255,250,235,0.05), transparent 30%)`.
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 2), new Color(255, 250, 235, 18));
// Border colour by state.
Color border = IsSelected ? CodexColors.Seal : (_hovered ? CodexColors.Gild : CodexColors.Rule);
int thickness = IsSelected ? 2 : 1;
DrawBorder(sb, Bounds, border, thickness);
if (IsSelected)
{
// Inner glow strip — 1px inside the border.
DrawBorder(sb, new Rectangle(Bounds.X + thickness, Bounds.Y + thickness, Bounds.Width - thickness * 2, Bounds.Height - thickness * 2),
new Color(border.R, border.G, border.B, (byte)40), 1);
// Corner wax-seal accent
int sealSize = 28;
sb.Draw(_atlas.WaxSeal, new Rectangle(Bounds.Right - sealSize / 2 - 4, Bounds.Y - sealSize / 2 + 4, sealSize, sealSize), Color.White);
}
else if (_hovered)
{
sb.Draw(_atlas.Pixel, Bounds, new Color(CodexColors.Gild.R, CodexColors.Gild.G, CodexColors.Gild.B, (byte)10));
}
Content?.Draw(sb, gt);
}
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
{
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
}
}
/// <summary>
/// 7-step horizontal stepper at the top of the wizard. Each step exposes a
/// locked / active / complete state with the matching marker (✕ / Roman /
/// ✓). Clicking a non-locked step navigates there.
/// </summary>
public sealed class CodexStepper : CodexWidget
{
public string[] Names { get; }
public int Current { get; set; }
public bool[] Complete { get; set; }
public bool[] Locked { get; set; }
public System.Action<int>? OnPick { get; set; }
private readonly CodexAtlas _atlas;
private readonly SpriteFontBase _romanFont = CodexFonts.DisplayMedium;
private readonly SpriteFontBase _labelFont = CodexFonts.MonoTagSmall;
public CodexStepper(string[] names, CodexAtlas atlas)
{
Names = names;
Complete = new bool[names.Length];
Locked = new bool[names.Length];
_atlas = atlas;
}
protected override Point MeasureCore(Point available) => new(available.X, 64);
protected override void ArrangeCore(Rectangle bounds) { }
private static readonly string[] Roman = new[] { "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX" };
public override void Update(GameTime gt, CodexInput input)
{
if (!input.LeftJustReleased) return;
int colW = Bounds.Width / Names.Length;
for (int i = 0; i < Names.Length; i++)
{
var cell = new Rectangle(Bounds.X + i * colW, Bounds.Y, colW, Bounds.Height);
if (cell.Contains(input.MousePosition) && !Locked[i] && i != Current) { OnPick?.Invoke(i); return; }
}
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
// Opaque parchment background so the stepper masks any body
// scroll-overflow that drew under it.
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg);
// Top + bottom rule
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), CodexColors.Rule);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), CodexColors.Rule);
sb.Draw(_atlas.Pixel, Bounds, new Color(0, 0, 0, 6));
int colW = Bounds.Width / Names.Length;
for (int i = 0; i < Names.Length; i++)
{
var cell = new Rectangle(Bounds.X + i * colW, Bounds.Y, colW, Bounds.Height);
// Vertical separator between cells.
if (i < Names.Length - 1)
sb.Draw(_atlas.Pixel, new Rectangle(cell.Right - 1, cell.Y + 4, 1, cell.Height - 8),
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)128));
bool isCurrent = i == Current;
bool isComplete = Complete[i] && !isCurrent;
bool isLocked = Locked[i];
// Numeral / mark
string mark = isLocked ? "✕" : (isComplete ? "✓" : Roman[i]);
Color numColor = isLocked ? CodexColors.InkMute
: isComplete ? CodexColors.Seal
: isCurrent ? CodexColors.Ink
: CodexColors.InkMute;
var ms = _romanFont.MeasureString(mark);
float numX = cell.X + (cell.Width - ms.X) / 2f;
float numY = cell.Y + 12;
_romanFont.DrawText(sb, mark, new Vector2(numX, numY), numColor);
// Step name
var ls = _labelFont.MeasureString(Names[i]);
float lx = cell.X + (cell.Width - ls.X) / 2f;
float ly = cell.Y + cell.Height - _labelFont.LineHeight - 8;
Color labelColor = isLocked ? new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)115)
: isCurrent ? CodexColors.Ink : CodexColors.InkMute;
_labelFont.DrawText(sb, Names[i].ToUpperInvariant(), new Vector2(lx, ly), labelColor);
if (isCurrent)
sb.Draw(_atlas.Pixel, new Rectangle((int)(cell.X + cell.Width * 0.14f), cell.Bottom - 2, (int)(cell.Width * 0.72f), 2), CodexColors.Gild);
}
}
}
@@ -0,0 +1,124 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
public enum CheckboxState { Default, Checked, LockedFromBg, Unavailable }
/// <summary>
/// One row of the skill picker. Visual states mirror the React design:
/// - Default: small ink-mute checkbox, hover gilds
/// - Checked: seal-red filled checkbox + ✓
/// - LockedFromBg: gild-filled checkbox + ✓ (sealed by background, can't toggle)
/// - Unavailable: dashed underline, faded text — class doesn't offer it
/// Click toggles only when state is Default or Checked.
/// </summary>
public sealed class CodexCheckboxRow : CodexWidget
{
public string Label { get; set; }
public string SourceTag { get; set; }
public CheckboxState State { get; set; }
public System.Action? OnClick { get; set; }
public System.Action? OnHover { get; set; }
private readonly CodexAtlas _atlas;
private readonly SpriteFontBase _font = CodexFonts.SerifBody;
private readonly SpriteFontBase _tagFont = CodexFonts.MonoTagSmall;
private bool _hovered;
public CodexCheckboxRow(string label, string sourceTag, CheckboxState state, CodexAtlas atlas)
{
Label = label;
SourceTag = sourceTag;
State = state;
_atlas = atlas;
}
protected override Point MeasureCore(Point available) => new(available.X, 28);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
_hovered = ContainsPoint(input.MousePosition);
// OnHover fires every frame the cursor is over the row, not just
// on hover-enter. The popover is shown only while a trigger calls
// Show() each frame (CodexHoverPopover.IsShown decays in one tick
// when no trigger requests it), so a single transition-only call
// would flash the popover for one frame and then hide it.
if (_hovered) OnHover?.Invoke();
if (_hovered && input.LeftJustReleased)
{
if (State == CheckboxState.Default || State == CheckboxState.Checked) OnClick?.Invoke();
}
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
// Bottom rule
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1),
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)80));
// Checkbox
int boxSize = 18;
var box = new Rectangle(Bounds.X + 2, Bounds.Y + (Bounds.Height - boxSize) / 2, boxSize, boxSize);
Color boxFill, boxBorder, checkColor = CodexColors.Bg;
switch (State)
{
case CheckboxState.Checked:
boxFill = CodexColors.Seal;
boxBorder = CodexColors.Seal;
break;
case CheckboxState.LockedFromBg:
boxFill = CodexColors.Gild;
boxBorder = CodexColors.Gild;
break;
case CheckboxState.Unavailable:
boxFill = Color.Transparent;
boxBorder = new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)90);
break;
default:
boxFill = Color.Transparent;
boxBorder = _hovered ? CodexColors.Gild : CodexColors.InkMute;
break;
}
sb.Draw(_atlas.Pixel, box, boxFill);
DrawBorder(sb, box, boxBorder, 1);
if (State == CheckboxState.Checked || State == CheckboxState.LockedFromBg)
{
string mark = "✓";
var s = _font.MeasureString(mark);
_font.DrawText(sb, mark, new Vector2(box.X + (box.Width - s.X) / 2f, box.Y + (box.Height - _font.LineHeight) / 2f),
checkColor);
}
// Label text
Color labelColor = State switch
{
CheckboxState.Checked => CodexColors.Seal,
CheckboxState.LockedFromBg => CodexColors.Gild,
CheckboxState.Unavailable => new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)90),
_ => _hovered ? CodexColors.Gild : CodexColors.Ink,
};
_font.DrawText(sb, Label, new Vector2(box.Right + 8, Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f), labelColor);
// Right-aligned source tag
if (!string.IsNullOrEmpty(SourceTag))
{
var ts = _tagFont.MeasureString(SourceTag);
_tagFont.DrawText(sb, SourceTag.ToUpperInvariant(),
new Vector2(Bounds.Right - ts.X - 4, Bounds.Y + (Bounds.Height - _tagFont.LineHeight) / 2f),
CodexColors.InkMute);
}
}
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
{
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
}
}
@@ -0,0 +1,155 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
public enum ChipKind
{
Trait, // gild edge, italic serif, default for clade/species/feature traits
TraitDetriment,
SkillFromBg, // gild edge — sealed by background
SkillFromClass, // seal-red edge — picked from class options
Language, // mono-tag pill, ink border
BgFeature, // seal-red trait variant for the background card's feature row
}
/// <summary>
/// Pill-shaped chip used for traits, skills, languages, and background feature
/// names. Hovering surfaces a popover with the full description; clicking
/// fires <see cref="OnClick"/> for the few cases the screen needs (skill toggle).
/// </summary>
public sealed class CodexChip : CodexWidget
{
public string Text { get; set; }
public string PopoverTitle { get; set; }
public string PopoverBody { get; set; }
public string? PopoverTag { get; set; }
public ChipKind Kind { get; set; }
public System.Action? OnClick { get; set; }
private readonly CodexAtlas _atlas;
private readonly SpriteFontBase _font;
/// <summary>The screen sets this when the user hovers over us; the screen handles popover layout.</summary>
public bool IsHovered { get; private set; }
public CodexChip(string text, ChipKind kind, CodexAtlas atlas,
string popoverTitle = "", string popoverBody = "", string? popoverTag = null)
{
Text = text;
Kind = kind;
_atlas = atlas;
PopoverTitle = popoverTitle;
PopoverBody = popoverBody;
PopoverTag = popoverTag;
_font = kind == ChipKind.Language ? CodexFonts.MonoTagSmall : CodexFonts.SerifItalic;
}
protected override Point MeasureCore(Point available)
{
var s = _font.MeasureString(Text);
return new Point((int)s.X + CodexDensity.ChipPad * 3, (int)System.MathF.Ceiling(_font.LineHeight) + CodexDensity.ChipPad * 2);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
IsHovered = ContainsPoint(input.MousePosition);
if (IsHovered && input.LeftJustReleased) OnClick?.Invoke();
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
var (fill, border, text) = GetColors();
if (IsHovered) fill = HoverShift(fill);
sb.Draw(_atlas.Pixel, Bounds, fill);
// 1-px outline.
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(Text);
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
_font.DrawText(sb, Text, new Vector2(tx, ty), text);
}
private (Color fill, Color border, Color text) GetColors() => Kind switch
{
ChipKind.Trait => (Mix(CodexColors.Gild, CodexColors.Bg, 0.07f), Mix(CodexColors.Gild, CodexColors.Rule, 0.55f), CodexColors.Ink),
ChipKind.TraitDetriment => (Mix(CodexColors.Seal, CodexColors.Bg, 0.08f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Ink),
ChipKind.SkillFromBg => (Mix(CodexColors.Gild, CodexColors.Bg, 0.06f), Mix(CodexColors.Gild, CodexColors.Rule, 0.60f), CodexColors.Gild),
ChipKind.SkillFromClass => (Mix(CodexColors.Seal, CodexColors.Bg, 0.06f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Seal),
ChipKind.Language => (CodexColors.Bg, CodexColors.Rule, CodexColors.InkSoft),
ChipKind.BgFeature => (Mix(CodexColors.Seal, CodexColors.Bg, 0.08f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Seal),
_ => (CodexColors.Bg, CodexColors.Rule, CodexColors.Ink),
};
private static Color Mix(Color a, Color b, float t)
=> new(
(byte)(a.R * t + b.R * (1 - t)),
(byte)(a.G * t + b.G * (1 - t)),
(byte)(a.B * t + b.B * (1 - t)),
(byte)0xFF);
private static Color HoverShift(Color c)
=> new((byte)System.Math.Min(255, c.R + 14), (byte)System.Math.Min(255, c.G + 14), (byte)System.Math.Min(255, c.B + 14), c.A);
}
/// <summary>
/// Small +N / N pill that sits next to ability names. Visually a chip with
/// monospace text and seal-red (positive) or ink-mute (negative) chrome.
/// Hover surfaces a popover listing the contributing sources (clade, species).
/// </summary>
public sealed class CodexBonusPill : CodexWidget
{
public int Total { get; }
public string PopoverBody { get; set; } = "";
private readonly CodexAtlas _atlas;
private readonly SpriteFontBase _font;
public bool IsHovered { get; private set; }
public CodexBonusPill(int total, CodexAtlas atlas, string popoverBody = "")
{
Total = total;
_atlas = atlas;
PopoverBody = popoverBody;
_font = CodexFonts.MonoTag;
}
protected override Point MeasureCore(Point available)
{
string label = (Total >= 0 ? "+" : "") + Total.ToString();
var s = _font.MeasureString(label);
return new Point((int)s.X + 12, (int)System.MathF.Ceiling(_font.LineHeight) + 6);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input) => IsHovered = ContainsPoint(input.MousePosition);
public override void Draw(SpriteBatch sb, GameTime gt)
{
var border = Total >= 0 ? CodexColors.Seal : CodexColors.InkMute;
var fill = Total >= 0
? new Color(CodexColors.Seal.R, CodexColors.Seal.G, CodexColors.Seal.B, (byte)(IsHovered ? 36 : 18))
: new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)(IsHovered ? 28 : 14));
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);
string label = (Total >= 0 ? "+" : "") + Total.ToString();
var s = _font.MeasureString(label);
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
_font.DrawText(sb, label, new Vector2(tx, ty), border);
}
}
@@ -0,0 +1,166 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Single floating popover panel. The screen owns one instance; widgets
/// (chips, bonus pills) request it to show by calling <see cref="Show"/>
/// with their trigger bounds + content. Visibility decays automatically
/// when the trigger no longer reports as hovered. Position is clamped to
/// the viewport so popovers near the right/bottom edges flip to fit.
///
/// Mirrors the React design's <c>.trait-hint</c>: parchment fill, gilded
/// border, italic display title + tag pill, body paragraph in serif body
/// face. The "Plainly Reading" footnote is supported via <see cref="Reading"/>.
/// </summary>
public sealed class CodexHoverPopover : CodexWidget
{
private readonly CodexAtlas _atlas;
private string _title = "";
private string _body = "";
private string? _tag;
private string? _reading;
private bool _detriment;
private Rectangle _triggerBounds;
private bool _showRequestedThisFrame;
public bool IsShown { get; private set; }
public string? Reading { get => _reading; set => _reading = value; }
public CodexHoverPopover(CodexAtlas atlas)
{
_atlas = atlas;
}
/// <summary>
/// Request the popover. Called from a widget's Update when it detects
/// hover. The popover stays visible only as long as some widget requests
/// it each frame.
/// </summary>
public void Show(Rectangle triggerBounds, string title, string body, string? tag = null, bool detriment = false)
{
_triggerBounds = triggerBounds;
_title = title;
_body = body;
_tag = tag;
_detriment = detriment;
_showRequestedThisFrame = true;
}
protected override Point MeasureCore(Point available) => Point.Zero;
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
IsShown = _showRequestedThisFrame;
_showRequestedThisFrame = false;
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
if (!IsShown) return;
const int width = 320;
var titleFont = CodexFonts.SerifItalic;
var bodyFont = CodexFonts.SerifBody;
var tagFont = CodexFonts.MonoTagSmall;
// Wrap body text to the width.
var titleLines = CodexLabel.WrapText(_title, titleFont, width - 32);
var bodyLines = CodexLabel.WrapText(_body, bodyFont, width - 32);
var readingLines = string.IsNullOrEmpty(_reading) ? System.Array.Empty<string>() : CodexLabel.WrapText(_reading!, bodyFont, width - 32);
int height = 14
+ (int)(titleFont.LineHeight * titleLines.Length)
+ 6
+ (int)(bodyFont.LineHeight * bodyLines.Length)
+ (readingLines.Length > 0 ? 8 + (int)(bodyFont.LineHeight * readingLines.Length) + 4 : 0)
+ 12;
// Position — prefer below the trigger, flip above if it doesn't
// fit there, and as a last resort clamp to whichever edge gives
// more room. Earlier code clamped only with `if (y < 8) y = 8`,
// which would push the popover off the bottom whenever the
// trigger sat near the viewport's bottom edge and the popover
// didn't fit above either.
int x = _triggerBounds.X;
if (x + width > _viewport.Width) x = _viewport.Width - width - 8;
if (x < 8) x = 8;
int spaceBelow = _viewport.Height - _triggerBounds.Bottom - 6;
int spaceAbove = _triggerBounds.Y - 6;
int y;
if (height <= spaceBelow)
{
y = _triggerBounds.Bottom + 6;
}
else if (height <= spaceAbove)
{
y = _triggerBounds.Y - height - 6;
}
else
{
// Doesn't fit either side; clamp so the popover sits within
// the viewport with at least an 8-px margin on the limiting side.
y = System.Math.Max(8, _viewport.Height - height - 8);
}
var rect = new Rectangle(x, y, width, height);
// Background
sb.Draw(_atlas.Pixel, rect, CodexColors.Bg2);
Color border = _detriment ? CodexColors.Seal : CodexColors.Gild;
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);
int cy = rect.Y + 12;
// Title (+ optional tag)
foreach (var line in titleLines)
{
titleFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.Ink);
cy += (int)titleFont.LineHeight;
}
if (!string.IsNullOrEmpty(_tag))
{
var tagSize = tagFont.MeasureString(_tag);
int tagX = rect.X + 16;
int tagY = cy;
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, (int)tagFont.LineHeight + 4), Color.Transparent);
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, 1), CodexColors.Seal);
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY + (int)tagFont.LineHeight + 3, (int)tagSize.X + 12, 1), CodexColors.Seal);
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
sb.Draw(_atlas.Pixel, new Rectangle(tagX + (int)tagSize.X + 11, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
tagFont.DrawText(sb, _tag, new Vector2(tagX + 6, tagY + 2), CodexColors.Seal);
cy += (int)tagFont.LineHeight + 6;
}
else cy += 6;
// Body
foreach (var line in bodyLines)
{
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkSoft);
cy += (int)bodyFont.LineHeight;
}
if (readingLines.Length > 0)
{
cy += 4;
sb.Draw(_atlas.Pixel, new Rectangle(rect.X + 16, cy, rect.Width - 32, 1), new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)128));
cy += 6;
foreach (var line in readingLines)
{
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkMute);
cy += (int)bodyFont.LineHeight;
}
}
Reading = null; // consumed
}
private Rectangle _viewport = new(0, 0, 1280, 800);
public void UpdateViewport(Rectangle vp) => _viewport = vp;
}
@@ -0,0 +1,147 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Single- or multi-line text widget. Wraps to <see cref="MaxWidth"/> if set;
/// otherwise to its arrange width. Color and font are explicit so the same
/// widget can render the codex header (DisplayLarge / Ink), an eyebrow
/// (MonoTag / InkMute), or a body paragraph (SerifBody / InkSoft).
/// </summary>
public sealed class CodexLabel : CodexWidget
{
public string Text { get; set; } = "";
public SpriteFontBase Font { get; set; }
public Color Color { get; set; } = CodexColors.Ink;
public HAlign HAlign { get; set; } = HAlign.Left;
/// <summary>If set, text wraps when its measured width would exceed this.</summary>
public int? MaxWidth { get; set; }
public CodexLabel(string text, SpriteFontBase font, Color? color = null, HAlign hAlign = HAlign.Left)
{
Text = text;
Font = font;
if (color.HasValue) Color = color.Value;
HAlign = hAlign;
}
private string[] _wrappedLines = System.Array.Empty<string>();
protected override Point MeasureCore(Point available)
{
int wrapW = MaxWidth ?? available.X;
_wrappedLines = WrapText(Text, Font, wrapW);
int width = 0;
foreach (var line in _wrappedLines)
{
var s = Font.MeasureString(line);
if (s.X > width) width = (int)System.MathF.Ceiling(s.X);
}
int height = (int)System.MathF.Ceiling(Font.LineHeight * (_wrappedLines.Length == 0 ? 1 : _wrappedLines.Length));
return new Point(System.Math.Min(width, wrapW), height);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt)
{
float y = Bounds.Y;
foreach (var line in _wrappedLines)
{
var s = Font.MeasureString(line);
float x = HAlign switch
{
HAlign.Center => Bounds.X + (Bounds.Width - s.X) / 2f,
HAlign.Right => Bounds.X + Bounds.Width - s.X,
_ => Bounds.X,
};
Font.DrawText(sb, line, new Vector2(x, y), Color);
y += Font.LineHeight;
}
}
/// <summary>
/// Greedy word wrap. Splits on spaces, fits as many words as possible per
/// line, hard-breaks oversize words. Honours embedded \n.
/// </summary>
public static string[] WrapText(string text, SpriteFontBase font, int maxWidth)
{
if (string.IsNullOrEmpty(text)) return new[] { "" };
var lines = new System.Collections.Generic.List<string>();
foreach (var paragraph in text.Split('\n'))
{
var words = paragraph.Split(' ');
var current = new System.Text.StringBuilder();
foreach (var word in words)
{
string trial = current.Length == 0 ? word : current + " " + word;
if (font.MeasureString(trial).X > maxWidth && current.Length > 0)
{
lines.Add(current.ToString());
current.Clear();
current.Append(word);
}
else
{
current.Clear();
current.Append(trial);
}
}
lines.Add(current.ToString());
}
return lines.ToArray();
}
}
/// <summary>
/// Decorative horizontal rule with a small central diamond glyph. Mirrors
/// the React design's <c>.divider</c> / <c>.clade-group-label::after</c>
/// hairline + ornament pattern.
/// </summary>
public sealed class CodexOrnamentRule : CodexWidget
{
public Color RuleColor { get; set; } = CodexColors.Rule;
public string? Label { get; set; }
public SpriteFontBase Font { get; set; }
private readonly CodexAtlas _atlas;
public CodexOrnamentRule(CodexAtlas atlas, SpriteFontBase font, string? label = null)
{
_atlas = atlas;
Font = font;
Label = label;
}
protected override Point MeasureCore(Point available) => new(available.X, 16);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt)
{
int midY = Bounds.Y + Bounds.Height / 2;
if (!string.IsNullOrEmpty(Label))
{
var s = Font.MeasureString(Label);
int padX = 12;
int textX = Bounds.X + 0;
// Left rule before the text.
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, 16, 1), RuleColor);
int textXStart = Bounds.X + 16 + padX / 2;
Font.DrawText(sb, Label, new Vector2(textXStart, midY - Font.LineHeight / 2f), CodexColors.InkMute);
int afterText = textXStart + (int)s.X + padX / 2;
sb.Draw(_atlas.Pixel, new Rectangle(afterText, midY, Bounds.Right - afterText, 1), RuleColor);
}
else
{
int half = (Bounds.Width - 16) / 2;
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, half, 1), RuleColor);
sb.Draw(_atlas.OrnamentDiamond, new Rectangle(Bounds.X + half, midY - 8, 16, 16), CodexColors.Gild);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X + half + 16, midY, half, 1), RuleColor);
}
}
}
@@ -0,0 +1,61 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Single-child container that paints a parchment fill and a 1-px ink rule
/// border. The review-step summary blocks and the aside container both use
/// this; for borderless wrappers (e.g. the page's main column) just nest in
/// a <see cref="Column"/> instead.
/// </summary>
public sealed class CodexPanel : CodexWidget
{
public CodexWidget? Child { get; set; }
public Color BackgroundColor { get; set; } = CodexColors.Bg2;
public Color BorderColor { get; set; } = CodexColors.Rule;
public bool Bordered { get; set; } = true;
public Thickness Inset { get; set; } = new(18, 18, 20, 18);
private readonly CodexAtlas _atlas;
public CodexPanel(CodexAtlas atlas, CodexWidget? child = null)
{
_atlas = atlas;
Child = child;
if (child is not null) child.Parent = this;
}
protected override Point MeasureCore(Point available)
{
if (Child is null) return new Point(Inset.HorizontalSum(), Inset.VerticalSum());
var inner = new Point(available.X - Inset.HorizontalSum(), available.Y - Inset.VerticalSum());
var s = Child.Measure(inner);
return new Point(s.X + Inset.HorizontalSum(), s.Y + Inset.VerticalSum());
}
protected override void ArrangeCore(Rectangle bounds)
{
Child?.Arrange(new Rectangle(
bounds.X + Inset.Left,
bounds.Y + Inset.Top,
bounds.Width - Inset.HorizontalSum(),
bounds.Height - Inset.VerticalSum()));
}
public override void Update(GameTime gt, CodexInput input) => Child?.Update(gt, input);
public override void Draw(SpriteBatch sb, GameTime gt)
{
sb.Draw(_atlas.Pixel, Bounds, BackgroundColor);
if (Bordered)
{
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), BorderColor);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), BorderColor);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, 1, Bounds.Height), BorderColor);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.Right - 1, Bounds.Y, 1, Bounds.Height), BorderColor);
}
Child?.Draw(sb, gt);
}
}
@@ -0,0 +1,89 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Single-line text input. The display matches the React design's
/// <c>input[type=text]</c>: serif-display font, transparent background,
/// gilded underline rule that lights up while focused. Used for the name
/// field in the Sign step.
///
/// Receives characters via <see cref="CodexInput.TextEnteredThisFrame"/>;
/// the parent screen subscribes to <c>Window.TextInput</c> in Initialize
/// and routes them through <see cref="CodexInput.OnTextInput"/>. Backspace,
/// Enter and arrow keys are handled in <see cref="Update"/>.
/// </summary>
public sealed class CodexTextBox : CodexWidget
{
public string Text { get; set; } = "";
public string Placeholder { get; set; } = "";
public bool IsFocused { get; set; }
public int? FixedWidth { get; set; }
public System.Action<string>? OnChanged { get; set; }
private readonly CodexAtlas _atlas;
private readonly SpriteFontBase _font;
private float _caretBlink;
public CodexTextBox(string initial, CodexAtlas atlas, int? fixedWidth = null, System.Action<string>? onChanged = null)
{
Text = initial;
_atlas = atlas;
FixedWidth = fixedWidth;
OnChanged = onChanged;
_font = CodexFonts.DisplayMedium;
}
protected override Point MeasureCore(Point available)
{
int w = FixedWidth ?? System.Math.Min(480, available.X);
int h = (int)System.MathF.Ceiling(_font.LineHeight) + 14;
return new Point(w, h);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
if (input.LeftJustPressed) IsFocused = ContainsPoint(input.MousePosition);
_caretBlink = (_caretBlink + (float)gt.ElapsedGameTime.TotalSeconds) % 1f;
if (!IsFocused) return;
bool changed = false;
if (!string.IsNullOrEmpty(input.TextEnteredThisFrame))
{
Text += input.TextEnteredThisFrame;
changed = true;
}
if (input.KeyJustPressed(Keys.Back) && Text.Length > 0)
{
Text = Text.Substring(0, Text.Length - 1);
changed = true;
}
if (input.KeyJustPressed(Keys.Enter)) IsFocused = false;
if (changed) OnChanged?.Invoke(Text);
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
// Background — none (transparent) per design; we just paint the underline.
Color underline = IsFocused ? CodexColors.Gild : CodexColors.Rule;
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 2, Bounds.Width, 2), underline);
bool empty = string.IsNullOrEmpty(Text);
string display = empty ? Placeholder : Text;
Color textColor = empty ? CodexColors.InkMute : CodexColors.Ink;
_font.DrawText(sb, display, new Vector2(Bounds.X + 4, Bounds.Y + 6), textColor);
// Caret blink — top-aligned, follows the end of the text.
if (IsFocused && _caretBlink < 0.5f)
{
float caretX = Bounds.X + 4 + _font.MeasureString(Text).X;
sb.Draw(_atlas.Pixel, new Rectangle((int)caretX, Bounds.Y + 6, 1, (int)_font.LineHeight), CodexColors.Gild);
}
}
}
@@ -0,0 +1,111 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Vertical scroll container. Measures its child at unbounded height to
/// learn the full content size, then arranges the child shifted by
/// <see cref="ScrollOffset"/>. Mouse-wheel input changes the offset; bounds
/// hit-testing for hover/click still uses screen-space, so widgets that
/// scroll out of view simply stop receiving cursor events.
///
/// Drawing is uncapped — child widgets draw in their offset positions, so
/// content above/below the visible band can spill into adjacent regions.
/// The screen's stepper and nav bar are painted with opaque backgrounds
/// to mask this overflow, which is cheaper than scissor clipping and avoids
/// the SpriteBatch end/restart dance.
/// </summary>
public sealed class ScrollPanel : CodexWidget
{
public CodexWidget? Child { get; set; }
public int ScrollOffset { get; private set; }
private int _contentHeight;
private readonly CodexAtlas _atlas;
/// <summary>
/// Fires whenever the wheel changes the scroll offset. The wizard
/// uses this to persist offset across <c>InvalidateLayout</c>: the
/// rebuilt tree creates a new <see cref="ScrollPanel"/>, but the
/// stored value gets re-applied via <see cref="SetInitialScroll"/>.
/// </summary>
public System.Action<int>? OnScrollChanged { get; set; }
public ScrollPanel(CodexAtlas atlas, CodexWidget? child = null)
{
_atlas = atlas;
Child = child;
if (child is not null) child.Parent = this;
}
/// <summary>Restore a saved offset before the first measure-arrange pass runs.</summary>
public void SetInitialScroll(int offset) => ScrollOffset = offset;
protected override Point MeasureCore(Point available)
{
if (Child is null)
{
_contentHeight = 0;
return new Point(available.X, available.Y);
}
var s = Child.Measure(new Point(System.Math.Max(0, available.X - 8), int.MaxValue / 2));
_contentHeight = s.Y;
return new Point(available.X, available.Y);
}
protected override void ArrangeCore(Rectangle bounds)
{
ClampScroll();
Child?.Arrange(new Rectangle(bounds.X, bounds.Y - ScrollOffset,
System.Math.Max(0, bounds.Width - 8), _contentHeight));
}
public override void Update(GameTime gt, CodexInput input)
{
if (Bounds.Contains(input.MousePosition) && input.ScrollDelta != 0)
{
ScrollOffset -= input.ScrollDelta / 2;
ClampScroll();
Child?.Arrange(new Rectangle(Bounds.X, Bounds.Y - ScrollOffset,
System.Math.Max(0, Bounds.Width - 8), _contentHeight));
OnScrollChanged?.Invoke(ScrollOffset);
}
// Nest a clip into the visible viewport so children scrolled out
// of view don't register hover/click. Intersect with any outer
// clip the parent already set so we never widen its scope.
var prevClip = input.GetMouseClip();
var newClip = prevClip is Rectangle p ? Rectangle.Intersect(p, Bounds) : Bounds;
input.SetMouseClip(newClip);
Child?.Update(gt, input);
if (prevClip is Rectangle r) input.SetMouseClip(r);
else input.ClearMouseClip();
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
Child?.Draw(sb, gt);
// Scrollbar thumb on the right edge — only when content overflows.
if (_contentHeight > Bounds.Height)
{
int trackX = Bounds.Right - 4;
int trackH = Bounds.Height;
sb.Draw(_atlas.Pixel, new Rectangle(trackX, Bounds.Y, 2, trackH),
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)80));
int thumbH = System.Math.Max(24, (int)((float)trackH * trackH / _contentHeight));
float t = (float)ScrollOffset / System.Math.Max(1, _contentHeight - trackH);
int thumbY = Bounds.Y + (int)((trackH - thumbH) * t);
sb.Draw(_atlas.Pixel, new Rectangle(trackX, thumbY, 2, thumbH), CodexColors.Gild);
}
}
private void ClampScroll()
{
int max = System.Math.Max(0, _contentHeight - Bounds.Height);
if (ScrollOffset < 0) ScrollOffset = 0;
if (ScrollOffset > max) ScrollOffset = max;
}
}