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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user