feat(D.2b): retail 3-slice vital bars + headless mockup verifier

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>
This commit is contained in:
Erik 2026-06-14 21:40:11 +02:00
parent 84630517e3
commit 1453ff7da2
7 changed files with 242 additions and 26 deletions

View file

@ -67,8 +67,12 @@ public static class MarkupDocument
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
SpriteResolve = resolve,
BackSpriteId = Hex((string?)el.Attribute("back")),
FrontSpriteId = Hex((string?)el.Attribute("front")),
BackLeft = Hex((string?)el.Attribute("backleft")),
BackTile = Hex((string?)el.Attribute("backtile")),
BackRight = Hex((string?)el.Attribute("backright")),
FrontLeft = Hex((string?)el.Attribute("frontleft")),
FrontTile = Hex((string?)el.Attribute("fronttile")),
FrontRight = Hex((string?)el.Attribute("frontright")),
});
break;
// future element kinds (label, button, image) added here

View file

@ -25,12 +25,27 @@ public sealed class UiMeter : UiElement
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>Resolver from a RenderSurface DataId to (GL handle, w, h). When set
/// with Back/Front sprite ids, the bar draws the retail sprites instead of solid color.</summary>
/// 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; }
/// <summary>Empty-track sprite (drawn full width). 0 = none.</summary>
public uint BackSpriteId { get; set; }
/// <summary>Colored-fill sprite (drawn cropped to the fill fraction). 0 = none.</summary>
public uint FrontSpriteId { 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; }
@ -49,19 +64,13 @@ public sealed class UiMeter : UiElement
float? pct = Fill();
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
if (SpriteResolve is { } resolve && (BackSpriteId != 0 || FrontSpriteId != 0))
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
{
// Retail bar: empty track full width, colored fill cropped to p (left→right).
if (BackSpriteId != 0)
{
var (bt, _, _) = resolve(BackSpriteId);
if (bt != 0) ctx.DrawSprite(bt, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One);
}
if (FrontSpriteId != 0 && pct is not null && p > 0f)
{
var (ft, _, _) = resolve(FrontSpriteId);
if (ft != 0) ctx.DrawSprite(ft, 0, 0, Width * p, Height, 0, 0, p, 1, Vector4.One);
}
// 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
{
@ -83,4 +92,35 @@ public sealed class UiMeter : UiElement
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);
}
}

View file

@ -1,5 +1,8 @@
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" resize="x">
<meter id="health" x="8" y="24" w="200" h="14" fill="{HealthPercent}" cur="{HealthCurrent}" max="{HealthMax}" color="#FFC70D0D" anchor="left,top,right" back="0x06005F3C" front="0x06005F3D"/>
<meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F" anchor="left,top,right" back="0x06005F3E" front="0x06005F3F"/>
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9" anchor="left,top,right" back="0x06005F40" front="0x06005F41"/>
<meter id="health" x="8" y="24" w="200" h="14" fill="{HealthPercent}" cur="{HealthCurrent}" max="{HealthMax}" color="#FFC70D0D" anchor="left,top,right"
backleft="0x06001141" backtile="0x06001140" backright="0x0600113F" frontleft="0x06001131" fronttile="0x06001132" frontright="0x06001133"/>
<meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F" anchor="left,top,right"
backleft="0x06001147" backtile="0x06001146" backright="0x06001145" frontleft="0x06001137" fronttile="0x06001138" frontright="0x06001139"/>
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9" anchor="left,top,right"
backleft="0x06001144" backtile="0x06001143" backright="0x06001142" frontleft="0x06001134" fronttile="0x06001135" frontright="0x06001136"/>
</panel>