b451f83174
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>
148 lines
5.3 KiB
C#
148 lines
5.3 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|