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:
parent
97bd1d2f09
commit
07bf6cbf60
5 changed files with 182 additions and 35 deletions
|
|
@ -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/ -->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
118
src/AcDream.App/UI/MarkupDocument.cs
Normal file
118
src/AcDream.App/UI/MarkupDocument.cs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/AcDream.App/UI/assets/vitals.xml
Normal file
5
src/AcDream.App/UI/assets/vitals.xml
Normal 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>
|
||||||
50
tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
Normal file
50
tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue