diff --git a/src/AcDream.App/UI/Layout/VitalsController.cs b/src/AcDream.App/UI/Layout/VitalsController.cs new file mode 100644 index 00000000..a455761a --- /dev/null +++ b/src/AcDream.App/UI/Layout/VitalsController.cs @@ -0,0 +1,64 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C). +/// Mirrors retail gmVitalsUI::PostInit: 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. +/// +/// The slice sprites + dat font on each are already +/// set by during tree construction; this controller +/// only binds the dynamic vitals data. Do not touch meter rendering fields here. +/// +public static class VitalsController +{ + /// Dat element id for the Health meter (0x100000E6). + public const uint Health = 0x100000E6; + /// Dat element id for the Stamina meter (0x100000EC). + public const uint Stamina = 0x100000EC; + /// Dat element id for the Mana meter (0x100000EE). + public const uint Mana = 0x100000EE; + + /// + /// Bind live vitals data providers to the Health, Stamina, and Mana meter + /// elements found in . Any meter whose id is absent + /// from the layout is silently skipped — partial layouts (e.g. test fakes) + /// do not cause errors. + /// + /// Imported vitals layout tree. + /// Provider returning Health fill fraction [0..1]. + /// Provider returning Stamina fill fraction [0..1]. + /// Provider returning Mana fill fraction [0..1]. + /// Provider returning Health "cur/max" overlay text. + /// Provider returning Stamina "cur/max" overlay text. + /// Provider returning Mana "cur/max" overlay text. + public static void Bind( + ImportedLayout layout, + Func healthPct, + Func staminaPct, + Func manaPct, + Func healthText, + Func staminaText, + Func 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 pct, + Func 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. + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs new file mode 100644 index 00000000..8b430265 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs @@ -0,0 +1,102 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Unit tests for : 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. +/// +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(); + 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); + } +}