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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
public enum CodexButtonVariant { Primary, Ghost, Small }
|
||||
|
||||
/// <summary>
|
||||
/// Codex-styled push button. The three variants match the React design's
|
||||
/// <c>.btn.primary / .btn.ghost / .btn.small</c>:
|
||||
/// - Primary: gilded fill on parchment, used for Confirm + Next-style actions.
|
||||
/// - Ghost: ink border, transparent fill, used for Back + secondary actions.
|
||||
/// - Small: smaller padding/font, used inline (Reroll / Auto-assign / Clear).
|
||||
/// </summary>
|
||||
public sealed class CodexButton : CodexWidget
|
||||
{
|
||||
public string Text { get; set; }
|
||||
public CodexButtonVariant Variant { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
public int? FixedWidth { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
private bool _hovered;
|
||||
private bool _pressed;
|
||||
|
||||
public CodexButton(string text, CodexAtlas atlas, CodexButtonVariant variant = CodexButtonVariant.Ghost,
|
||||
System.Action? onClick = null, int? fixedWidth = null)
|
||||
{
|
||||
Text = text;
|
||||
_atlas = atlas;
|
||||
Variant = variant;
|
||||
OnClick = onClick;
|
||||
FixedWidth = fixedWidth;
|
||||
_font = variant == CodexButtonVariant.Small ? CodexFonts.MonoTag : CodexFonts.DisplaySmall;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var s = _font.MeasureString(Text);
|
||||
int padX = Variant == CodexButtonVariant.Small ? 12 : 22;
|
||||
int padY = Variant == CodexButtonVariant.Small ? 6 : 10;
|
||||
int w = FixedWidth ?? ((int)s.X + padX * 2);
|
||||
int h = (int)System.MathF.Ceiling(_font.LineHeight) + padY * 2;
|
||||
return new Point(System.Math.Min(w, available.X), h);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
bool wasHovered = _hovered;
|
||||
_hovered = ContainsPoint(input.MousePosition);
|
||||
if (_hovered && input.LeftJustPressed) _pressed = true;
|
||||
if (input.LeftJustReleased)
|
||||
{
|
||||
if (_pressed && _hovered && Enabled) OnClick?.Invoke();
|
||||
_pressed = false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
Color fill, border, textColor;
|
||||
switch (Variant)
|
||||
{
|
||||
case CodexButtonVariant.Primary:
|
||||
fill = _hovered ? CodexColors.Seal2 : CodexColors.Seal;
|
||||
border = CodexColors.Seal2;
|
||||
textColor = CodexColors.Bg;
|
||||
break;
|
||||
case CodexButtonVariant.Ghost:
|
||||
fill = _hovered ? CodexColors.Ink : CodexColors.Bg;
|
||||
border = CodexColors.Ink;
|
||||
textColor = _hovered ? CodexColors.Bg : CodexColors.Ink;
|
||||
break;
|
||||
default:
|
||||
fill = _hovered ? CodexColors.Bg2 : CodexColors.Bg;
|
||||
border = CodexColors.Rule;
|
||||
textColor = CodexColors.InkSoft;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
// 40% opacity per .btn[disabled]
|
||||
fill = new Color(fill.R, fill.G, fill.B, (byte)(fill.A * 0.4f));
|
||||
textColor = new Color(textColor.R, textColor.G, textColor.B, (byte)(textColor.A * 0.6f));
|
||||
}
|
||||
|
||||
// Body fill
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
// 1-px border outline
|
||||
DrawBorder(sb, Bounds, border, 1);
|
||||
|
||||
var s = _font.MeasureString(Text);
|
||||
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
|
||||
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
|
||||
_font.DrawText(sb, Text, new Vector2(tx, ty), textColor);
|
||||
}
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Selectable card. The clade / species / class / background pickers each
|
||||
/// render a grid of these. Visual states track the React design:
|
||||
/// - Default: rule-coloured 1-px border, parchment fill.
|
||||
/// - Hover: gilded border, subtle gild halo overlay.
|
||||
/// - Selected: seal-red border + inner glow + corner wax-seal accent.
|
||||
///
|
||||
/// Click toggles selection by calling <see cref="OnClick"/>; the parent step
|
||||
/// owns the "what is selected" state and rebuilds. The card's content tree
|
||||
/// is whatever <see cref="Content"/> child is supplied — typically a small
|
||||
/// Column with name + meta + chips.
|
||||
/// </summary>
|
||||
public sealed class CodexCard : CodexWidget
|
||||
{
|
||||
public CodexWidget? Content { get; set; }
|
||||
public bool IsSelected { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
public Texture2D? CornerSigil { get; set; }
|
||||
public string? CornerLetter { get; set; } // overlay glyph drawn on the sigil placeholder
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private bool _hovered;
|
||||
private const int Pad = CodexDensity.CardPad;
|
||||
|
||||
public CodexCard(CodexAtlas atlas, CodexWidget? content = null, bool selected = false, System.Action? onClick = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Content = content;
|
||||
if (content is not null) content.Parent = this;
|
||||
IsSelected = selected;
|
||||
OnClick = onClick;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Content is null) return new Point(System.Math.Min(CodexDensity.CardWidth, available.X), 80);
|
||||
var inner = new Point(available.X - Pad * 2, available.Y - Pad * 2);
|
||||
var s = Content.Measure(inner);
|
||||
return new Point(s.X + Pad * 2, s.Y + Pad * 2);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
Content?.Arrange(new Rectangle(
|
||||
bounds.X + Pad, bounds.Y + Pad,
|
||||
bounds.Width - Pad * 2,
|
||||
bounds.Height - Pad * 2));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
_hovered = ContainsPoint(input.MousePosition);
|
||||
if (_hovered && input.LeftJustReleased) OnClick?.Invoke();
|
||||
Content?.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Body fill — parchment shade
|
||||
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg2);
|
||||
// Subtle top-down lift overlay (2-px gradient strip) to match `linear-gradient(180deg, rgba(255,250,235,0.05), transparent 30%)`.
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 2), new Color(255, 250, 235, 18));
|
||||
|
||||
// Border colour by state.
|
||||
Color border = IsSelected ? CodexColors.Seal : (_hovered ? CodexColors.Gild : CodexColors.Rule);
|
||||
int thickness = IsSelected ? 2 : 1;
|
||||
DrawBorder(sb, Bounds, border, thickness);
|
||||
|
||||
if (IsSelected)
|
||||
{
|
||||
// Inner glow strip — 1px inside the border.
|
||||
DrawBorder(sb, new Rectangle(Bounds.X + thickness, Bounds.Y + thickness, Bounds.Width - thickness * 2, Bounds.Height - thickness * 2),
|
||||
new Color(border.R, border.G, border.B, (byte)40), 1);
|
||||
// Corner wax-seal accent
|
||||
int sealSize = 28;
|
||||
sb.Draw(_atlas.WaxSeal, new Rectangle(Bounds.Right - sealSize / 2 - 4, Bounds.Y - sealSize / 2 + 4, sealSize, sealSize), Color.White);
|
||||
}
|
||||
else if (_hovered)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, Bounds, new Color(CodexColors.Gild.R, CodexColors.Gild.G, CodexColors.Gild.B, (byte)10));
|
||||
}
|
||||
|
||||
Content?.Draw(sb, gt);
|
||||
}
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 7-step horizontal stepper at the top of the wizard. Each step exposes a
|
||||
/// locked / active / complete state with the matching marker (✕ / Roman /
|
||||
/// ✓). Clicking a non-locked step navigates there.
|
||||
/// </summary>
|
||||
public sealed class CodexStepper : CodexWidget
|
||||
{
|
||||
public string[] Names { get; }
|
||||
public int Current { get; set; }
|
||||
public bool[] Complete { get; set; }
|
||||
public bool[] Locked { get; set; }
|
||||
public System.Action<int>? OnPick { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _romanFont = CodexFonts.DisplayMedium;
|
||||
private readonly SpriteFontBase _labelFont = CodexFonts.MonoTagSmall;
|
||||
|
||||
public CodexStepper(string[] names, CodexAtlas atlas)
|
||||
{
|
||||
Names = names;
|
||||
Complete = new bool[names.Length];
|
||||
Locked = new bool[names.Length];
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 64);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
private static readonly string[] Roman = new[] { "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX" };
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (!input.LeftJustReleased) return;
|
||||
int colW = Bounds.Width / Names.Length;
|
||||
for (int i = 0; i < Names.Length; i++)
|
||||
{
|
||||
var cell = new Rectangle(Bounds.X + i * colW, Bounds.Y, colW, Bounds.Height);
|
||||
if (cell.Contains(input.MousePosition) && !Locked[i] && i != Current) { OnPick?.Invoke(i); return; }
|
||||
}
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Opaque parchment background so the stepper masks any body
|
||||
// scroll-overflow that drew under it.
|
||||
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg);
|
||||
// Top + bottom rule
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), CodexColors.Rule);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), CodexColors.Rule);
|
||||
sb.Draw(_atlas.Pixel, Bounds, new Color(0, 0, 0, 6));
|
||||
|
||||
int colW = Bounds.Width / Names.Length;
|
||||
for (int i = 0; i < Names.Length; i++)
|
||||
{
|
||||
var cell = new Rectangle(Bounds.X + i * colW, Bounds.Y, colW, Bounds.Height);
|
||||
// Vertical separator between cells.
|
||||
if (i < Names.Length - 1)
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(cell.Right - 1, cell.Y + 4, 1, cell.Height - 8),
|
||||
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)128));
|
||||
|
||||
bool isCurrent = i == Current;
|
||||
bool isComplete = Complete[i] && !isCurrent;
|
||||
bool isLocked = Locked[i];
|
||||
// Numeral / mark
|
||||
string mark = isLocked ? "✕" : (isComplete ? "✓" : Roman[i]);
|
||||
Color numColor = isLocked ? CodexColors.InkMute
|
||||
: isComplete ? CodexColors.Seal
|
||||
: isCurrent ? CodexColors.Ink
|
||||
: CodexColors.InkMute;
|
||||
|
||||
var ms = _romanFont.MeasureString(mark);
|
||||
float numX = cell.X + (cell.Width - ms.X) / 2f;
|
||||
float numY = cell.Y + 12;
|
||||
_romanFont.DrawText(sb, mark, new Vector2(numX, numY), numColor);
|
||||
|
||||
// Step name
|
||||
var ls = _labelFont.MeasureString(Names[i]);
|
||||
float lx = cell.X + (cell.Width - ls.X) / 2f;
|
||||
float ly = cell.Y + cell.Height - _labelFont.LineHeight - 8;
|
||||
Color labelColor = isLocked ? new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)115)
|
||||
: isCurrent ? CodexColors.Ink : CodexColors.InkMute;
|
||||
_labelFont.DrawText(sb, Names[i].ToUpperInvariant(), new Vector2(lx, ly), labelColor);
|
||||
|
||||
if (isCurrent)
|
||||
sb.Draw(_atlas.Pixel, new Rectangle((int)(cell.X + cell.Width * 0.14f), cell.Bottom - 2, (int)(cell.Width * 0.72f), 2), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
public enum CheckboxState { Default, Checked, LockedFromBg, Unavailable }
|
||||
|
||||
/// <summary>
|
||||
/// One row of the skill picker. Visual states mirror the React design:
|
||||
/// - Default: small ink-mute checkbox, hover gilds
|
||||
/// - Checked: seal-red filled checkbox + ✓
|
||||
/// - LockedFromBg: gild-filled checkbox + ✓ (sealed by background, can't toggle)
|
||||
/// - Unavailable: dashed underline, faded text — class doesn't offer it
|
||||
/// Click toggles only when state is Default or Checked.
|
||||
/// </summary>
|
||||
public sealed class CodexCheckboxRow : CodexWidget
|
||||
{
|
||||
public string Label { get; set; }
|
||||
public string SourceTag { get; set; }
|
||||
public CheckboxState State { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
public System.Action? OnHover { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font = CodexFonts.SerifBody;
|
||||
private readonly SpriteFontBase _tagFont = CodexFonts.MonoTagSmall;
|
||||
private bool _hovered;
|
||||
|
||||
public CodexCheckboxRow(string label, string sourceTag, CheckboxState state, CodexAtlas atlas)
|
||||
{
|
||||
Label = label;
|
||||
SourceTag = sourceTag;
|
||||
State = state;
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 28);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
_hovered = ContainsPoint(input.MousePosition);
|
||||
// OnHover fires every frame the cursor is over the row, not just
|
||||
// on hover-enter. The popover is shown only while a trigger calls
|
||||
// Show() each frame (CodexHoverPopover.IsShown decays in one tick
|
||||
// when no trigger requests it), so a single transition-only call
|
||||
// would flash the popover for one frame and then hide it.
|
||||
if (_hovered) OnHover?.Invoke();
|
||||
if (_hovered && input.LeftJustReleased)
|
||||
{
|
||||
if (State == CheckboxState.Default || State == CheckboxState.Checked) OnClick?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
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)80));
|
||||
|
||||
// Checkbox
|
||||
int boxSize = 18;
|
||||
var box = new Rectangle(Bounds.X + 2, Bounds.Y + (Bounds.Height - boxSize) / 2, boxSize, boxSize);
|
||||
Color boxFill, boxBorder, checkColor = CodexColors.Bg;
|
||||
switch (State)
|
||||
{
|
||||
case CheckboxState.Checked:
|
||||
boxFill = CodexColors.Seal;
|
||||
boxBorder = CodexColors.Seal;
|
||||
break;
|
||||
case CheckboxState.LockedFromBg:
|
||||
boxFill = CodexColors.Gild;
|
||||
boxBorder = CodexColors.Gild;
|
||||
break;
|
||||
case CheckboxState.Unavailable:
|
||||
boxFill = Color.Transparent;
|
||||
boxBorder = new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)90);
|
||||
break;
|
||||
default:
|
||||
boxFill = Color.Transparent;
|
||||
boxBorder = _hovered ? CodexColors.Gild : CodexColors.InkMute;
|
||||
break;
|
||||
}
|
||||
sb.Draw(_atlas.Pixel, box, boxFill);
|
||||
DrawBorder(sb, box, boxBorder, 1);
|
||||
|
||||
if (State == CheckboxState.Checked || State == CheckboxState.LockedFromBg)
|
||||
{
|
||||
string mark = "✓";
|
||||
var s = _font.MeasureString(mark);
|
||||
_font.DrawText(sb, mark, new Vector2(box.X + (box.Width - s.X) / 2f, box.Y + (box.Height - _font.LineHeight) / 2f),
|
||||
checkColor);
|
||||
}
|
||||
|
||||
// Label text
|
||||
Color labelColor = State switch
|
||||
{
|
||||
CheckboxState.Checked => CodexColors.Seal,
|
||||
CheckboxState.LockedFromBg => CodexColors.Gild,
|
||||
CheckboxState.Unavailable => new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)90),
|
||||
_ => _hovered ? CodexColors.Gild : CodexColors.Ink,
|
||||
};
|
||||
_font.DrawText(sb, Label, new Vector2(box.Right + 8, Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f), labelColor);
|
||||
|
||||
// Right-aligned source tag
|
||||
if (!string.IsNullOrEmpty(SourceTag))
|
||||
{
|
||||
var ts = _tagFont.MeasureString(SourceTag);
|
||||
_tagFont.DrawText(sb, SourceTag.ToUpperInvariant(),
|
||||
new Vector2(Bounds.Right - ts.X - 4, Bounds.Y + (Bounds.Height - _tagFont.LineHeight) / 2f),
|
||||
CodexColors.InkMute);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
public enum ChipKind
|
||||
{
|
||||
Trait, // gild edge, italic serif, default for clade/species/feature traits
|
||||
TraitDetriment,
|
||||
SkillFromBg, // gild edge — sealed by background
|
||||
SkillFromClass, // seal-red edge — picked from class options
|
||||
Language, // mono-tag pill, ink border
|
||||
BgFeature, // seal-red trait variant for the background card's feature row
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pill-shaped chip used for traits, skills, languages, and background feature
|
||||
/// names. Hovering surfaces a popover with the full description; clicking
|
||||
/// fires <see cref="OnClick"/> for the few cases the screen needs (skill toggle).
|
||||
/// </summary>
|
||||
public sealed class CodexChip : CodexWidget
|
||||
{
|
||||
public string Text { get; set; }
|
||||
public string PopoverTitle { get; set; }
|
||||
public string PopoverBody { get; set; }
|
||||
public string? PopoverTag { get; set; }
|
||||
public ChipKind Kind { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
|
||||
/// <summary>The screen sets this when the user hovers over us; the screen handles popover layout.</summary>
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
public CodexChip(string text, ChipKind kind, CodexAtlas atlas,
|
||||
string popoverTitle = "", string popoverBody = "", string? popoverTag = null)
|
||||
{
|
||||
Text = text;
|
||||
Kind = kind;
|
||||
_atlas = atlas;
|
||||
PopoverTitle = popoverTitle;
|
||||
PopoverBody = popoverBody;
|
||||
PopoverTag = popoverTag;
|
||||
_font = kind == ChipKind.Language ? CodexFonts.MonoTagSmall : CodexFonts.SerifItalic;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var s = _font.MeasureString(Text);
|
||||
return new Point((int)s.X + CodexDensity.ChipPad * 3, (int)System.MathF.Ceiling(_font.LineHeight) + CodexDensity.ChipPad * 2);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
IsHovered = ContainsPoint(input.MousePosition);
|
||||
if (IsHovered && input.LeftJustReleased) OnClick?.Invoke();
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
var (fill, border, text) = GetColors();
|
||||
if (IsHovered) fill = HoverShift(fill);
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
|
||||
// 1-px outline.
|
||||
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);
|
||||
|
||||
var s = _font.MeasureString(Text);
|
||||
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
|
||||
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
|
||||
_font.DrawText(sb, Text, new Vector2(tx, ty), text);
|
||||
}
|
||||
|
||||
private (Color fill, Color border, Color text) GetColors() => Kind switch
|
||||
{
|
||||
ChipKind.Trait => (Mix(CodexColors.Gild, CodexColors.Bg, 0.07f), Mix(CodexColors.Gild, CodexColors.Rule, 0.55f), CodexColors.Ink),
|
||||
ChipKind.TraitDetriment => (Mix(CodexColors.Seal, CodexColors.Bg, 0.08f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Ink),
|
||||
ChipKind.SkillFromBg => (Mix(CodexColors.Gild, CodexColors.Bg, 0.06f), Mix(CodexColors.Gild, CodexColors.Rule, 0.60f), CodexColors.Gild),
|
||||
ChipKind.SkillFromClass => (Mix(CodexColors.Seal, CodexColors.Bg, 0.06f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Seal),
|
||||
ChipKind.Language => (CodexColors.Bg, CodexColors.Rule, CodexColors.InkSoft),
|
||||
ChipKind.BgFeature => (Mix(CodexColors.Seal, CodexColors.Bg, 0.08f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Seal),
|
||||
_ => (CodexColors.Bg, CodexColors.Rule, CodexColors.Ink),
|
||||
};
|
||||
|
||||
private static Color Mix(Color a, Color b, float t)
|
||||
=> new(
|
||||
(byte)(a.R * t + b.R * (1 - t)),
|
||||
(byte)(a.G * t + b.G * (1 - t)),
|
||||
(byte)(a.B * t + b.B * (1 - t)),
|
||||
(byte)0xFF);
|
||||
|
||||
private static Color HoverShift(Color c)
|
||||
=> new((byte)System.Math.Min(255, c.R + 14), (byte)System.Math.Min(255, c.G + 14), (byte)System.Math.Min(255, c.B + 14), c.A);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Small +N / −N pill that sits next to ability names. Visually a chip with
|
||||
/// monospace text and seal-red (positive) or ink-mute (negative) chrome.
|
||||
/// Hover surfaces a popover listing the contributing sources (clade, species).
|
||||
/// </summary>
|
||||
public sealed class CodexBonusPill : CodexWidget
|
||||
{
|
||||
public int Total { get; }
|
||||
public string PopoverBody { get; set; } = "";
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
public CodexBonusPill(int total, CodexAtlas atlas, string popoverBody = "")
|
||||
{
|
||||
Total = total;
|
||||
_atlas = atlas;
|
||||
PopoverBody = popoverBody;
|
||||
_font = CodexFonts.MonoTag;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
string label = (Total >= 0 ? "+" : "") + Total.ToString();
|
||||
var s = _font.MeasureString(label);
|
||||
return new Point((int)s.X + 12, (int)System.MathF.Ceiling(_font.LineHeight) + 6);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input) => IsHovered = ContainsPoint(input.MousePosition);
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
var border = Total >= 0 ? CodexColors.Seal : CodexColors.InkMute;
|
||||
var fill = Total >= 0
|
||||
? new Color(CodexColors.Seal.R, CodexColors.Seal.G, CodexColors.Seal.B, (byte)(IsHovered ? 36 : 18))
|
||||
: new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)(IsHovered ? 28 : 14));
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
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 = (Total >= 0 ? "+" : "") + Total.ToString();
|
||||
var s = _font.MeasureString(label);
|
||||
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
|
||||
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
|
||||
_font.DrawText(sb, label, new Vector2(tx, ty), border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single floating popover panel. The screen owns one instance; widgets
|
||||
/// (chips, bonus pills) request it to show by calling <see cref="Show"/>
|
||||
/// with their trigger bounds + content. Visibility decays automatically
|
||||
/// when the trigger no longer reports as hovered. Position is clamped to
|
||||
/// the viewport so popovers near the right/bottom edges flip to fit.
|
||||
///
|
||||
/// Mirrors the React design's <c>.trait-hint</c>: parchment fill, gilded
|
||||
/// border, italic display title + tag pill, body paragraph in serif body
|
||||
/// face. The "Plainly Reading" footnote is supported via <see cref="Reading"/>.
|
||||
/// </summary>
|
||||
public sealed class CodexHoverPopover : CodexWidget
|
||||
{
|
||||
private readonly CodexAtlas _atlas;
|
||||
private string _title = "";
|
||||
private string _body = "";
|
||||
private string? _tag;
|
||||
private string? _reading;
|
||||
private bool _detriment;
|
||||
private Rectangle _triggerBounds;
|
||||
private bool _showRequestedThisFrame;
|
||||
public bool IsShown { get; private set; }
|
||||
public string? Reading { get => _reading; set => _reading = value; }
|
||||
|
||||
public CodexHoverPopover(CodexAtlas atlas)
|
||||
{
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request the popover. Called from a widget's Update when it detects
|
||||
/// hover. The popover stays visible only as long as some widget requests
|
||||
/// it each frame.
|
||||
/// </summary>
|
||||
public void Show(Rectangle triggerBounds, string title, string body, string? tag = null, bool detriment = false)
|
||||
{
|
||||
_triggerBounds = triggerBounds;
|
||||
_title = title;
|
||||
_body = body;
|
||||
_tag = tag;
|
||||
_detriment = detriment;
|
||||
_showRequestedThisFrame = true;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => Point.Zero;
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
IsShown = _showRequestedThisFrame;
|
||||
_showRequestedThisFrame = false;
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
if (!IsShown) return;
|
||||
|
||||
const int width = 320;
|
||||
var titleFont = CodexFonts.SerifItalic;
|
||||
var bodyFont = CodexFonts.SerifBody;
|
||||
var tagFont = CodexFonts.MonoTagSmall;
|
||||
|
||||
// Wrap body text to the width.
|
||||
var titleLines = CodexLabel.WrapText(_title, titleFont, width - 32);
|
||||
var bodyLines = CodexLabel.WrapText(_body, bodyFont, width - 32);
|
||||
var readingLines = string.IsNullOrEmpty(_reading) ? System.Array.Empty<string>() : CodexLabel.WrapText(_reading!, bodyFont, width - 32);
|
||||
|
||||
int height = 14
|
||||
+ (int)(titleFont.LineHeight * titleLines.Length)
|
||||
+ 6
|
||||
+ (int)(bodyFont.LineHeight * bodyLines.Length)
|
||||
+ (readingLines.Length > 0 ? 8 + (int)(bodyFont.LineHeight * readingLines.Length) + 4 : 0)
|
||||
+ 12;
|
||||
|
||||
// Position — prefer below the trigger, flip above if it doesn't
|
||||
// fit there, and as a last resort clamp to whichever edge gives
|
||||
// more room. Earlier code clamped only with `if (y < 8) y = 8`,
|
||||
// which would push the popover off the bottom whenever the
|
||||
// trigger sat near the viewport's bottom edge and the popover
|
||||
// didn't fit above either.
|
||||
int x = _triggerBounds.X;
|
||||
if (x + width > _viewport.Width) x = _viewport.Width - width - 8;
|
||||
if (x < 8) x = 8;
|
||||
|
||||
int spaceBelow = _viewport.Height - _triggerBounds.Bottom - 6;
|
||||
int spaceAbove = _triggerBounds.Y - 6;
|
||||
int y;
|
||||
if (height <= spaceBelow)
|
||||
{
|
||||
y = _triggerBounds.Bottom + 6;
|
||||
}
|
||||
else if (height <= spaceAbove)
|
||||
{
|
||||
y = _triggerBounds.Y - height - 6;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Doesn't fit either side; clamp so the popover sits within
|
||||
// the viewport with at least an 8-px margin on the limiting side.
|
||||
y = System.Math.Max(8, _viewport.Height - height - 8);
|
||||
}
|
||||
|
||||
var rect = new Rectangle(x, y, width, height);
|
||||
|
||||
// Background
|
||||
sb.Draw(_atlas.Pixel, rect, CodexColors.Bg2);
|
||||
Color border = _detriment ? CodexColors.Seal : CodexColors.Gild;
|
||||
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);
|
||||
|
||||
int cy = rect.Y + 12;
|
||||
// Title (+ optional tag)
|
||||
foreach (var line in titleLines)
|
||||
{
|
||||
titleFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.Ink);
|
||||
cy += (int)titleFont.LineHeight;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(_tag))
|
||||
{
|
||||
var tagSize = tagFont.MeasureString(_tag);
|
||||
int tagX = rect.X + 16;
|
||||
int tagY = cy;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, (int)tagFont.LineHeight + 4), Color.Transparent);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, 1), CodexColors.Seal);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY + (int)tagFont.LineHeight + 3, (int)tagSize.X + 12, 1), CodexColors.Seal);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX + (int)tagSize.X + 11, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
|
||||
tagFont.DrawText(sb, _tag, new Vector2(tagX + 6, tagY + 2), CodexColors.Seal);
|
||||
cy += (int)tagFont.LineHeight + 6;
|
||||
}
|
||||
else cy += 6;
|
||||
|
||||
// Body
|
||||
foreach (var line in bodyLines)
|
||||
{
|
||||
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkSoft);
|
||||
cy += (int)bodyFont.LineHeight;
|
||||
}
|
||||
|
||||
if (readingLines.Length > 0)
|
||||
{
|
||||
cy += 4;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(rect.X + 16, cy, rect.Width - 32, 1), new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)128));
|
||||
cy += 6;
|
||||
foreach (var line in readingLines)
|
||||
{
|
||||
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkMute);
|
||||
cy += (int)bodyFont.LineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
Reading = null; // consumed
|
||||
}
|
||||
|
||||
private Rectangle _viewport = new(0, 0, 1280, 800);
|
||||
public void UpdateViewport(Rectangle vp) => _viewport = vp;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single- or multi-line text widget. Wraps to <see cref="MaxWidth"/> if set;
|
||||
/// otherwise to its arrange width. Color and font are explicit so the same
|
||||
/// widget can render the codex header (DisplayLarge / Ink), an eyebrow
|
||||
/// (MonoTag / InkMute), or a body paragraph (SerifBody / InkSoft).
|
||||
/// </summary>
|
||||
public sealed class CodexLabel : CodexWidget
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SpriteFontBase Font { get; set; }
|
||||
public Color Color { get; set; } = CodexColors.Ink;
|
||||
public HAlign HAlign { get; set; } = HAlign.Left;
|
||||
|
||||
/// <summary>If set, text wraps when its measured width would exceed this.</summary>
|
||||
public int? MaxWidth { get; set; }
|
||||
|
||||
public CodexLabel(string text, SpriteFontBase font, Color? color = null, HAlign hAlign = HAlign.Left)
|
||||
{
|
||||
Text = text;
|
||||
Font = font;
|
||||
if (color.HasValue) Color = color.Value;
|
||||
HAlign = hAlign;
|
||||
}
|
||||
|
||||
private string[] _wrappedLines = System.Array.Empty<string>();
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int wrapW = MaxWidth ?? available.X;
|
||||
_wrappedLines = WrapText(Text, Font, wrapW);
|
||||
int width = 0;
|
||||
foreach (var line in _wrappedLines)
|
||||
{
|
||||
var s = Font.MeasureString(line);
|
||||
if (s.X > width) width = (int)System.MathF.Ceiling(s.X);
|
||||
}
|
||||
int height = (int)System.MathF.Ceiling(Font.LineHeight * (_wrappedLines.Length == 0 ? 1 : _wrappedLines.Length));
|
||||
return new Point(System.Math.Min(width, wrapW), height);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
float y = Bounds.Y;
|
||||
foreach (var line in _wrappedLines)
|
||||
{
|
||||
var s = Font.MeasureString(line);
|
||||
float x = HAlign switch
|
||||
{
|
||||
HAlign.Center => Bounds.X + (Bounds.Width - s.X) / 2f,
|
||||
HAlign.Right => Bounds.X + Bounds.Width - s.X,
|
||||
_ => Bounds.X,
|
||||
};
|
||||
Font.DrawText(sb, line, new Vector2(x, y), Color);
|
||||
y += Font.LineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy word wrap. Splits on spaces, fits as many words as possible per
|
||||
/// line, hard-breaks oversize words. Honours embedded \n.
|
||||
/// </summary>
|
||||
public static string[] WrapText(string text, SpriteFontBase font, int maxWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return new[] { "" };
|
||||
var lines = new System.Collections.Generic.List<string>();
|
||||
foreach (var paragraph in text.Split('\n'))
|
||||
{
|
||||
var words = paragraph.Split(' ');
|
||||
var current = new System.Text.StringBuilder();
|
||||
foreach (var word in words)
|
||||
{
|
||||
string trial = current.Length == 0 ? word : current + " " + word;
|
||||
if (font.MeasureString(trial).X > maxWidth && current.Length > 0)
|
||||
{
|
||||
lines.Add(current.ToString());
|
||||
current.Clear();
|
||||
current.Append(word);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Clear();
|
||||
current.Append(trial);
|
||||
}
|
||||
}
|
||||
lines.Add(current.ToString());
|
||||
}
|
||||
return lines.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decorative horizontal rule with a small central diamond glyph. Mirrors
|
||||
/// the React design's <c>.divider</c> / <c>.clade-group-label::after</c>
|
||||
/// hairline + ornament pattern.
|
||||
/// </summary>
|
||||
public sealed class CodexOrnamentRule : CodexWidget
|
||||
{
|
||||
public Color RuleColor { get; set; } = CodexColors.Rule;
|
||||
public string? Label { get; set; }
|
||||
public SpriteFontBase Font { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
public CodexOrnamentRule(CodexAtlas atlas, SpriteFontBase font, string? label = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Font = font;
|
||||
Label = label;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 16);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
int midY = Bounds.Y + Bounds.Height / 2;
|
||||
|
||||
if (!string.IsNullOrEmpty(Label))
|
||||
{
|
||||
var s = Font.MeasureString(Label);
|
||||
int padX = 12;
|
||||
int textX = Bounds.X + 0;
|
||||
// Left rule before the text.
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, 16, 1), RuleColor);
|
||||
int textXStart = Bounds.X + 16 + padX / 2;
|
||||
Font.DrawText(sb, Label, new Vector2(textXStart, midY - Font.LineHeight / 2f), CodexColors.InkMute);
|
||||
int afterText = textXStart + (int)s.X + padX / 2;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(afterText, midY, Bounds.Right - afterText, 1), RuleColor);
|
||||
}
|
||||
else
|
||||
{
|
||||
int half = (Bounds.Width - 16) / 2;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, half, 1), RuleColor);
|
||||
sb.Draw(_atlas.OrnamentDiamond, new Rectangle(Bounds.X + half, midY - 8, 16, 16), CodexColors.Gild);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X + half + 16, midY, half, 1), RuleColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single-child container that paints a parchment fill and a 1-px ink rule
|
||||
/// border. The review-step summary blocks and the aside container both use
|
||||
/// this; for borderless wrappers (e.g. the page's main column) just nest in
|
||||
/// a <see cref="Column"/> instead.
|
||||
/// </summary>
|
||||
public sealed class CodexPanel : CodexWidget
|
||||
{
|
||||
public CodexWidget? Child { get; set; }
|
||||
public Color BackgroundColor { get; set; } = CodexColors.Bg2;
|
||||
public Color BorderColor { get; set; } = CodexColors.Rule;
|
||||
public bool Bordered { get; set; } = true;
|
||||
public Thickness Inset { get; set; } = new(18, 18, 20, 18);
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
public CodexPanel(CodexAtlas atlas, CodexWidget? child = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Child = child;
|
||||
if (child is not null) child.Parent = this;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Child is null) return new Point(Inset.HorizontalSum(), Inset.VerticalSum());
|
||||
var inner = new Point(available.X - Inset.HorizontalSum(), available.Y - Inset.VerticalSum());
|
||||
var s = Child.Measure(inner);
|
||||
return new Point(s.X + Inset.HorizontalSum(), s.Y + Inset.VerticalSum());
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
Child?.Arrange(new Rectangle(
|
||||
bounds.X + Inset.Left,
|
||||
bounds.Y + Inset.Top,
|
||||
bounds.Width - Inset.HorizontalSum(),
|
||||
bounds.Height - Inset.VerticalSum()));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input) => Child?.Update(gt, input);
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, Bounds, BackgroundColor);
|
||||
if (Bordered)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), BorderColor);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), BorderColor);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, 1, Bounds.Height), BorderColor);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.Right - 1, Bounds.Y, 1, Bounds.Height), BorderColor);
|
||||
}
|
||||
Child?.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single-line text input. The display matches the React design's
|
||||
/// <c>input[type=text]</c>: serif-display font, transparent background,
|
||||
/// gilded underline rule that lights up while focused. Used for the name
|
||||
/// field in the Sign step.
|
||||
///
|
||||
/// Receives characters via <see cref="CodexInput.TextEnteredThisFrame"/>;
|
||||
/// the parent screen subscribes to <c>Window.TextInput</c> in Initialize
|
||||
/// and routes them through <see cref="CodexInput.OnTextInput"/>. Backspace,
|
||||
/// Enter and arrow keys are handled in <see cref="Update"/>.
|
||||
/// </summary>
|
||||
public sealed class CodexTextBox : CodexWidget
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public string Placeholder { get; set; } = "";
|
||||
public bool IsFocused { get; set; }
|
||||
public int? FixedWidth { get; set; }
|
||||
public System.Action<string>? OnChanged { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
private float _caretBlink;
|
||||
|
||||
public CodexTextBox(string initial, CodexAtlas atlas, int? fixedWidth = null, System.Action<string>? onChanged = null)
|
||||
{
|
||||
Text = initial;
|
||||
_atlas = atlas;
|
||||
FixedWidth = fixedWidth;
|
||||
OnChanged = onChanged;
|
||||
_font = CodexFonts.DisplayMedium;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int w = FixedWidth ?? System.Math.Min(480, available.X);
|
||||
int h = (int)System.MathF.Ceiling(_font.LineHeight) + 14;
|
||||
return new Point(w, h);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (input.LeftJustPressed) IsFocused = ContainsPoint(input.MousePosition);
|
||||
_caretBlink = (_caretBlink + (float)gt.ElapsedGameTime.TotalSeconds) % 1f;
|
||||
if (!IsFocused) return;
|
||||
|
||||
bool changed = false;
|
||||
if (!string.IsNullOrEmpty(input.TextEnteredThisFrame))
|
||||
{
|
||||
Text += input.TextEnteredThisFrame;
|
||||
changed = true;
|
||||
}
|
||||
if (input.KeyJustPressed(Keys.Back) && Text.Length > 0)
|
||||
{
|
||||
Text = Text.Substring(0, Text.Length - 1);
|
||||
changed = true;
|
||||
}
|
||||
if (input.KeyJustPressed(Keys.Enter)) IsFocused = false;
|
||||
if (changed) OnChanged?.Invoke(Text);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Background — none (transparent) per design; we just paint the underline.
|
||||
Color underline = IsFocused ? CodexColors.Gild : CodexColors.Rule;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 2, Bounds.Width, 2), underline);
|
||||
|
||||
bool empty = string.IsNullOrEmpty(Text);
|
||||
string display = empty ? Placeholder : Text;
|
||||
Color textColor = empty ? CodexColors.InkMute : CodexColors.Ink;
|
||||
_font.DrawText(sb, display, new Vector2(Bounds.X + 4, Bounds.Y + 6), textColor);
|
||||
|
||||
// Caret blink — top-aligned, follows the end of the text.
|
||||
if (IsFocused && _caretBlink < 0.5f)
|
||||
{
|
||||
float caretX = Bounds.X + 4 + _font.MeasureString(Text).X;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle((int)caretX, Bounds.Y + 6, 1, (int)_font.LineHeight), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Vertical scroll container. Measures its child at unbounded height to
|
||||
/// learn the full content size, then arranges the child shifted by
|
||||
/// <see cref="ScrollOffset"/>. Mouse-wheel input changes the offset; bounds
|
||||
/// hit-testing for hover/click still uses screen-space, so widgets that
|
||||
/// scroll out of view simply stop receiving cursor events.
|
||||
///
|
||||
/// Drawing is uncapped — child widgets draw in their offset positions, so
|
||||
/// content above/below the visible band can spill into adjacent regions.
|
||||
/// The screen's stepper and nav bar are painted with opaque backgrounds
|
||||
/// to mask this overflow, which is cheaper than scissor clipping and avoids
|
||||
/// the SpriteBatch end/restart dance.
|
||||
/// </summary>
|
||||
public sealed class ScrollPanel : CodexWidget
|
||||
{
|
||||
public CodexWidget? Child { get; set; }
|
||||
public int ScrollOffset { get; private set; }
|
||||
private int _contentHeight;
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever the wheel changes the scroll offset. The wizard
|
||||
/// uses this to persist offset across <c>InvalidateLayout</c>: the
|
||||
/// rebuilt tree creates a new <see cref="ScrollPanel"/>, but the
|
||||
/// stored value gets re-applied via <see cref="SetInitialScroll"/>.
|
||||
/// </summary>
|
||||
public System.Action<int>? OnScrollChanged { get; set; }
|
||||
|
||||
public ScrollPanel(CodexAtlas atlas, CodexWidget? child = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Child = child;
|
||||
if (child is not null) child.Parent = this;
|
||||
}
|
||||
|
||||
/// <summary>Restore a saved offset before the first measure-arrange pass runs.</summary>
|
||||
public void SetInitialScroll(int offset) => ScrollOffset = offset;
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Child is null)
|
||||
{
|
||||
_contentHeight = 0;
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
var s = Child.Measure(new Point(System.Math.Max(0, available.X - 8), int.MaxValue / 2));
|
||||
_contentHeight = s.Y;
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
ClampScroll();
|
||||
Child?.Arrange(new Rectangle(bounds.X, bounds.Y - ScrollOffset,
|
||||
System.Math.Max(0, bounds.Width - 8), _contentHeight));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (Bounds.Contains(input.MousePosition) && input.ScrollDelta != 0)
|
||||
{
|
||||
ScrollOffset -= input.ScrollDelta / 2;
|
||||
ClampScroll();
|
||||
Child?.Arrange(new Rectangle(Bounds.X, Bounds.Y - ScrollOffset,
|
||||
System.Math.Max(0, Bounds.Width - 8), _contentHeight));
|
||||
OnScrollChanged?.Invoke(ScrollOffset);
|
||||
}
|
||||
|
||||
// Nest a clip into the visible viewport so children scrolled out
|
||||
// of view don't register hover/click. Intersect with any outer
|
||||
// clip the parent already set so we never widen its scope.
|
||||
var prevClip = input.GetMouseClip();
|
||||
var newClip = prevClip is Rectangle p ? Rectangle.Intersect(p, Bounds) : Bounds;
|
||||
input.SetMouseClip(newClip);
|
||||
Child?.Update(gt, input);
|
||||
if (prevClip is Rectangle r) input.SetMouseClip(r);
|
||||
else input.ClearMouseClip();
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
Child?.Draw(sb, gt);
|
||||
|
||||
// Scrollbar thumb on the right edge — only when content overflows.
|
||||
if (_contentHeight > Bounds.Height)
|
||||
{
|
||||
int trackX = Bounds.Right - 4;
|
||||
int trackH = Bounds.Height;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(trackX, Bounds.Y, 2, trackH),
|
||||
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)80));
|
||||
|
||||
int thumbH = System.Math.Max(24, (int)((float)trackH * trackH / _contentHeight));
|
||||
float t = (float)ScrollOffset / System.Math.Max(1, _contentHeight - trackH);
|
||||
int thumbY = Bounds.Y + (int)((trackH - thumbH) * t);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(trackX, thumbY, 2, thumbH), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClampScroll()
|
||||
{
|
||||
int max = System.Math.Max(0, _contentHeight - Bounds.Height);
|
||||
if (ScrollOffset < 0) ScrollOffset = 0;
|
||||
if (ScrollOffset > max) ScrollOffset = max;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user