feat(studio): #156 #157 mockup desktop and font resolver

Create AGENTS.md as a thin bridge to CLAUDE.md, fix isolated dat-layout previews by normalizing panel roots to the studio canvas, add a --mockup mode that mounts production-backed UI windows into one UiHost, and thread the per-element dat-font resolver through GameWindow's live imports.

Verified with dotnet test and headless studio screenshots for inventory and the mockup desktop.
This commit is contained in:
Erik 2026-06-26 12:56:05 +02:00
parent ca94b479bf
commit 9444a328fa
8 changed files with 405 additions and 44 deletions

View file

@ -0,0 +1,251 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using DatReaderWriter;
namespace AcDream.App.Studio;
/// <summary>
/// Builds a multi-window UI desktop for the studio. It mounts several real
/// imported panels into one production <see cref="UiHost"/> so drag, resize,
/// click, focus, and z-order behavior all run through the same UI root as the
/// game.
/// </summary>
internal static class MockupDesktop
{
public static void Load(DatCollection dats, RenderStack stack)
{
var objects = SampleData.BuildObjectTable();
MountVitals(dats, stack);
MountToolbar(dats, stack, objects);
MountCharacter(dats, stack, objects);
MountInventory(dats, stack, objects);
MountChat(dats, stack);
}
private static ImportedLayout? Import(DatCollection dats, uint layoutId, RenderStack stack)
=> LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont,
fontResolve: stack.ResolveDatFont);
private static void MountVitals(DatCollection dats, RenderStack stack)
{
var layout = Import(dats, 0x2100006Cu, stack);
if (layout is null) return;
FixtureProvider.Populate(0x2100006Cu, layout, stack, SampleData.BuildObjectTable(), dats);
var root = layout.Root;
root.Left = 12f;
root.Top = 18f;
root.ClickThrough = false;
root.Anchors = AnchorEdges.None;
root.Draggable = true;
root.Resizable = true;
root.ResizeX = true;
root.ResizeY = false;
root.MinWidth = 40f;
stack.UiHost.Root.AddChild(root);
}
private static void MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{
var layout = Import(dats, 0x21000016u, stack);
if (layout is null) return;
FixtureProvider.Populate(0x21000016u, layout, stack, objects, dats);
const int border = RetailChromeSprites.Border;
var toolbarRoot = layout.Root;
float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f;
float contentH = toolbarRoot.Height;
var frame = new UiCollapsibleFrame(stack.ResolveChrome)
{
Left = 12f,
Top = 220f,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
Opacity = 1f,
};
toolbarRoot.Left = border;
toolbarRoot.Top = border;
toolbarRoot.Width = contentW;
toolbarRoot.Height = contentH;
toolbarRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right;
toolbarRoot.ClickThrough = true;
toolbarRoot.Draggable = false;
toolbarRoot.Resizable = false;
frame.AddChild(toolbarRoot);
ConfigureToolbarCollapse(layout, frame, contentH, border);
stack.UiHost.Root.AddChild(frame);
}
private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border)
{
uint[] row2Ids =
{
0x100006B6u,
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
0x100006C0u,
};
var row2 = new List<UiElement>();
float minRow2Top = float.MaxValue;
foreach (var id in row2Ids)
{
if (layout.FindElement(id) is not { } element) continue;
row2.Add(element);
if (element.Top < minRow2Top) minRow2Top = element.Top;
}
if (row2.Count == 0) return;
float expandedH = contentH + 2 * border;
float collapsedH = minRow2Top + 2 * border;
frame.CollapsedHeight = collapsedH;
frame.ExpandedHeight = expandedH;
frame.SecondRow = row2;
frame.Resizable = true;
frame.ResizableEdges = ResizeEdges.Bottom;
frame.MinHeight = collapsedH;
frame.MaxHeight = expandedH;
}
private static void MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{
var layout = Import(dats, 0x2100002Eu, stack);
if (layout is null) return;
FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats);
var root = layout.Root;
root.Left = 540f;
root.Top = 18f;
root.Anchors = AnchorEdges.None;
root.ClickThrough = false;
root.Draggable = true;
root.Resizable = true;
root.MinWidth = root.Width;
root.MinHeight = root.Height;
stack.UiHost.Root.AddChild(root);
}
private static void MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{
var layout = Import(dats, 0x21000023u, stack);
if (layout is null) return;
FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats);
const int border = RetailChromeSprites.Border;
var inventoryRoot = layout.Root;
float contentW = inventoryRoot.Width;
float contentH = inventoryRoot.Height;
var frame = new UiNineSlicePanel(stack.ResolveChrome)
{
Left = 900f,
Top = 18f,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
Opacity = 1f,
Visible = true,
Anchors = AnchorEdges.None,
Draggable = true,
Resizable = true,
ResizeX = false,
ResizableEdges = ResizeEdges.Bottom,
MinHeight = contentH + 2 * border,
MaxHeight = 560f,
};
inventoryRoot.Left = border;
inventoryRoot.Top = border;
inventoryRoot.Width = contentW;
inventoryRoot.Height = contentH;
inventoryRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
inventoryRoot.Draggable = false;
frame.AddChild(inventoryRoot);
StretchV(layout, 0x100001D0u);
StretchV(layout, 0x100001CFu);
StretchV(layout, 0x100001C6u);
StretchV(layout, 0x100001C7u);
PinTopLeft(layout, 0x100001CDu);
PinTopLeft(layout, 0x100001CEu);
stack.UiHost.Root.AddChild(frame);
stack.UiHost.RegisterWindow(WindowNames.Inventory, frame);
}
private static void StretchV(ImportedLayout layout, uint id)
{
if (layout.FindElement(id) is { } element)
element.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
}
private static void PinTopLeft(ImportedLayout layout, uint id)
{
if (layout.FindElement(id) is { } element)
element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
}
private static void MountChat(DatCollection dats, RenderStack stack)
{
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
if (rootInfo is null) return;
var layout = LayoutImporter.Build(rootInfo, stack.ResolveChrome, stack.VitalsDatFont,
fontResolve: stack.ResolveDatFont);
var chatLog = new ChatLog();
chatLog.SetLocalPlayerGuid(SampleData.PlayerGuid);
chatLog.OnSystemMessage("Welcome to the acdream UI mockup.", 0);
chatLog.OnLocalSpeech("You", "This desktop is running through UiHost.", SampleData.PlayerGuid, isRanged: false);
chatLog.OnChannelBroadcast(1, "Caith", "Drag and resize the windows here.", "General");
chatLog.OnTellReceived("Alicia", "The old UI is starting to wake up.", 0x5000ABCDu);
var vm = new ChatVM(chatLog, displayLimit: 200);
var controller = ChatWindowController.Bind(
rootInfo,
layout,
vm,
() => NullCommandBus.Instance,
stack.VitalsDatFont,
debugFont: null,
stack.ResolveChrome);
if (controller is null) return;
const int border = RetailChromeSprites.Border;
var chatRoot = controller.Root;
float contentW = 490f;
float contentH = chatRoot.Height;
var frame = new UiNineSlicePanel(stack.ResolveChrome)
{
Left = 12f,
Top = 390f,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
MinWidth = 200f,
MinHeight = 90f,
Opacity = 0.75f,
};
chatRoot.Left = border;
chatRoot.Top = border;
chatRoot.Width = contentW;
chatRoot.Height = contentH;
chatRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
chatRoot.Draggable = false;
chatRoot.Resizable = false;
frame.AddChild(chatRoot);
stack.UiHost.Root.AddChild(frame);
stack.UiHost.Root.DefaultTextInput = controller.Input;
}
}