Initial commit: Theriapolis baseline at port/godot branch point

Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,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;
}
}