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);
}
}