UiMeter gains SpriteResolve/BackSpriteId/FrontSpriteId; when both are set, OnDraw draws the empty-track sprite full-width then the colored-fill sprite UV-cropped to the live fill fraction (left-to-right drain). Falls back to solid rects when sprite ids are absent, keeping existing behavior and tests intact. MarkupDocument.Build() parses `back`/`front` hex attrs on <meter> and passes `resolve` into every UiMeter. vitals.xml wires the authoritative LayoutDesc 0x21000014 sprites (Health 0x06005F3C/3D, Stamina 3E/3F, Mana 40/41). The bar prove-out block in GameWindow.cs was already gone. If the sprites decode as 1x1 magenta at runtime they are paletted (INDEX16/P8) — the solid-color fallback will display instead and can be investigated separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
6.2 KiB
C#
155 lines
6.2 KiB
C#
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"),
|
|
};
|
|
|
|
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
|
|
string? resize = (string?)root.Attribute("resize");
|
|
if (resize is not null)
|
|
{
|
|
panel.ResizeX = resize is "x" or "both";
|
|
panel.ResizeY = resize is "y" or "both";
|
|
}
|
|
|
|
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,
|
|
Anchors = Anchor((string?)el.Attribute("anchor")),
|
|
SpriteResolve = resolve,
|
|
BackSpriteId = Hex((string?)el.Attribute("back")),
|
|
FrontSpriteId = Hex((string?)el.Attribute("front")),
|
|
});
|
|
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]);
|
|
}
|
|
|
|
private static uint Hex(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) return 0;
|
|
var t = s.Trim();
|
|
if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..];
|
|
return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber,
|
|
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
|
|
}
|
|
|
|
private static AnchorEdges Anchor(string? csv)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
|
|
var a = AnchorEdges.None;
|
|
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
|
|
a |= part.ToLowerInvariant() switch
|
|
{
|
|
"left" => AnchorEdges.Left,
|
|
"top" => AnchorEdges.Top,
|
|
"right" => AnchorEdges.Right,
|
|
"bottom" => AnchorEdges.Bottom,
|
|
_ => AnchorEdges.None,
|
|
};
|
|
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
|
|
}
|
|
}
|