feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -14,6 +14,12 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public static class MarkupDocument
|
||||
{
|
||||
// Retail's generic runtime-text tooltip skin. Plugin controls have no
|
||||
// LayoutDesc of their own, so a tooltip= attribute explicitly opts them
|
||||
// into the same popup that game-code SetTooltip call sites use.
|
||||
private const uint RuntimeTooltipRootElementId = 0x10000397u;
|
||||
private const uint RuntimeTooltipLayoutDid = 0x21000041u;
|
||||
|
||||
/// <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>
|
||||
|
|
@ -74,13 +80,47 @@ public static class MarkupDocument
|
|||
}
|
||||
|
||||
foreach (var el in root.Elements())
|
||||
AddElement(panel, el, binding, resolve, datFont);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static void AddElement(
|
||||
UiElement parent,
|
||||
XElement el,
|
||||
object binding,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
switch (el.Name.LocalName)
|
||||
{
|
||||
switch (el.Name.LocalName)
|
||||
{
|
||||
case "meter":
|
||||
case "group":
|
||||
var group = new UiPanel
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
BackgroundColor = el.Attribute("background") is null
|
||||
? Vector4.Zero
|
||||
: Color((string?)el.Attribute("background")),
|
||||
BorderColor = el.Attribute("border") is null
|
||||
? Vector4.Zero
|
||||
: Color((string?)el.Attribute("border")),
|
||||
BorderThickness = el.Attribute("border") is null ? 0f : 1f,
|
||||
// Transparent layout groups do not claim empty space, while
|
||||
// their interactive descendants remain hittable.
|
||||
ClickThrough = true,
|
||||
};
|
||||
ApplyCommon(group, el, binding);
|
||||
parent.AddChild(group);
|
||||
foreach (XElement child in el.Elements())
|
||||
AddElement(group, child, binding, resolve, datFont);
|
||||
break;
|
||||
|
||||
case "meter":
|
||||
var cur = BindUint((string?)el.Attribute("cur"), binding);
|
||||
var max = BindUint((string?)el.Attribute("max"), binding);
|
||||
panel.AddChild(new UiMeter
|
||||
var meter = new UiMeter
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
|
|
@ -97,10 +137,12 @@ public static class MarkupDocument
|
|||
FrontLeft = Hex((string?)el.Attribute("frontleft")),
|
||||
FrontTile = Hex((string?)el.Attribute("fronttile")),
|
||||
FrontRight = Hex((string?)el.Attribute("frontright")),
|
||||
});
|
||||
};
|
||||
ApplyCommon(meter, el, binding);
|
||||
parent.AddChild(meter);
|
||||
break;
|
||||
|
||||
case "label":
|
||||
case "label":
|
||||
// Text may be a literal or a {Binding}. Bound labels re-read
|
||||
// their property every frame through the Func, so a plugin
|
||||
// updates its status line by assigning a property rather
|
||||
|
|
@ -114,10 +156,11 @@ public static class MarkupDocument
|
|||
};
|
||||
if (el.Attribute("color") is not null)
|
||||
label.TextColor = Color((string?)el.Attribute("color"));
|
||||
panel.AddChild(label);
|
||||
ApplyCommon(label, el, binding);
|
||||
parent.AddChild(label);
|
||||
break;
|
||||
|
||||
case "button":
|
||||
case "button":
|
||||
// onclick binds to an Action property on the binding
|
||||
// object. Resolved once at build time: a button whose
|
||||
// handler silently failed to bind is a bug worth failing
|
||||
|
|
@ -151,13 +194,243 @@ public static class MarkupDocument
|
|||
button.TextSource = BindString(caption, binding);
|
||||
if (el.Attribute("color") is not null)
|
||||
button.TextColor = Color((string?)el.Attribute("color"));
|
||||
if (el.Attribute("background") is not null)
|
||||
button.BackgroundColor = Color(
|
||||
(string?)el.Attribute("background"));
|
||||
if (el.Attribute("border") is not null)
|
||||
button.BorderColor = Color(
|
||||
(string?)el.Attribute("border"));
|
||||
ApplyCommon(button, el, binding);
|
||||
if (onClick is not null)
|
||||
button.Click += onClick;
|
||||
panel.AddChild(button);
|
||||
parent.AddChild(button);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tab":
|
||||
string? tabClickName = (string?)el.Attribute("onclick");
|
||||
Action? tabClick = BindAction(tabClickName, binding);
|
||||
if (tabClickName is not null && tabClick is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<tab onclick=\"{tabClickName}\"> did not resolve to an "
|
||||
+ $"Action property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
var tab = new UiMarkupTabButton
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
Text = (string?)el.Attribute("text") ?? string.Empty,
|
||||
DatFont = datFont,
|
||||
SelectedSource = BindRequiredBoolReader(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding,
|
||||
"tab selected"),
|
||||
};
|
||||
ApplyCommon(tab, el, binding);
|
||||
if (tabClick is not null)
|
||||
tab.Click += tabClick;
|
||||
parent.AddChild(tab);
|
||||
break;
|
||||
|
||||
case "toggle":
|
||||
string? toggleClickName = (string?)el.Attribute("onclick");
|
||||
Action? toggleClick = BindAction(toggleClickName, binding);
|
||||
if (toggleClickName is not null && toggleClick is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<toggle onclick=\"{toggleClickName}\"> did not resolve to an "
|
||||
+ $"Action property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
string? toggleCaption = (string?)el.Attribute("text");
|
||||
var toggle = new UiMarkupToggle
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
Text = toggleCaption ?? string.Empty,
|
||||
TextSource = BindString(toggleCaption, binding),
|
||||
CheckedSource = BindRequiredBoolReader(
|
||||
(string?)el.Attribute("checked"),
|
||||
binding,
|
||||
"toggle checked"),
|
||||
DatFont = datFont,
|
||||
Toggle = toggleClick,
|
||||
};
|
||||
if (el.Attribute("color") is not null)
|
||||
toggle.TextColor = Color((string?)el.Attribute("color"));
|
||||
ApplyCommon(toggle, el, binding);
|
||||
parent.AddChild(toggle);
|
||||
break;
|
||||
|
||||
case "slider":
|
||||
string? changeName = (string?)el.Attribute("onchange");
|
||||
Action<float>? changed = BindFloatAction(changeName, binding);
|
||||
if (changeName is not null && changed is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<slider onchange=\"{changeName}\"> did not resolve to an "
|
||||
+ $"Action<float> property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
var slider = new UiScrollbar
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
Horizontal = true,
|
||||
SpriteResolve = resolve,
|
||||
ScalarPositionSource = BindFloat(
|
||||
(string?)el.Attribute("value"),
|
||||
binding),
|
||||
ScalarChanged = changed,
|
||||
};
|
||||
RetailScrollbarChrome.ApplyHorizontal(slider);
|
||||
ApplyCommon(slider, el, binding);
|
||||
parent.AddChild(slider);
|
||||
break;
|
||||
|
||||
case "field":
|
||||
string? fieldChangeName = (string?)el.Attribute("onchange");
|
||||
Action<string>? fieldChanged = BindStringAction(
|
||||
fieldChangeName,
|
||||
binding);
|
||||
if (fieldChangeName is not null && fieldChanged is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<field onchange=\"{fieldChangeName}\"> did not resolve to an "
|
||||
+ $"Action<string> property on {binding.GetType().Name}");
|
||||
}
|
||||
string? submitName = (string?)el.Attribute("onsubmit");
|
||||
Action<string>? submitted = BindStringAction(submitName, binding);
|
||||
if (submitName is not null && submitted is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<field onsubmit=\"{submitName}\"> did not resolve to an "
|
||||
+ $"Action<string> property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
var field = new UiField
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
DatFont = datFont,
|
||||
BackgroundColor = el.Attribute("background") is null
|
||||
? new Vector4(0f, 0f, 0f, 0.9f)
|
||||
: Color((string?)el.Attribute("background")),
|
||||
TextColor = el.Attribute("color") is null
|
||||
? new Vector4(0.91f, 0.87f, 0.76f, 1f)
|
||||
: Color((string?)el.Attribute("color")),
|
||||
MaxCharacters = Math.Max(1, I(el, "maxlength", 128)),
|
||||
ClearOnSubmit = B(el, "clearonsubmit", false),
|
||||
RecordHistory = false,
|
||||
OnTextChanged = fieldChanged,
|
||||
OnSubmit = submitted,
|
||||
};
|
||||
field.SetText(BindString((string?)el.Attribute("text"), binding)());
|
||||
ApplyCommon(field, el, binding);
|
||||
parent.AddChild(field);
|
||||
break;
|
||||
|
||||
case "menu":
|
||||
string? menuChangeName = (string?)el.Attribute("onchange");
|
||||
Action<string>? menuChanged = BindStringAction(
|
||||
menuChangeName,
|
||||
binding);
|
||||
if (menuChangeName is not null && menuChanged is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<menu onchange=\"{menuChangeName}\"> did not resolve to an "
|
||||
+ $"Action<string> property on {binding.GetType().Name}");
|
||||
}
|
||||
Func<IReadOnlyList<string>> menuItems = BindStringList(
|
||||
(string?)el.Attribute("items"),
|
||||
binding,
|
||||
"menu items");
|
||||
Func<string?> menuSelected = BindString(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding);
|
||||
var menu = new UiMenu
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
DatFont = datFont,
|
||||
SpriteResolve = resolve,
|
||||
RowsPerColumn = Math.Max(1, I(el, "rows", 7)),
|
||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||
ColumnWidth = Math.Max(20f, F(el, "w")),
|
||||
OpenUpward = B(el, "openupward", false),
|
||||
TextIndent = 6f,
|
||||
ButtonTextIndent = 6f,
|
||||
NormalSprite = 0x06004D65u,
|
||||
PressedSprite = 0x06004D66u,
|
||||
PopupBgSprite = 0x0600124Cu,
|
||||
ItemNormalSprite = 0x0600124Eu,
|
||||
ItemHighlightSprite = 0x0600124Du,
|
||||
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
||||
OnSelect = payload =>
|
||||
{
|
||||
if (payload is string value)
|
||||
menuChanged?.Invoke(value);
|
||||
},
|
||||
};
|
||||
void RefreshMenu()
|
||||
{
|
||||
menu.Items = menuItems()
|
||||
.Select(static value => new UiMenu.MenuItem(value, value))
|
||||
.ToArray();
|
||||
menu.Selected = menuSelected();
|
||||
}
|
||||
RefreshMenu();
|
||||
menu.BeforeOpen = RefreshMenu;
|
||||
ApplyCommon(menu, el, binding);
|
||||
parent.AddChild(menu);
|
||||
break;
|
||||
|
||||
case "list":
|
||||
string? listChangeName = (string?)el.Attribute("onchange");
|
||||
Action<int>? listChanged = BindIntAction(listChangeName, binding);
|
||||
if (listChangeName is not null && listChanged is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
|
||||
+ $"Action<int> property on {binding.GetType().Name}");
|
||||
}
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||
DatFont = datFont,
|
||||
ItemsSource = BindStringList(
|
||||
(string?)el.Attribute("items"),
|
||||
binding,
|
||||
"list items"),
|
||||
ItemColorsSource = BindUintList(
|
||||
(string?)el.Attribute("colors"),
|
||||
binding,
|
||||
"list colors"),
|
||||
SelectedIndexSource = BindRequiredIntReader(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding,
|
||||
"list selected"),
|
||||
SelectionChanged = listChanged,
|
||||
};
|
||||
ApplyCommon(list, el, binding);
|
||||
parent.AddChild(list);
|
||||
break;
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -197,13 +470,197 @@ public static class MarkupDocument
|
|||
return () => (property.GetValue(binding) as Action)?.Invoke();
|
||||
}
|
||||
|
||||
private static Action<float>? BindFloatAction(
|
||||
string? attribute,
|
||||
object binding)
|
||||
{
|
||||
if (attribute is null || !IsBinding(attribute))
|
||||
return null;
|
||||
|
||||
string name = attribute[1..^1];
|
||||
PropertyInfo? property = binding.GetType().GetProperty(name);
|
||||
if (property is null
|
||||
|| !typeof(Action<float>).IsAssignableFrom(property.PropertyType))
|
||||
return null;
|
||||
return value => (property.GetValue(binding) as Action<float>)?.Invoke(value);
|
||||
}
|
||||
|
||||
private static Action<string>? BindStringAction(
|
||||
string? attribute,
|
||||
object binding)
|
||||
{
|
||||
if (attribute is null || !IsBinding(attribute))
|
||||
return null;
|
||||
|
||||
string name = attribute[1..^1];
|
||||
PropertyInfo? property = binding.GetType().GetProperty(name);
|
||||
if (property is null
|
||||
|| !typeof(Action<string>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return value => (property.GetValue(binding) as Action<string>)?.Invoke(value);
|
||||
}
|
||||
|
||||
private static Action<int>? BindIntAction(string? attribute, object binding)
|
||||
{
|
||||
if (attribute is null || !IsBinding(attribute))
|
||||
return null;
|
||||
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
|
||||
if (property is null
|
||||
|| !typeof(Action<int>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
|
||||
}
|
||||
|
||||
private static Func<IReadOnlyList<string>> BindStringList(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
||||
throw new FormatException($"{context} must be a string-list binding");
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null
|
||||
|| !typeof(IEnumerable<string>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to an IEnumerable<string> property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is IEnumerable<string> values
|
||||
? values.ToArray()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static Func<IReadOnlyList<uint>> BindUintList(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return static () => Array.Empty<uint>();
|
||||
if (!IsBinding(expression))
|
||||
throw new FormatException($"{context} must be a uint-list binding");
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null
|
||||
|| !typeof(IEnumerable<uint>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to an IEnumerable<uint> property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is IEnumerable<uint> values
|
||||
? values.ToArray()
|
||||
: Array.Empty<uint>();
|
||||
}
|
||||
|
||||
private static bool IsBinding(string value) =>
|
||||
value.Length > 2 && value[0] == '{' && value[^1] == '}';
|
||||
|
||||
private static void ApplyCommon(
|
||||
UiElement element,
|
||||
XElement source,
|
||||
object binding)
|
||||
{
|
||||
element.Name = (string?)source.Attribute("name")
|
||||
?? (string?)source.Attribute("id");
|
||||
BindBool((string?)source.Attribute("visible"), binding,
|
||||
value => element.Visible = value,
|
||||
sourceReader => element.VisibleSource = sourceReader);
|
||||
BindBool((string?)source.Attribute("enabled"), binding,
|
||||
value => element.Enabled = value,
|
||||
sourceReader => element.EnabledSource = sourceReader);
|
||||
|
||||
string? tooltip = (string?)source.Attribute("tooltip");
|
||||
if (!string.IsNullOrWhiteSpace(tooltip))
|
||||
{
|
||||
element.RuntimeTooltipTextSource = BindString(tooltip, binding);
|
||||
element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId;
|
||||
element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid;
|
||||
element.AuthoredTooltipEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BindBool(
|
||||
string? expression,
|
||||
object binding,
|
||||
Action<bool> setLiteral,
|
||||
Action<Func<bool>> setSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return;
|
||||
if (!IsBinding(expression))
|
||||
{
|
||||
if (bool.TryParse(expression, out bool literal))
|
||||
setLiteral(literal);
|
||||
return;
|
||||
}
|
||||
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null || property.PropertyType != typeof(bool))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to a bool property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
setSource(() => property.GetValue(binding) is true);
|
||||
}
|
||||
|
||||
private static Func<bool> BindRequiredBoolReader(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
||||
throw new FormatException($"{context} must be a bool binding");
|
||||
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null || property.PropertyType != typeof(bool))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to a bool property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is true;
|
||||
}
|
||||
|
||||
private static Func<int> BindRequiredIntReader(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
||||
throw new FormatException($"{context} must be an int binding");
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null || property.PropertyType != typeof(int))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to an int property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is int value ? value : -1;
|
||||
}
|
||||
|
||||
private static float F(XElement e, string attr)
|
||||
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var v) ? v : 0f;
|
||||
|
||||
private static float FOr(XElement e, string attr, float fallback)
|
||||
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out float value) ? value : fallback;
|
||||
|
||||
private static int I(XElement e, string attr, int fallback)
|
||||
=> int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture, out int value) ? value : fallback;
|
||||
|
||||
private static bool B(XElement e, string attr, bool fallback)
|
||||
=> bool.TryParse((string?)e.Attribute(attr), out bool value)
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
|
||||
/// controls.ini convention). Falls back to opaque white on bad input.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue