feat(D.2b): vital bars use retail dat sprites (back track + fill-cropped front)

UiMeter gains SpriteResolve/BackSpriteId/FrontSpriteId; when both are
set, OnDraw draws the empty-track sprite full-width then the colored-fill
sprite UV-cropped to the live fill fraction (left-to-right drain). Falls
back to solid rects when sprite ids are absent, keeping existing behavior
and tests intact.

MarkupDocument.Build() parses `back`/`front` hex attrs on <meter> and
passes `resolve` into every UiMeter.  vitals.xml wires the authoritative
LayoutDesc 0x21000014 sprites (Health 0x06005F3C/3D, Stamina 3E/3F,
Mana 40/41).  The bar prove-out block in GameWindow.cs was already gone.

If the sprites decode as 1x1 magenta at runtime they are paletted
(INDEX16/P8) — the solid-color fallback will display instead and can be
investigated separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 19:45:54 +02:00
parent 56ee5eff60
commit 84630517e3
4 changed files with 68 additions and 16 deletions

View file

@ -58,14 +58,17 @@ public static class MarkupDocument
var max = BindUint((string?)el.Attribute("max"), binding);
panel.AddChild(new UiMeter
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BarColor = Color((string?)el.Attribute("color")),
Fill = BindFloat((string?)el.Attribute("fill"), binding),
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BarColor = Color((string?)el.Attribute("color")),
Fill = BindFloat((string?)el.Attribute("fill"), binding),
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")),
});
break;
// future element kinds (label, button, image) added here
@ -125,6 +128,15 @@ public static class MarkupDocument
return binding.GetType().GetProperty(expr[1..^1]);
}
private static uint Hex(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return 0;
var t = s.Trim();
if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..];
return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
private static AnchorEdges Anchor(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;

View file

@ -24,6 +24,14 @@ public sealed class UiMeter : UiElement
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 Back/Front sprite ids, 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; }
public UiMeter() { ClickThrough = true; }
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
@ -38,13 +46,32 @@ public sealed class UiMeter : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
ctx.DrawRect(0, 0, Width, Height, BgColor);
float? pct = Fill();
if (pct is float p)
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
if (SpriteResolve is { } resolve && (BackSpriteId != 0 || FrontSpriteId != 0))
{
var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height);
if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor);
// 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);
}
}
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();

View file

@ -1,5 +1,5 @@
<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"/>
<meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F" anchor="left,top,right"/>
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9" anchor="left,top,right"/>
<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"/>
</panel>