feat(D.2b): VitalsController — bind live vitals data by element id

Mirrors retail gmVitalsUI::PostInit: grab Health/Stamina/Mana meters from
the imported layout by their dat element ids (0x100000E6 / EC / EE) and
wire Func<float> fill + Func<string> label providers. Missing ids are
silently skipped (no throw). Slice sprites + dat font already set by the
factory — this is pure data wiring, not graphics.

3 TDD tests: single-meter fill+label, all-three distinct providers, missing-id no-throw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 14:06:14 +02:00
parent 9a55a688ca
commit 9d2527d9c8
2 changed files with 166 additions and 0 deletions

View file

@ -0,0 +1,64 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C).
/// Mirrors retail <c>gmVitalsUI::PostInit</c>: grab the three meter elements
/// by their dat element ids and bind live data providers (fill fraction + cur/max
/// text) to each. This is the ONLY per-window code in the whole importer — pure
/// data wiring, not graphics.
///
/// <para>The slice sprites + dat font on each <see cref="UiMeter"/> are already
/// set by <see cref="DatWidgetFactory"/> during tree construction; this controller
/// only binds the dynamic vitals data. Do not touch meter rendering fields here.</para>
/// </summary>
public static class VitalsController
{
/// <summary>Dat element id for the Health meter (0x100000E6).</summary>
public const uint Health = 0x100000E6;
/// <summary>Dat element id for the Stamina meter (0x100000EC).</summary>
public const uint Stamina = 0x100000EC;
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
public const uint Mana = 0x100000EE;
/// <summary>
/// Bind live vitals data providers to the Health, Stamina, and Mana meter
/// elements found in <paramref name="layout"/>. Any meter whose id is absent
/// from the layout is silently skipped — partial layouts (e.g. test fakes)
/// do not cause errors.
/// </summary>
/// <param name="layout">Imported vitals layout tree.</param>
/// <param name="healthPct">Provider returning Health fill fraction [0..1].</param>
/// <param name="staminaPct">Provider returning Stamina fill fraction [0..1].</param>
/// <param name="manaPct">Provider returning Mana fill fraction [0..1].</param>
/// <param name="healthText">Provider returning Health "cur/max" overlay text.</param>
/// <param name="staminaText">Provider returning Stamina "cur/max" overlay text.</param>
/// <param name="manaText">Provider returning Mana "cur/max" overlay text.</param>
public static void Bind(
ImportedLayout layout,
Func<float> healthPct,
Func<float> staminaPct,
Func<float> manaPct,
Func<string> healthText,
Func<string> staminaText,
Func<string> manaText)
{
BindMeter(layout, Health, healthPct, healthText);
BindMeter(layout, Stamina, staminaPct, staminaText);
BindMeter(layout, Mana, manaPct, manaText);
}
private static void BindMeter(
ImportedLayout layout, uint id,
Func<float> pct,
Func<string> text)
{
if (layout.FindElement(id) is UiMeter m)
{
m.Fill = () => pct();
m.Label = () => text();
}
// Silently skip if the id is absent — missing meters are not an error.
}
}

View file

@ -0,0 +1,102 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="VitalsController.Bind"/>: verifies that the controller
/// correctly maps element ids to UiMeter instances and wires the Fill / Label providers.
/// No dats, no GL — pure data-wiring tests.
/// </summary>
public class VitalsBindingTests
{
// ── Test 1: Health meter Fill + Label providers are bound ─────────────────
[Fact]
public void Bind_SetsHealthMeterFillFromProvider()
{
var health = new UiMeter();
var layout = FakeLayout(("0x100000E6", health));
float hp = 0.42f;
VitalsController.Bind(layout,
healthPct: () => hp,
staminaPct: () => 1f,
manaPct: () => 1f,
healthText: () => "42/100",
staminaText: () => "",
manaText: () => "");
Assert.Equal(0.42f, health.Fill()!.Value);
Assert.Equal("42/100", health.Label());
}
// ── Test 2: All three meters wired to distinct providers ──────────────────
[Fact]
public void Bind_AllThreeMeters_EachBoundToOwnProvider()
{
var health = new UiMeter();
var stamina = new UiMeter();
var mana = new UiMeter();
var layout = FakeLayout(
("0x100000E6", health),
("0x100000EC", stamina),
("0x100000EE", mana));
VitalsController.Bind(layout,
healthPct: () => 0.25f,
staminaPct: () => 0.50f,
manaPct: () => 0.75f,
healthText: () => "25/100",
staminaText: () => "50/100",
manaText: () => "75/100");
// Each meter should reflect its own provider, not another's.
Assert.Equal(0.25f, health.Fill()!.Value);
Assert.Equal("25/100", health.Label());
Assert.Equal(0.50f, stamina.Fill()!.Value);
Assert.Equal("50/100", stamina.Label());
Assert.Equal(0.75f, mana.Fill()!.Value);
Assert.Equal("75/100", mana.Label());
}
// ── Test 3: Missing meter ids are silently skipped (no throw) ─────────────
[Fact]
public void Bind_MissingMeterIds_DoesNotThrow()
{
// Only Health is present; Stamina and Mana are absent from the layout.
var health = new UiMeter();
var layout = FakeLayout(("0x100000E6", health));
// Should not throw even though Stamina/Mana are missing.
VitalsController.Bind(layout,
healthPct: () => 1f,
staminaPct: () => 1f,
manaPct: () => 1f,
healthText: () => "100/100",
staminaText: () => "100/100",
manaText: () => "100/100");
// Health was present — it should be wired.
Assert.Equal(1f, health.Fill()!.Value);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items)
{
var dict = new Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var (idHex, e) in items)
{
uint id = Convert.ToUInt32(idHex, 16);
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}