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