The generalized channel menu wouldn't open: the factory recursed the Type-6 menu element's dat children, building its invisible Type-12 label child as a UiText. Hit-testing is children-first and UiText consumes MouseDown (selection), so the label child swallowed the menu button click and the dropdown never opened. The transcript similarly gained an invisible Ghosted-button child (a 16x16 selection dead-zone). The old hand-made build never had these — it skipped Type 12 and hand-placed the widgets with no children. Fix: behavioral widgets (Meter/Menu/Button/Scrollbar/Text/Field) draw their full appearance and reproduce their dat sub-elements procedurally, so they are LEAF — the importer must not build their dat children as separate (click-stealing) widgets. Add UiElement.ConsumesDatChildren (default false; the 6 behavioral widgets override true) and gate LayoutImporter recursion on it (replacing the UiMeter-only special case). Only generic containers (UiDatElement, panels) recurse. Visually confirmed in the live client (channel menu opens; General/Trade selected and sent). Vitals unchanged (UiMeter was already leaf). Full suite: 404 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
8.3 KiB
C#
171 lines
8.3 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>Retail dat font (Font 0x40000000) for the "cur/max" overlay. When
|
|
/// set, the label renders through the dat-font two-pass blit (outline + fill);
|
|
/// when null, the debug <see cref="UiRenderContext.DefaultFont"/> bitmap font
|
|
/// is used instead. Set by the host when the retail UI is active.</summary>
|
|
public UiDatFont? DatFont { get; set; }
|
|
|
|
/// <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 TILED gradient middle (the "fill-tile" repeats at native width — it does not
|
|
// stretch), and a fixed-width right-cap. The "back" slice is the empty track
|
|
// (drawn full width); the "front" slice is the coloured fill (drawn full-geometry
|
|
// but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's
|
|
// shows through when partial). Ids come from the stacked vitals LayoutDesc
|
|
// (0x2100006C) via the dump-vitals-layout CLI; 0 = none.
|
|
/// <summary>Empty-track left-cap RenderSurface id.</summary>
|
|
public uint BackLeft { get; set; }
|
|
/// <summary>Empty-track middle (tiled 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 (tiled 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>The meter draws its own 3-slice bars; the importer must not build its
|
|
/// grandchild slice/text elements as separate widgets.</summary>
|
|
public override bool ConsumesDatChildren => 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))
|
|
{
|
|
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
|
|
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
|
|
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
|
|
// The front carries its own right-cap (shown at 100%); clipping below 100%
|
|
// removes it and reveals the back track's right-cap — retail's scissor-fill.
|
|
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
|
if (pct is not null && p > 0f)
|
|
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
|
}
|
|
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))
|
|
{
|
|
if (DatFont is { } datFont)
|
|
{
|
|
// Retail path: centered cur/max via the dat font's two-pass blit.
|
|
float tw = datFont.MeasureWidth(label);
|
|
float tx = (Width - tw) * 0.5f;
|
|
float ty = (Height - datFont.LineHeight) * 0.5f;
|
|
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor);
|
|
}
|
|
else if (ctx.DefaultFont is { } font)
|
|
{
|
|
// Fallback: debug bitmap font (no dat font available).
|
|
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 the full-width horizontal 3-slice (native-width left-cap, stretched
|
|
/// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED
|
|
/// so nothing past <paramref name="clipW"/> (local px from the left) is drawn.
|
|
/// The back track passes <c>clipW = Width</c>; the front fill passes
|
|
/// <c>clipW = Width * fraction</c>. Clipping UV-crops each slice proportionally,
|
|
/// so the fill ends cleanly and the back's right-cap shows through when partial.
|
|
/// A 0 id skips that slice.
|
|
/// </summary>
|
|
private void DrawHBar(
|
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
|
uint leftId, uint midId, uint rightId, float clipW)
|
|
{
|
|
if (clipW <= 0f) return;
|
|
float w = Width, h = Height;
|
|
var (lt, lw, _) = resolve(leftId);
|
|
var (mt, mw, _) = resolve(midId);
|
|
var (rt, rw, _) = resolve(rightId);
|
|
|
|
float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
|
|
float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
|
|
float midW = w - capL - capR;
|
|
|
|
// Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI
|
|
// texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their
|
|
// own native width → a single 1:1 copy. The wide middle spans many native
|
|
// widths → it TILES, matching retail's "fill-tile" + ImgTex::TileCSI rather
|
|
// than stretching one copy. (Same UV-repeat the chrome border already uses.)
|
|
DrawPiece(ctx, lt, 0f, capL, lw, h, clipW);
|
|
DrawPiece(ctx, mt, capL, midW, mw, h, clipW);
|
|
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
|
|
}
|
|
|
|
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
|
|
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
|
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
|
|
/// Clipped so nothing past <paramref name="clipW"/> shows. For a cap (span == native)
|
|
/// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is
|
|
/// UV-cropped.</summary>
|
|
private static void DrawPiece(
|
|
UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW)
|
|
{
|
|
if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return;
|
|
float visibleW = MathF.Min(pieceW, clipW - pieceX);
|
|
if (visibleW <= 0f) return;
|
|
float u1 = visibleW / nativeW; // >1 ⇒ texture repeats (tiles); ≤1 ⇒ a partial copy
|
|
ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
|
|
}
|
|
}
|