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
355
src/AcDream.App/UI/PluginSidePanel.cs
Normal file
355
src/AcDream.App/UI/PluginSidePanel.cs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
|
||||
/// presentation visibility; it never touches plugin enable/session lifetime.
|
||||
/// </summary>
|
||||
public sealed class PluginSidePanel : UiPanel, IDisposable
|
||||
{
|
||||
private const float OuterPadding = 4f;
|
||||
private const float ButtonExtent = 28f;
|
||||
private const float ButtonGap = 4f;
|
||||
private const float DefaultTop = 116f;
|
||||
|
||||
private readonly RetailWindowManager _windows;
|
||||
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
|
||||
private readonly UiDatFont? _font;
|
||||
private readonly Dictionary<RetailWindowHandle, ShelfEntry> _entries = [];
|
||||
private bool _disposed;
|
||||
private float _lastLayoutHeight = -1f;
|
||||
|
||||
public PluginSidePanel(
|
||||
RetailWindowManager windows,
|
||||
Func<uint, (uint tex, int width, int height)> resolve,
|
||||
UiDatFont? font)
|
||||
{
|
||||
_windows = windows ?? throw new ArgumentNullException(nameof(windows));
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
_font = font;
|
||||
|
||||
Width = ButtonExtent + OuterPadding * 2f;
|
||||
Height = OuterPadding * 2f;
|
||||
Top = DefaultTop;
|
||||
Anchors = AnchorEdges.None;
|
||||
Draggable = false;
|
||||
Resizable = false;
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f);
|
||||
BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f);
|
||||
BorderThickness = 1f;
|
||||
Visible = false;
|
||||
|
||||
_windows.WindowUnregistered += OnWindowUnregistered;
|
||||
}
|
||||
|
||||
/// <summary>Number of live plugin-window entries, exposed for gates.</summary>
|
||||
public int EntryCount => _entries.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Adds one manifest-scoped plugin window and its minimize affordance.
|
||||
/// Duplicate handles are idempotent.
|
||||
/// </summary>
|
||||
public void Add(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
RetailWindowHandle handle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
if (_entries.ContainsKey(handle))
|
||||
return;
|
||||
|
||||
// Plugin windows are ordinary retained windows, but unlike imported
|
||||
// retail windows they have no authored MoveTo override. Keep their
|
||||
// chrome reachable at the minimum 800x600 canvas and after a display
|
||||
// resize. An oversized window follows retail's top-left-priority rule:
|
||||
// pin to zero rather than stranding the title/minimize controls.
|
||||
handle.OuterFrame.ConstrainDragToParent = true;
|
||||
handle.OuterFrame.ConstrainResizeToParent = true;
|
||||
KeepWindowReachable(handle);
|
||||
|
||||
var button = new PluginShelfButton(
|
||||
descriptor,
|
||||
owner.DisplayName,
|
||||
handle,
|
||||
_resolve,
|
||||
_font)
|
||||
{
|
||||
Width = ButtonExtent,
|
||||
Height = ButtonExtent,
|
||||
};
|
||||
button.Click += () =>
|
||||
{
|
||||
if (handle.IsVisible)
|
||||
handle.Hide();
|
||||
else
|
||||
handle.Show();
|
||||
};
|
||||
|
||||
var minimize = new PluginMinimizeButton(handle, _font)
|
||||
{
|
||||
Left = MathF.Max(8f, handle.OuterFrame.Width - 23f),
|
||||
Top = 3f,
|
||||
Width = 18f,
|
||||
Height = 17f,
|
||||
Anchors = AnchorEdges.Top | AnchorEdges.Right,
|
||||
};
|
||||
handle.OuterFrame.AddChild(minimize);
|
||||
|
||||
_entries.Add(handle, new ShelfEntry(button, minimize));
|
||||
AddChild(button);
|
||||
Reflow();
|
||||
}
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
|
||||
// Screen-edge dock: root bounds become authoritative at draw time, so
|
||||
// compute this from the live parent rather than capturing an anchor
|
||||
// margin while the pre-first-frame root still measures 0x0.
|
||||
if (Parent is { } parent)
|
||||
{
|
||||
float availableHeight = MathF.Max(
|
||||
ButtonExtent + OuterPadding * 2f,
|
||||
parent.Height - Top - OuterPadding);
|
||||
if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f)
|
||||
{
|
||||
_lastLayoutHeight = availableHeight;
|
||||
Reflow(availableHeight);
|
||||
}
|
||||
Left = MathF.Max(0f, parent.Width - Width - OuterPadding);
|
||||
}
|
||||
|
||||
foreach (RetailWindowHandle handle in _entries.Keys)
|
||||
KeepWindowReachable(handle);
|
||||
|
||||
// The shelf remains reachable even after ordinary windows are raised.
|
||||
if (Parent is { } root)
|
||||
{
|
||||
int highest = 0;
|
||||
foreach (UiElement sibling in root.Children)
|
||||
{
|
||||
if (!ReferenceEquals(sibling, this))
|
||||
highest = Math.Max(highest, sibling.ZOrder);
|
||||
}
|
||||
if (ZOrder <= highest)
|
||||
ZOrder = highest == int.MaxValue ? highest : highest + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowUnregistered(RetailWindowHandle handle)
|
||||
{
|
||||
if (!_entries.Remove(handle, out ShelfEntry entry))
|
||||
return;
|
||||
|
||||
RemoveChild(entry.Button);
|
||||
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
|
||||
handle.OuterFrame.RemoveChild(entry.Minimize);
|
||||
entry.Button.DisposeSubscriptions();
|
||||
Reflow();
|
||||
}
|
||||
|
||||
private static void KeepWindowReachable(RetailWindowHandle handle)
|
||||
{
|
||||
if (handle.OuterFrame.Parent is not { } parent
|
||||
|| parent.Width <= 0f
|
||||
|| parent.Height <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float left = Math.Clamp(
|
||||
handle.Left,
|
||||
0f,
|
||||
MathF.Max(0f, parent.Width - handle.Width));
|
||||
float top = Math.Clamp(
|
||||
handle.Top,
|
||||
0f,
|
||||
MathF.Max(0f, parent.Height - handle.Height));
|
||||
if (left != handle.Left || top != handle.Top)
|
||||
handle.MoveTo(left, top);
|
||||
}
|
||||
|
||||
private void Reflow(float maximumHeight = float.PositiveInfinity)
|
||||
{
|
||||
int maximumRows = float.IsPositiveInfinity(maximumHeight)
|
||||
? Math.Max(1, _entries.Count)
|
||||
: Math.Max(
|
||||
1,
|
||||
(int)MathF.Floor(
|
||||
(maximumHeight - OuterPadding * 2f + ButtonGap)
|
||||
/ (ButtonExtent + ButtonGap)));
|
||||
int index = 0;
|
||||
foreach (ShelfEntry entry in _entries.Values)
|
||||
{
|
||||
int column = index / maximumRows;
|
||||
int row = index % maximumRows;
|
||||
entry.Button.Left = OuterPadding
|
||||
+ column * (ButtonExtent + ButtonGap);
|
||||
entry.Button.Top = OuterPadding
|
||||
+ row * (ButtonExtent + ButtonGap);
|
||||
index++;
|
||||
}
|
||||
|
||||
int rows = Math.Min(index, maximumRows);
|
||||
int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows;
|
||||
Width = OuterPadding * 2f
|
||||
+ columns * ButtonExtent
|
||||
+ Math.Max(0, columns - 1) * ButtonGap;
|
||||
Height = OuterPadding * 2f
|
||||
+ rows * ButtonExtent
|
||||
+ Math.Max(0, rows - 1) * ButtonGap;
|
||||
Visible = index > 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_windows.WindowUnregistered -= OnWindowUnregistered;
|
||||
|
||||
foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries)
|
||||
{
|
||||
entry.Button.DisposeSubscriptions();
|
||||
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
|
||||
handle.OuterFrame.RemoveChild(entry.Minimize);
|
||||
}
|
||||
_entries.Clear();
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
private readonly record struct ShelfEntry(
|
||||
PluginShelfButton Button,
|
||||
PluginMinimizeButton Minimize);
|
||||
|
||||
private sealed class PluginShelfButton : UiSimpleButton
|
||||
{
|
||||
private static readonly Vector4 HiddenBackground =
|
||||
new(0.025f, 0.025f, 0.02f, 0.96f);
|
||||
private static readonly Vector4 VisibleBackground =
|
||||
new(0.09f, 0.19f, 0.055f, 0.96f);
|
||||
private static readonly Vector4 HiddenBorder =
|
||||
new(0.48f, 0.38f, 0.14f, 1f);
|
||||
private static readonly Vector4 VisibleBorder =
|
||||
new(0.76f, 0.64f, 0.25f, 1f);
|
||||
|
||||
private readonly RetailWindowHandle _handle;
|
||||
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
|
||||
private readonly uint _iconSurfaceId;
|
||||
private readonly string _tooltip;
|
||||
|
||||
internal PluginShelfButton(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string ownerDisplayName,
|
||||
RetailWindowHandle handle,
|
||||
Func<uint, (uint tex, int width, int height)> resolve,
|
||||
UiDatFont? font)
|
||||
{
|
||||
_handle = handle;
|
||||
_resolve = resolve;
|
||||
_iconSurfaceId = descriptor.IconSurfaceId;
|
||||
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
|
||||
StringComparison.Ordinal)
|
||||
? descriptor.Title
|
||||
: $"{ownerDisplayName} — {descriptor.Title}";
|
||||
Text = _iconSurfaceId == 0
|
||||
? Initials(descriptor.IconText, descriptor.Title)
|
||||
: string.Empty;
|
||||
DatFont = font;
|
||||
Outline = true;
|
||||
BorderThickness = 1f;
|
||||
_handle.Shown += OnVisibilityChanged;
|
||||
_handle.Hidden += OnVisibilityChanged;
|
||||
RefreshPresentation();
|
||||
}
|
||||
|
||||
public override string? GetTooltipText() => _tooltip;
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
RefreshPresentation();
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
base.OnDraw(ctx);
|
||||
if (_iconSurfaceId == 0)
|
||||
return;
|
||||
|
||||
(uint texture, int width, int height) = _resolve(_iconSurfaceId);
|
||||
if (texture == 0 || width <= 0 || height <= 0)
|
||||
return;
|
||||
float extent = MathF.Min(Width - 6f, Height - 6f);
|
||||
ctx.DrawSprite(
|
||||
texture,
|
||||
(Width - extent) * 0.5f,
|
||||
(Height - extent) * 0.5f,
|
||||
extent,
|
||||
extent,
|
||||
0f,
|
||||
0f,
|
||||
1f,
|
||||
1f,
|
||||
Vector4.One);
|
||||
}
|
||||
|
||||
internal void DisposeSubscriptions()
|
||||
{
|
||||
_handle.Shown -= OnVisibilityChanged;
|
||||
_handle.Hidden -= OnVisibilityChanged;
|
||||
}
|
||||
|
||||
private void OnVisibilityChanged(RetailWindowHandle _) =>
|
||||
RefreshPresentation();
|
||||
|
||||
private void RefreshPresentation()
|
||||
{
|
||||
BackgroundColor = _handle.IsVisible
|
||||
? VisibleBackground
|
||||
: HiddenBackground;
|
||||
BorderColor = _handle.IsVisible ? VisibleBorder : HiddenBorder;
|
||||
}
|
||||
|
||||
private static string Initials(string? requested, string title)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requested))
|
||||
return requested.Trim()[..Math.Min(3, requested.Trim().Length)];
|
||||
|
||||
string[] words = title.Split(
|
||||
' ',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (words.Length == 0)
|
||||
return "?";
|
||||
if (words.Length == 1)
|
||||
return words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant();
|
||||
return string.Concat(words.Take(2).Select(static word =>
|
||||
char.ToUpperInvariant(word[0])));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginMinimizeButton : UiSimpleButton
|
||||
{
|
||||
private readonly RetailWindowHandle _handle;
|
||||
|
||||
internal PluginMinimizeButton(RetailWindowHandle handle, UiDatFont? font)
|
||||
{
|
||||
_handle = handle;
|
||||
Text = "–";
|
||||
DatFont = font;
|
||||
Outline = true;
|
||||
BackgroundColor = new Vector4(0.02f, 0.02f, 0.015f, 0.94f);
|
||||
BorderColor = new Vector4(0.58f, 0.46f, 0.17f, 1f);
|
||||
BorderThickness = 1f;
|
||||
Click += () => _handle.Hide();
|
||||
}
|
||||
|
||||
public override string? GetTooltipText() => "Minimize to plugin sidepanel";
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue