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>
167 lines
6.7 KiB
C#
167 lines
6.7 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 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;
|
|
}
|