Files
TheriapolisV3/Theriapolis.Game/CodexUI/Steps/StepStats.cs
T

270 lines
11 KiB
C#
Raw Normal View History

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