Render each vital bar as a horizontal 3-slice from the real retail RenderSurface sprites (authoritative ids from the vitals LayoutDesc 0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a stretched glassy-gradient middle, and a fixed-width right-cap. The empty back track draws full width; the coloured front fill grows from the left to the value (the track owns the right end, so the fill omits its own right-cap). Replaces the flat single-sprite Alphablend overlay that read as the old UI - this is the bordered gradient look from the retail screenshot (red HP / gold stamina / blue mana). UiMeter gains the six 9-slice ids (BackLeft/Tile/Right + FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the backleft/backtile/backright/frontleft/fronttile/frontright attrs; vitals.xml carries the 18 per-vital ids. The temporary ACDREAM_BAR_PROVEOUT component grid is removed. Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite that assembles the bars with the SAME DrawHBar logic, so the sprite assembly can be verified by eye (Read the PNG) without launching the client + server - the fast UI-iteration loop the user asked for. export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
6.5 KiB
C#
159 lines
6.5 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,
|
|
BackLeft = Hex((string?)el.Attribute("backleft")),
|
|
BackTile = Hex((string?)el.Attribute("backtile")),
|
|
BackRight = Hex((string?)el.Attribute("backright")),
|
|
FrontLeft = Hex((string?)el.Attribute("frontleft")),
|
|
FrontTile = Hex((string?)el.Attribute("fronttile")),
|
|
FrontRight = Hex((string?)el.Attribute("frontright")),
|
|
});
|
|
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;
|
|
}
|
|
}
|