Render each vital bar as a horizontal 3-slice from the real retail RenderSurface sprites (authoritative ids from the vitals LayoutDesc 0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a stretched glassy-gradient middle, and a fixed-width right-cap. The empty back track draws full width; the coloured front fill grows from the left to the value (the track owns the right end, so the fill omits its own right-cap). Replaces the flat single-sprite Alphablend overlay that read as the old UI - this is the bordered gradient look from the retail screenshot (red HP / gold stamina / blue mana). UiMeter gains the six 9-slice ids (BackLeft/Tile/Right + FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the backleft/backtile/backright/frontleft/fronttile/frontright attrs; vitals.xml carries the 18 per-vital ids. The temporary ACDREAM_BAR_PROVEOUT component grid is removed. Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite that assembles the bars with the SAME DrawHBar logic, so the sprite assembly can be verified by eye (Read the PNG) without launching the client + server - the fast UI-iteration loop the user asked for. export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
5.6 KiB
C#
126 lines
5.6 KiB
C#
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a
|
|
/// partial-width solid fill, and an optional centered "current/max" numeric
|
|
/// overlay. <see cref="Fill"/> returns 0..1 (null = no data → empty bar);
|
|
/// <see cref="Label"/> returns the overlay text (null = no number).
|
|
///
|
|
/// <para>
|
|
/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite
|
|
/// (glassy center highlight) and the retail dat font are a later polish pass —
|
|
/// retail's vitals are bars exactly like this, just sprited.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class UiMeter : UiElement
|
|
{
|
|
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
|
|
public Func<float?> Fill { get; set; } = () => 0f;
|
|
/// <summary>Centered overlay text provider (e.g. "291/291"); null = none.</summary>
|
|
public Func<string?> Label { get; set; } = () => null;
|
|
public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f);
|
|
public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
|
|
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
|
|
|
|
/// <summary>Resolver from a RenderSurface DataId to (GL handle, w, h). When set
|
|
/// with the 9-slice ids below, the bar draws the retail sprites instead of solid color.</summary>
|
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
|
|
|
// Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
|
|
// a stretched gradient middle, and a fixed-width right-cap. The "back" slice is
|
|
// the empty track (drawn full width); the "front" slice is the coloured fill
|
|
// (drawn from the left, grown to the fill fraction — the track owns the right
|
|
// end, so the fill omits its own right-cap). Ids come from the vitals LayoutDesc
|
|
// (0x21000014) via tools/dump-vitals-bars; 0 = none.
|
|
/// <summary>Empty-track left-cap RenderSurface id.</summary>
|
|
public uint BackLeft { get; set; }
|
|
/// <summary>Empty-track middle (stretched gradient) RenderSurface id.</summary>
|
|
public uint BackTile { get; set; }
|
|
/// <summary>Empty-track right-cap RenderSurface id.</summary>
|
|
public uint BackRight { get; set; }
|
|
/// <summary>Coloured-fill left-cap RenderSurface id.</summary>
|
|
public uint FrontLeft { get; set; }
|
|
/// <summary>Coloured-fill middle (stretched gradient) RenderSurface id.</summary>
|
|
public uint FrontTile { get; set; }
|
|
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
|
|
public uint FrontRight { get; set; }
|
|
|
|
public UiMeter() { ClickThrough = true; }
|
|
|
|
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
|
|
/// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
|
|
public static (float x, float y, float w, float h) ComputeFillRect(
|
|
float pct, float w, float h)
|
|
{
|
|
if (pct < 0f) pct = 0f;
|
|
if (pct > 1f) pct = 1f;
|
|
return (0f, 0f, w * pct, h);
|
|
}
|
|
|
|
protected override void OnDraw(UiRenderContext ctx)
|
|
{
|
|
float? pct = Fill();
|
|
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
|
|
|
|
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
|
{
|
|
// Empty track: full-width 3-slice (left-cap + stretched gradient + right-cap).
|
|
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, 0, 0, Width, Height, withRightCap: true);
|
|
// Coloured fill: grows from the left to the value, no right-cap of its own.
|
|
if (pct is not null && p > 0f)
|
|
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false);
|
|
}
|
|
else
|
|
{
|
|
// Placeholder solid-color fallback.
|
|
ctx.DrawRect(0, 0, Width, Height, BgColor);
|
|
if (pct is not null && p > 0f)
|
|
{
|
|
var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height);
|
|
if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor);
|
|
}
|
|
}
|
|
|
|
string? label = Label();
|
|
if (!string.IsNullOrEmpty(label) && ctx.DefaultFont is { } font)
|
|
{
|
|
float tw = font.MeasureWidth(label);
|
|
float tx = (Width - tw) * 0.5f;
|
|
float ty = (Height - font.LineHeight) * 0.5f;
|
|
ctx.DrawString(label, tx, ty, LabelColor);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws a horizontal 3-slice into <paramref name="w"/> x <paramref name="h"/> at
|
|
/// (<paramref name="x"/>,<paramref name="y"/>): a native-width left-cap, a stretched
|
|
/// middle, and (when <paramref name="withRightCap"/>) a native-width right-cap. Caps
|
|
/// are clamped so a narrow bar never overdraws. A 0 id skips that slice.
|
|
/// </summary>
|
|
private static void DrawHBar(
|
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
|
uint leftId, uint tileId, uint rightId,
|
|
float x, float y, float w, float h, bool withRightCap)
|
|
{
|
|
if (w <= 0f) return;
|
|
var (lt, lw, _) = resolve(leftId);
|
|
var (tt, _, _) = resolve(tileId);
|
|
var (rt, rw, _) = resolve(rightId);
|
|
|
|
float rcap = withRightCap && rt != 0 ? MathF.Min(rw, w) : 0f;
|
|
float lcap = lt != 0 ? MathF.Min(lw, w - rcap) : 0f;
|
|
|
|
if (lt != 0 && lcap > 0f)
|
|
ctx.DrawSprite(lt, x, y, lcap, h, 0, 0, 1, 1, Vector4.One);
|
|
|
|
float midX = x + lcap;
|
|
float midW = w - lcap - rcap;
|
|
if (tt != 0 && midW > 0f)
|
|
ctx.DrawSprite(tt, midX, y, midW, h, 0, 0, 1, 1, Vector4.One);
|
|
|
|
if (rcap > 0f)
|
|
ctx.DrawSprite(rt, x + w - rcap, y, rcap, h, 0, 0, 1, 1, Vector4.One);
|
|
}
|
|
}
|