feat(D.2b): MarkupDocument (XML -> UiElement tree); vitals panel from vitals.xml

Implements Task 8 of the D.2b retail-UI plan. MarkupDocument.Build() parses
KSML-style panel markup into a live UiNineSlicePanel subtree, resolving
{Binding} attribute expressions against a supplied object via reflection.
Color format is #AARRGGBB (alpha-first, matching controls.ini). Handles
<panel> root (geometry + optional title label) and <meter> children (fill,
label, bar color). Future element kinds (label, button, image) extend the
switch without touching existing code.

vitals.xml encodes the just-approved vitals panel layout (health red #FFC70D0D,
stamina gold #FFD49E1F, mana blue #FF1F33D9); ships next to the binary via
PreserveNewest csproj rule. GameWindow.cs drops the 35-line hand-built panel
block in favour of a 4-line File.ReadAllText + MarkupDocument.Build call —
identical tree, identical render, now data-driven.

2 new tests (Build_CreatesPanelWithMeterFillLabelAndGeometry,
Build_NullBindingValuesYieldNullFillAndLabel) + 11 total targeted green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 17:38:07 +02:00
parent 97bd1d2f09
commit 07bf6cbf60
5 changed files with 182 additions and 35 deletions

View file

@ -50,6 +50,11 @@
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv"> <None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
next to the binary so MarkupDocument.Build can load them at runtime. -->
<None Include="UI\assets\**\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ --> <!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->

View file

@ -1753,42 +1753,11 @@ public sealed class GameWindow : IDisposable
var controls = _options.AcDir is { } acDir var controls = _options.AcDir is { } acDir
? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini")) ? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini"))
: AcDream.App.UI.ControlsIni.Parse(string.Empty); : AcDream.App.UI.ControlsIni.Parse(string.Empty);
var titleColor = controls.TryColor("title", "color", out var tc) string vitalsXml = System.IO.File.ReadAllText(
? tc : new System.Numerics.Vector4(1f, 1f, 1f, 1f); System.IO.Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"));
var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
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,
});
_uiHost.Root.AddChild(panel); _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 // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is

View file

@ -0,0 +1,118 @@
using System;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Xml.Linq;
namespace AcDream.App.UI;
/// <summary>
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
/// into a live <see cref="UiElement"/> subtree. <c>{Binding}</c> 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.
/// </summary>
public static class MarkupDocument
{
/// <param name="xml">Raw XML markup for a single panel.</param>
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> 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 <panel>, 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;
/// <summary>
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
/// controls.ini convention). Falls back to opaque white on bad input.
/// </summary>
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<float?> 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<uint?> 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]);
}
}

View file

@ -0,0 +1,5 @@
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals">
<meter id="health" x="8" y="24" w="200" h="14" fill="{HealthPercent}" cur="{HealthCurrent}" max="{HealthMax}" color="#FFC70D0D"/>
<meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F"/>
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9"/>
</panel>

View file

@ -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 =
"<panel id=\"acdream.vitals\" x=\"10\" y=\"30\" w=\"220\" h=\"96\" title=\"Vitals\">" +
" <meter id=\"health\" x=\"8\" y=\"24\" w=\"200\" h=\"14\" fill=\"{HealthPercent}\" cur=\"{HealthCurrent}\" max=\"{HealthMax}\" color=\"#FFFF0000\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
Assert.IsType<UiNineSlicePanel>(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<UiMeter>(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 =
"<panel id=\"v\" x=\"0\" y=\"0\" w=\"10\" h=\"10\" title=\"V\">" +
" <meter id=\"mana\" x=\"0\" y=\"0\" w=\"10\" h=\"2\" fill=\"{ManaPercent}\" cur=\"{ManaCurrent}\" max=\"{ManaMax}\" color=\"#FF0000FF\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
var meter = Assert.IsType<UiMeter>(panel.Children[1]);
Assert.Null(meter.Fill());
Assert.Null(meter.Label());
}
}