diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index d50c6b46..64eac77a 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -50,6 +50,11 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b8bdbd66..74b8a5d5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1753,42 +1753,11 @@ public sealed class GameWindow : IDisposable var controls = _options.AcDir is { } acDir ? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini")) : AcDream.App.UI.ControlsIni.Parse(string.Empty); - var titleColor = controls.TryColor("title", "color", out var tc) - ? tc : new System.Numerics.Vector4(1f, 1f, 1f, 1f); - - 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 = titleColor, - }); - - 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, - }); - + string vitalsXml = System.IO.File.ReadAllText( + System.IO.Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml")); + var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls); _uiHost.Root.AddChild(panel); - Console.WriteLine("[D.2b] retail UI active — vitals panel wired (render-only)."); + Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); } // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs new file mode 100644 index 00000000..d4b0cb42 --- /dev/null +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -0,0 +1,118 @@ +using System; +using System.Globalization; +using System.Numerics; +using System.Reflection; +using System.Xml.Linq; + +namespace AcDream.App.UI; + +/// +/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) +/// into a live subtree. {Binding} attribute +/// values resolve against a supplied object by property name (reflection). +/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7. +/// +public static class MarkupDocument +{ + /// Raw XML markup for a single panel. + /// Object whose public properties are bound to {PropName} attributes. + /// Surface id → (GL handle, width, height) for chrome sprites. + /// Optional controls.ini stylesheet for the title color. + public static UiNineSlicePanel Build( + string xml, object binding, Func resolve, + ControlsIni? style = null) + { + var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup"); + if (root.Name.LocalName != "panel") + throw new FormatException($"root must be , got <{root.Name.LocalName}>"); + + var panel = new UiNineSlicePanel(resolve) + { + Left = F(root, "x"), + Top = F(root, "y"), + Width = F(root, "w"), + Height = F(root, "h"), + }; + + string? title = (string?)root.Attribute("title"); + if (!string.IsNullOrEmpty(title)) + { + Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One; + panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4, TextColor = tc }); + } + + foreach (var el in root.Elements()) + { + switch (el.Name.LocalName) + { + case "meter": + var cur = BindUint((string?)el.Attribute("cur"), binding); + var max = BindUint((string?)el.Attribute("max"), binding); + panel.AddChild(new UiMeter + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + BarColor = Color((string?)el.Attribute("color")), + Fill = BindFloat((string?)el.Attribute("fill"), binding), + Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, + }); + break; + // future element kinds (label, button, image) added here + } + } + return panel; + } + + private static float F(XElement e, string attr) + => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, + CultureInfo.InvariantCulture, out var v) ? v : 0f; + + /// + /// Parses #AARRGGBB → RGBA (alpha first, matching + /// controls.ini convention). Falls back to opaque white on bad input. + /// + private static Vector4 Color(string? hex) + { + if (hex is { Length: 9 } && hex[0] == '#' + && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out uint argb)) + return new Vector4( + ((argb >> 16) & 0xFF) / 255f, + ((argb >> 8) & 0xFF) / 255f, + (argb & 0xFF) / 255f, + ((argb >> 24) & 0xFF) / 255f); + return Vector4.One; + } + + private static Func BindFloat(string? expr, object binding) + { + var pi = Prop(expr, binding); + if (pi is null) return () => 0f; + return () => pi.GetValue(binding) switch + { + float f => f, + null => (float?)null, + var v => Convert.ToSingle(v, CultureInfo.InvariantCulture), + }; + } + + private static Func BindUint(string? expr, object binding) + { + var pi = Prop(expr, binding); + if (pi is null) return () => null; + return () => pi.GetValue(binding) switch + { + uint u => u, + null => (uint?)null, + var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture), + }; + } + + private static PropertyInfo? Prop(string? expr, object binding) + { + if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null; + return binding.GetType().GetProperty(expr[1..^1]); + } +} diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml new file mode 100644 index 00000000..868926d4 --- /dev/null +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs new file mode 100644 index 00000000..8ba52d27 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -0,0 +1,50 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class MarkupDocumentTests +{ + private sealed class FakeBinding + { + public float HealthPercent => 0.5f; + public uint? HealthCurrent => 109; + public uint? HealthMax => 218; + public float? ManaPercent => null; + public uint? ManaCurrent => null; + public uint? ManaMax => null; + } + + [Fact] + public void Build_CreatesPanelWithMeterFillLabelAndGeometry() + { + const string xml = + "" + + " " + + ""; + + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + + Assert.IsType(panel); + Assert.Equal(10f, panel.Left); + Assert.Equal(220f, panel.Width); + Assert.Equal(2, panel.Children.Count); // title UiLabel + 1 meter + var meter = Assert.IsType(panel.Children[1]); + Assert.Equal(8f, meter.Left); + Assert.Equal(200f, meter.Width); + Assert.Equal(0.5f, meter.Fill()); + Assert.Equal("109/218", meter.Label()); + } + + [Fact] + public void Build_NullBindingValuesYieldNullFillAndLabel() + { + const string xml = + "" + + " " + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + var meter = Assert.IsType(panel.Children[1]); + Assert.Null(meter.Fill()); + Assert.Null(meter.Label()); + } +}