The login tunnel now covers from the first world-facing frame (the sky-void backdrop can never present pre-tunnel) and holds through an atomic tunnel-to-world swap at reveal completion — the void is structurally unreachable on both edges, pinned by frame-sequence tests across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport- Controller/RuntimeWorldTransitState. Vitals detail icons draw at their authored centered offsets in both stacked and side-by-side layouts. Implemented and live-probed by the fix agent; finalized by the lead after the agent parked post-verification (gates re-run green: App 5512/3, Runtime 1747/0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
499 lines
25 KiB
C#
499 lines
25 KiB
C#
using System.Numerics;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>Justification of a meter's absorbed state caption (dat property
|
||
/// 0x14 on the caption child's own state: 0/2 = Left, 3/5 = Right, 1 = Center
|
||
/// — the same mapping ElementReader applies at element level).</summary>
|
||
public enum UiMeterLabelAlign : byte { Left = 0, Center = 1, Right = 2 }
|
||
|
||
/// <summary>
|
||
/// One absorbed vitals detail-icon overlay (the <c>0x100004A9</c> child of a
|
||
/// meter's back/front slice container): its ShowDetail sprite, authored rect
|
||
/// (local to the container, which spans the meter at 0,0), the authored raw
|
||
/// edge-anchor modes (<c>ElementDesc</c> Left/Top/Right/Bottom, values 0–4),
|
||
/// and the container's authored size. The overlays author <c>L3/R3</c> —
|
||
/// retail's CENTER anchors — so when the meter is resized away from its
|
||
/// authored width the icon re-centers through the exact
|
||
/// <see cref="UiLayoutPolicy"/> port of
|
||
/// <c>UIElement::UpdateForParentSizeChange @0x00462640</c> instead of staying
|
||
/// pinned at its authored X (the gate-observed left-drift).
|
||
/// </summary>
|
||
internal readonly record struct UiMeterDetailOverlaySpec(
|
||
uint Sprite,
|
||
float X, float Y, float W, float H,
|
||
uint LeftMode, uint TopMode, uint RightMode, uint BottomMode,
|
||
float ParentW, float ParentH);
|
||
|
||
/// <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, IUiDatStateful
|
||
{
|
||
private readonly Dictionary<uint, uint> _stateFillSprites = new();
|
||
private readonly Dictionary<uint, (string Text, UiMeterLabelAlign Align)> _stateLabels = new();
|
||
private (string Text, UiMeterLabelAlign Align)? _activeStateLabel;
|
||
|
||
// Vitals ShowDetail icon overlays (see ConfigureDetailOverlay).
|
||
private bool _detailConfigured;
|
||
private bool _detailPassToChildren;
|
||
private UiMeterDetailOverlaySpec _detailBack;
|
||
private UiMeterDetailOverlaySpec _detailFront;
|
||
|
||
/// <summary>True when this meter absorbed the vitals detail-icon overlays. Exposed for tests.</summary>
|
||
internal bool HasDetailOverlay => _detailConfigured;
|
||
/// <summary>The dim back-container detail icon (ShowDetail media). Exposed for tests.</summary>
|
||
internal uint DetailBackSprite => _detailBack.Sprite;
|
||
/// <summary>The bright fill-clipped front-container detail icon. Exposed for tests.</summary>
|
||
internal uint DetailFrontSprite => _detailFront.Sprite;
|
||
/// <summary>The back overlay's authored meter-local rect. Exposed for tests.</summary>
|
||
internal (float X, float Y, float W, float H) DetailBackRect =>
|
||
(_detailBack.X, _detailBack.Y, _detailBack.W, _detailBack.H);
|
||
/// <summary>The complete absorbed back overlay. Exposed for tests.</summary>
|
||
internal UiMeterDetailOverlaySpec DetailBack => _detailBack;
|
||
/// <summary>The complete absorbed front overlay. Exposed for tests.</summary>
|
||
internal UiMeterDetailOverlaySpec DetailFront => _detailFront;
|
||
|
||
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
|
||
public uint ElementId { get; set; }
|
||
|
||
/// <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 LayoutDesc property <c>0x21</c> (two-pass glyph outline,
|
||
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded by DatWidgetFactory
|
||
/// from the element's effective-default state, same as <see cref="UiText.Outline"/>
|
||
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
|
||
public bool Outline { get; set; }
|
||
|
||
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
|
||
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
|
||
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
|
||
|
||
/// <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; }
|
||
|
||
/// <summary>The active numeric DAT state for a stateful fill meter.</summary>
|
||
public uint ActiveRetailStateId { get; private set; }
|
||
|
||
/// <summary>The caption latched by the active retail state (null when the
|
||
/// state authors none). Exposed for tests.</summary>
|
||
internal string? ActiveStateLabel => _activeStateLabel?.Text;
|
||
|
||
/// <summary>The latched caption's authored justification. Exposed for tests.</summary>
|
||
internal UiMeterLabelAlign? ActiveStateLabelAlign => _activeStateLabel?.Align;
|
||
|
||
/// <summary>
|
||
/// Registers a fill sprite carried by a child state of the imported meter.
|
||
/// Used by the powerbar shape in LayoutDesc 0x21000072, whose track is on
|
||
/// the meter and whose Jump/Melee/Missile/DDD fills are states of one child.
|
||
/// </summary>
|
||
internal void ConfigureStateFill(uint stateId, uint spriteId)
|
||
{
|
||
if (stateId != 0 && spriteId != 0)
|
||
_stateFillSprites[stateId] = spriteId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Registers a caption carried by a state of the meter's absorbed Type-12
|
||
/// child (gmPowerbarUI's 0x10000035: JumpMode 'Height' / MeleeMode 'Power'
|
||
/// / MissileMode 'Accuracy'). Retail shows it via the meter's
|
||
/// PassToChildren state cascade; the absorbed equivalent latches it in
|
||
/// <see cref="TrySetRetailState"/> and draws it when no live
|
||
/// <see cref="Label"/> provider supplies text. Each mode STATE authors its
|
||
/// own justification (dat property 0x14 — the powerbar modes author 0x3 =
|
||
/// Right; only the element default is centered), so alignment rides the
|
||
/// state entry.
|
||
/// </summary>
|
||
internal void ConfigureStateLabel(uint stateId, string text, UiMeterLabelAlign align)
|
||
{
|
||
if (stateId != 0 && !string.IsNullOrEmpty(text))
|
||
_stateLabels[stateId] = (text, align);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Registers the vitals "detail" icon overlays absorbed from the meter's
|
||
/// two slice containers (the <c>0x100004A9</c> children — health heart
|
||
/// 0x06007490/91, stamina sword 0x06007492/93, mana scepter 0x06007494/95).
|
||
/// Retail authors each icon TWICE: a dim version in the BACK container
|
||
/// (drawn unclipped over the empty track) and a bright version in the
|
||
/// FRONT container (fill-clipped with the rest of the front layer —
|
||
/// <c>UIElement_Meter::DrawChildren @0x0046FBD0</c> clips the whole
|
||
/// <c>m_pcChildImage</c> child, element id 2, to the 0x69 fraction), so
|
||
/// the icon itself fills up with the vital. Both overlays author media
|
||
/// ONLY for <c>ShowDetail</c> (<c>HideDetail</c> authors File=0), so they
|
||
/// draw solely in that state. Each spec carries the overlay's authored
|
||
/// rect (local to its container, which spans the meter at 0,0), its raw
|
||
/// edge-anchor modes, and the container's authored size — see
|
||
/// <see cref="ComputeDetailOverlayRect"/> for how those position the icon
|
||
/// on a resized meter.
|
||
/// </summary>
|
||
internal void ConfigureDetailOverlay(
|
||
in UiMeterDetailOverlaySpec back,
|
||
in UiMeterDetailOverlaySpec front,
|
||
bool passToChildren)
|
||
{
|
||
_detailBack = back;
|
||
_detailFront = front;
|
||
_detailConfigured = back.Sprite != 0 || front.Sprite != 0;
|
||
_detailPassToChildren = passToChildren;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The overlay's effective meter-local rect at the meter's CURRENT size.
|
||
/// At the authored container size the authored rect stands verbatim —
|
||
/// retail's <c>UIElement::UpdateForParentSizeChange @0x00462640</c> runs
|
||
/// only when a parent actually resizes, and the designers hand-placed
|
||
/// rects that are not always the exact center formula (the stamina sword
|
||
/// authors X=32 where the mode-3 formula yields 33). On any other size
|
||
/// the authored edge modes are applied from the ORIGINAL child/parent
|
||
/// rects through <see cref="UiLayoutPolicy.Apply"/>, the exact port of
|
||
/// that retail routine — the overlays' authored <c>L3/R3</c> center
|
||
/// anchors are what keep the heart/sword/scepter centered when the
|
||
/// vitals window is resized (retail near mode 3 =
|
||
/// <c>curParentW/2 - origChildW/2</c> @0x004627ca; far mode 3 =
|
||
/// <c>curParentW/2 + origChildW/2 - 1</c> @0x00462827, integer
|
||
/// arithmetic, so odd authored widths lose one pixel exactly as retail
|
||
/// does).
|
||
/// </summary>
|
||
internal static (float X, float Y, float W, float H) ComputeDetailOverlayRect(
|
||
in UiMeterDetailOverlaySpec overlay, float meterW, float meterH)
|
||
{
|
||
int parentW = (int)meterW, parentH = (int)meterH;
|
||
int origParentW = (int)overlay.ParentW, origParentH = (int)overlay.ParentH;
|
||
if (origParentW <= 0 || origParentH <= 0
|
||
|| (parentW == origParentW && parentH == origParentH))
|
||
{
|
||
return (overlay.X, overlay.Y, overlay.W, overlay.H);
|
||
}
|
||
|
||
var originalChild = UiPixelRect.FromPositionAndSize(
|
||
(int)overlay.X, (int)overlay.Y, (int)overlay.W, (int)overlay.H);
|
||
var originalParent = UiPixelRect.FromPositionAndSize(
|
||
0, 0, origParentW, origParentH);
|
||
var currentParent = UiPixelRect.FromPositionAndSize(
|
||
0, 0, parentW, parentH);
|
||
UiPixelRect effective = UiLayoutPolicy.Apply(
|
||
overlay.LeftMode,
|
||
overlay.TopMode,
|
||
overlay.RightMode,
|
||
overlay.BottomMode,
|
||
originalChild,
|
||
originalParent,
|
||
originalChild,
|
||
currentParent);
|
||
return (effective.X0, effective.Y0, effective.Width, effective.Height);
|
||
}
|
||
|
||
public bool TrySetRetailState(uint stateId)
|
||
{
|
||
// Vitals detail toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00
|
||
// flips the window root between HideDetail 0x10000006 and ShowDetail
|
||
// 0x10000007; the meter receives the state through the authored
|
||
// PassToChildren cascade — UIElement::SetState @0x00464E70). The
|
||
// meter's own HideDetail/ShowDetail StateDescs are media-less and
|
||
// exist purely to keep propagating (PassToChildren=true), so forward
|
||
// to the absorbed text child (whose per-state 0x3B hides the numbers
|
||
// in ShowDetail) and let OnDraw key the icon overlays off the state.
|
||
if (_detailConfigured
|
||
&& stateId is RetailUiStateIds.HideDetail or RetailUiStateIds.ShowDetail)
|
||
{
|
||
ActiveRetailStateId = stateId;
|
||
if (_detailPassToChildren)
|
||
foreach (UiElement child in Children)
|
||
if (child is IUiDatStateful stateful)
|
||
stateful.TrySetRetailState(stateId);
|
||
return true;
|
||
}
|
||
|
||
bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId);
|
||
bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption);
|
||
if (!hasFill && !hasLabel)
|
||
return false;
|
||
|
||
ActiveRetailStateId = stateId;
|
||
if (hasFill)
|
||
{
|
||
FrontLeft = 0;
|
||
FrontTile = spriteId;
|
||
FrontRight = 0;
|
||
}
|
||
_activeStateLabel = hasLabel ? caption : null;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
|
||
/// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
|
||
public bool Vertical { get; set; }
|
||
|
||
/// <summary>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.</summary>
|
||
public bool FillFromBottom { get; set; } = true;
|
||
|
||
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;
|
||
|
||
private static float AlignX(UiMeterLabelAlign align, float width, float textWidth)
|
||
=> align switch
|
||
{
|
||
UiMeterLabelAlign.Left => 0f,
|
||
UiMeterLabelAlign.Right => width - textWidth,
|
||
_ => (width - textWidth) * 0.5f,
|
||
};
|
||
|
||
/// <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);
|
||
}
|
||
|
||
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the vertical fill rect
|
||
/// (local px). <paramref name="fromBottom"/> true → the fill occupies the bottom
|
||
/// <c>h*pct</c> px (retail direction 4); false → the top (direction 2).</summary>
|
||
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();
|
||
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
|
||
|
||
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
||
{
|
||
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.
|
||
//
|
||
// ShowDetail icon overlays ride their authored containers: the dim BACK
|
||
// icon draws over the full track (its 0x100004A9 child draws AFTER the
|
||
// three slice children — higher ReadOrder within the back container);
|
||
// the bright FRONT icon is clipped with the rest of the front layer to
|
||
// the fill fraction (retail clips the whole element-id-2 child).
|
||
bool detail = _detailConfigured
|
||
&& ActiveRetailStateId == RetailUiStateIds.ShowDetail;
|
||
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||
if (detail)
|
||
{
|
||
DrawDetailIcon(
|
||
ctx, resolve, _detailBack.Sprite,
|
||
ComputeDetailOverlayRect(in _detailBack, Width, Height),
|
||
Width);
|
||
}
|
||
if (pct is not null && p > 0f)
|
||
{
|
||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||
if (detail)
|
||
{
|
||
DrawDetailIcon(
|
||
ctx, resolve, _detailFront.Sprite,
|
||
ComputeDetailOverlayRect(in _detailFront, Width, Height),
|
||
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);
|
||
}
|
||
}
|
||
|
||
// A live provider (vitals "cur/max") wins and stays centered;
|
||
// otherwise the active retail-state caption shows with its own
|
||
// authored justification (the powerbar modes author Right).
|
||
string? label = Label();
|
||
UiMeterLabelAlign align = UiMeterLabelAlign.Center;
|
||
if (string.IsNullOrEmpty(label) && _activeStateLabel is { } stateLabel)
|
||
(label, align) = stateLabel;
|
||
if (!string.IsNullOrEmpty(label))
|
||
{
|
||
if (DatFont is { } datFont)
|
||
{
|
||
// Retail path: the dat font's two-pass blit.
|
||
float tw = datFont.MeasureWidth(label);
|
||
float tx = AlignX(align, Width, tw);
|
||
float ty = (Height - datFont.LineHeight) * 0.5f;
|
||
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor, Outline, OutlineColor);
|
||
}
|
||
else if (ctx.DefaultFont is { } font)
|
||
{
|
||
// Fallback: debug bitmap font (no dat font available).
|
||
float tw = font.MeasureWidth(label);
|
||
float tx = AlignX(align, Width, tw);
|
||
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;
|
||
// Only resolve a slice when its id is non-zero. resolve(0) returns the 1x1 MAGENTA
|
||
// placeholder with a NON-ZERO GL handle, so resolving a zero (absent) cap id and then
|
||
// testing `tex != 0` would draw a 1px magenta cap. The single-image meter (toolbar
|
||
// selected-object bar) has no left/right caps (ids 0); the 3-slice vitals meter sets
|
||
// all six ids. Guard on the id, not the resolved handle.
|
||
var (lt, lw, _) = leftId != 0 ? resolve(leftId) : (0u, 0, 0);
|
||
var (mt, mw, _) = midId != 0 ? resolve(midId) : (0u, 0, 0);
|
||
var (rt, rw, _) = rightId != 0 ? resolve(rightId) : (0u, 0, 0);
|
||
|
||
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>Draws a single-tile vertical bar slice. The track (<paramref name="isFill"/>
|
||
/// false) spans the full height; the fill spans <paramref name="visibleH"/> px from the
|
||
/// bottom (<paramref name="fromBottom"/>) 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.</summary>
|
||
private void DrawVBar(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Draws one ShowDetail icon overlay at its authored meter-local rect,
|
||
/// horizontally clipped to <paramref name="clipW"/> local px (the back icon
|
||
/// passes the full width; the front icon passes <c>Width * fraction</c>,
|
||
/// mirroring retail's whole-front-container fill clip). The visible portion
|
||
/// is UV-cropped so the icon reveals left-to-right with the fill. Height is
|
||
/// clamped to the meter's box — retail clips children to the parent rect
|
||
/// (the stamina FRONT overlay authors H=28 in a 16px bar; retail shows 16).
|
||
/// </summary>
|
||
private void DrawDetailIcon(
|
||
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||
uint spriteId, (float X, float Y, float W, float H) rect, float clipW)
|
||
{
|
||
if (spriteId == 0 || rect.W <= 0f || rect.H <= 0f) return;
|
||
var (tex, _, _) = resolve(spriteId);
|
||
if (tex == 0) return;
|
||
float visibleW = MathF.Min(rect.W, clipW - rect.X);
|
||
if (visibleW <= 0f) return;
|
||
float h = MathF.Min(rect.H, Height - rect.Y);
|
||
if (h <= 0f) return;
|
||
float u1 = visibleW / rect.W;
|
||
ctx.DrawSprite(tex, rect.X, rect.Y, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|