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; /// /// 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. /// 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? 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); } } } /// /// 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. /// public sealed class CodexPoolDie : CodexWidget { public int Value { get; } public int IndexInPool { get; set; } public bool IsSelected { get; set; } public System.Action? 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); } }