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; /// /// 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. /// 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(); 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); } } /// /// 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. /// internal sealed class PoolBox : CodexWidget { private readonly CodexCharacterCreationScreen _s; private readonly CodexAtlas _atlas; private readonly DragDropController _drag; private readonly System.Collections.Generic.List _dice = new(); private readonly System.Collections.Generic.List _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); } }