feat(D.2b): wire UiHost + live retail Vitals panel (render-only); retire TS-30

Wires the dormant AcDream.App/UI retained-mode tree into GameWindow under
ACDREAM_RETAIL_UI=1: an 8-piece dat-sprite UiNineSlicePanel framing three
UiMeter vital bars bound to the existing VitalsVM. Render-only (UiHost input not
yet bridged to the InputDispatcher — next sub-phase). Coexists with the ImGui
devtools path; no regression there.

Visually verified against a live retail client: the bars match retail's vitals
structure (three stacked horizontal bars, current/max numbers centered) — so the
earlier "orbs" assumption was wrong (retail vitals ARE bars), and stamina is GOLD
not cyan (the #10F0F0 research note was wrong). UiMeter gains a centered numeric
Label (stub debug font for now). Spec §8 + the markup example corrected to match.

Bookkeeping: retired divergence row TS-30 (flat-rect panels -> real dat chrome)
and added IA-15 (our UiHost/markup engine vs keystone.dll's LayoutDesc tree).

Remaining polish (filed, §15): glassy gradient bar fill sprite + the retail dat
font for the numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 16:56:57 +02:00
parent 064ef41ce4
commit b18403da02
4 changed files with 102 additions and 15 deletions

View file

@ -612,6 +612,8 @@ public sealed class GameWindow : IDisposable
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref).
@ -1729,6 +1731,58 @@ public sealed class GameWindow : IDisposable
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl);
// Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing
// UiHost retained-mode tree (dormant until now) + a first vitals panel.
// Render-only: UiHost input is NOT yet bridged to the InputDispatcher
// (next sub-phase), so the close button + window drag are inert. Coexists
// with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged.
if (_options.RetailUi)
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
var cache = _textureCache!;
(uint, int, int) ResolveChrome(uint id)
{
uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h);
return (t, w, h);
}
var panel = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
{ Left = 10, Top = 30, Width = 220, Height = 96 };
panel.AddChild(new AcDream.App.UI.UiLabel
{
Text = "Vitals", Left = 8, Top = 4,
TextColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f),
});
var vm = _vitalsVm!;
panel.AddChild(new AcDream.App.UI.UiMeter
{
Left = 8, Top = 24, Width = 200, Height = 14,
BarColor = new System.Numerics.Vector4(0.78f, 0.05f, 0.05f, 1f), // health red
Fill = () => vm.HealthPercent,
Label = () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : null,
});
panel.AddChild(new AcDream.App.UI.UiMeter
{
Left = 8, Top = 44, Width = 200, Height = 14,
BarColor = new System.Numerics.Vector4(0.83f, 0.62f, 0.12f, 1f), // stamina gold (retail; not cyan)
Fill = () => vm.StaminaPercent,
Label = () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : null,
});
panel.AddChild(new AcDream.App.UI.UiMeter
{
Left = 8, Top = 64, Width = 200, Height = 14,
BarColor = new System.Numerics.Vector4(0.12f, 0.20f, 0.85f, 1f), // mana blue
Fill = () => vm.ManaPercent,
Label = () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : null,
});
_uiHost.Root.AddChild(panel);
Console.WriteLine("[D.2b] retail UI active — vitals panel wired (render-only).");
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
// mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
// always construct.
@ -8150,6 +8204,16 @@ public sealed class GameWindow : IDisposable
SkipWorldGeometry: ;
}
// Phase D.2b — retail-look UI tree (render-only; input integration deferred).
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
// blend/depth state and restores. Drawn before ImGui so the devtools
// overlay composites on top during development.
if (_options.RetailUi && _uiHost is not null)
{
_uiHost.Tick(deltaSeconds);
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
}
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
// so ImGui composites on top. ImGuiController save/restores the
// GL state it touches (blend, scissor, VAO, shader, texture); any
@ -12040,6 +12104,7 @@ public sealed class GameWindow : IDisposable
_sceneLightingUbo?.Dispose();
_particleRenderer?.Dispose();
_debugLines?.Dispose();
_uiHost?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();
_dats?.Dispose();

View file

@ -3,17 +3,26 @@ using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// A horizontal vital bar: an empty background rect with a partial-width
/// fill. <see cref="Fill"/> returns 0..1 (or null = no data → empty bar).
/// Solid-color for Spec 1; the retail orb sprite + scissor crop is a later
/// sub-phase.
/// 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 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
public UiMeter() { ClickThrough = true; }
@ -30,11 +39,21 @@ 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)
{
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) && ctx.DefaultFont is { } font)
{
float tw = font.MeasureWidth(label);
float tx = (Width - tw) * 0.5f;
float ty = (Height - font.LineHeight) * 0.5f;
ctx.DrawString(label, tx, ty, LabelColor);
}
}
}