diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 057402c7..5d7fc535 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -55,6 +55,14 @@ public sealed class UiMeter : UiElement /// Coloured-fill right-cap RenderSurface id. public uint FrontRight { get; set; } + /// Vertical orientation (retail m_eDirection 2/4). Default false = + /// horizontal (direction 1). The burden bar is the only vertical meter today. + public bool Vertical { get; set; } + + /// For a vertical meter, fill grows from the bottom up (retail direction 4) + /// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed. + public bool FillFromBottom { get; set; } = true; + public UiMeter() { ClickThrough = true; } /// The meter draws its own 3-slice bars; the importer must not build its @@ -71,6 +79,18 @@ public sealed class UiMeter : UiElement return (0f, 0f, w * pct, h); } + /// Clamp to [0,1] and return the vertical fill rect + /// (local px). true → the fill occupies the bottom + /// h*pct px (retail direction 4); false → the top (direction 2). + public static (float x, float y, float w, float h) ComputeVFillRect( + float pct, float w, float h, bool fromBottom) + { + if (pct < 0f) pct = 0f; + if (pct > 1f) pct = 1f; + float fh = h * pct; + return (0f, fromBottom ? h - fh : 0f, w, fh); + } + protected override void OnDraw(UiRenderContext ctx) { float? pct = Fill(); @@ -78,14 +98,25 @@ public sealed class UiMeter : UiElement 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); + if (Vertical) + { + // Track full height, fill clipped to the fraction along the fill direction. + // The burden meter is a single-tile bar (BackTile/FrontTile, no caps). + DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false); + if (pct is not null && p > 0f) + DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true); + } + else + { + // 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 { @@ -158,6 +189,28 @@ public sealed class UiMeter : UiElement DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW); } + /// Draws a single-tile vertical bar slice. The track ( + /// false) spans the full height; the fill spans px from the + /// bottom () or top, UV-cropped to that fraction of the + /// sprite so the fill reveals the matching part of the art (retail + /// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op. + private void DrawVBar(UiRenderContext ctx, Func resolve, + uint tileId, float visibleH, bool fromBottom, bool isFill) + { + if (tileId == 0 || visibleH <= 0f) return; + var (tex, _, _) = resolve(tileId); + if (tex == 0) return; + float w = Width, h = Height; + if (visibleH > h) visibleH = h; + float frac = h > 0f ? visibleH / h : 0f; + // Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]). + // Top-down / track: top of the rect AND top of the sprite (v in [0, frac]). + float y = isFill && fromBottom ? h - visibleH : 0f; + float v0 = isFill && fromBottom ? 1f - frac : 0f; + float v1 = isFill && fromBottom ? 1f : frac; + ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One); + } + /// Draw a slice over local [, /// pieceX+], with the texture repeating every /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). diff --git a/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs b/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs new file mode 100644 index 00000000..6f130e24 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs @@ -0,0 +1,35 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiMeterVerticalTests +{ + [Fact] + public void Vertical_fill_from_bottom_occupies_lower_fraction() + { + // 11x58 bar, 50% → fill rect is the bottom 29px. + var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true); + Assert.Equal(0f, x); + Assert.Equal(29f, y, 3); // h - h*p = 58 - 29 + Assert.Equal(11f, w); + Assert.Equal(29f, h, 3); + } + + [Fact] + public void Vertical_fill_from_top_starts_at_zero() + { + var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false); + Assert.Equal(0f, y); + Assert.Equal(14.5f, h, 3); + } + + [Theory] + [InlineData(-1f, 0f)] + [InlineData(2f, 58f)] + public void Vertical_fill_clamps_fraction(float pct, float expectedH) + { + var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true); + Assert.Equal(expectedH, h, 3); + } +}