merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c83fd02642
94 changed files with 16216 additions and 199 deletions
|
|
@ -50,6 +50,11 @@
|
|||
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
|
||||
next to the binary so MarkupDocument.Build can load them at runtime. -->
|
||||
<None Include="UI\assets\**\*.xml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ namespace AcDream.App.Plugins;
|
|||
|
||||
public sealed class AppPluginHost : IPluginHost
|
||||
{
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
Events = events;
|
||||
Ui = ui;
|
||||
}
|
||||
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State { get; }
|
||||
public IEvents Events { get; }
|
||||
public IUiRegistry Ui { get; }
|
||||
}
|
||||
|
|
|
|||
27
src/AcDream.App/Plugins/BufferedUiRegistry.cs
Normal file
27
src/AcDream.App/Plugins/BufferedUiRegistry.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
|
||||
/// Program.cs before the GL window opens) until GameWindow drains them into the
|
||||
/// UiHost tree after construction.
|
||||
/// </summary>
|
||||
public sealed class BufferedUiRegistry : IUiRegistry
|
||||
{
|
||||
public readonly record struct Pending(string MarkupPath, object Binding);
|
||||
|
||||
private readonly List<Pending> _pending = new();
|
||||
|
||||
public void AddMarkupPanel(string markupPath, object binding)
|
||||
=> _pending.Add(new Pending(markupPath, binding));
|
||||
|
||||
/// <summary>Return + clear all buffered registrations.</summary>
|
||||
public IReadOnlyList<Pending> Drain()
|
||||
{
|
||||
var copy = _pending.ToArray();
|
||||
_pending.Clear();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
|||
|
||||
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
||||
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
|
||||
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
|
||||
|
||||
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||
|
|
@ -56,7 +57,7 @@ try
|
|||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
|
||||
window.Run();
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -612,6 +612,10 @@ public sealed class GameWindow : IDisposable
|
|||
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
|
||||
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
|
||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.UiHost? _uiHost;
|
||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
|
||||
// _panelHost does. Self-subscribes to CombatState in its ctor, so
|
||||
// disposing isn't required (panel host holds the only ref).
|
||||
|
|
@ -862,12 +866,14 @@ public sealed class GameWindow : IDisposable
|
|||
private int _liveAnimRejectSingleFrame;
|
||||
private int _liveAnimRejectPartFrames;
|
||||
|
||||
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents)
|
||||
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
|
||||
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
||||
{
|
||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||
_datDir = options.DatDir;
|
||||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
_uiRegistry = uiRegistry;
|
||||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||||
}
|
||||
|
|
@ -972,8 +978,10 @@ public sealed class GameWindow : IDisposable
|
|||
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
||||
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
||||
firstMouse,
|
||||
wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse,
|
||||
wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
|
||||
wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|
||||
|| (_uiHost?.Root.WantsMouse ?? false),
|
||||
wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard)
|
||||
|| (_uiHost?.Root.WantsKeyboard ?? false));
|
||||
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
|
||||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||
_kbSource, _mouseSource, _keyBindings);
|
||||
|
|
@ -1054,7 +1062,8 @@ public sealed class GameWindow : IDisposable
|
|||
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
|
||||
// surviving direct-mouse handler. Suppresses RMB orbit /
|
||||
// FlyCamera look while ImGui has the mouse focus.
|
||||
if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|
||||
if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|
||||
|| (_uiHost?.Root.WantsMouse ?? false))
|
||||
{
|
||||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
|
|
@ -1744,6 +1753,175 @@ public sealed class GameWindow : IDisposable
|
|||
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
|
||||
_samplerCache = new SamplerCache(_gl);
|
||||
|
||||
// Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing
|
||||
// UiHost retained-mode tree (dormant until now) + a first vitals panel.
|
||||
// Render-only: UiHost input is NOT yet bridged to the InputDispatcher
|
||||
// (next sub-phase), so the close button + window drag are inert. Coexists
|
||||
// with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged.
|
||||
if (_options.RetailUi)
|
||||
{
|
||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
|
||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
|
||||
// same devices) is gated off via WantCaptureMouse/Keyboard above when the
|
||||
// pointer is over a widget — no double-handling.
|
||||
foreach (var m in _input!.Mice) _uiHost.WireMouse(m);
|
||||
foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb);
|
||||
|
||||
var cache = _textureCache!;
|
||||
(uint, int, int) ResolveChrome(uint id)
|
||||
{
|
||||
uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h);
|
||||
return (t, w, h);
|
||||
}
|
||||
|
||||
// Phase D.2b — optional retail stylesheet. controls.ini lives under
|
||||
// the AC install (ACDREAM_AC_DIR); absent → source-verified fallback.
|
||||
var controls = _options.AcDir is { } acDir
|
||||
? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini"))
|
||||
: AcDream.App.UI.ControlsIni.Parse(string.Empty);
|
||||
// Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000,
|
||||
// Latin-1, 16px, outline atlas). Passed into the importer so the meter
|
||||
// number overlay renders through the dat-font two-pass blit; falls back to
|
||||
// the debug font only if it fails to load. Under _datLock like other reads.
|
||||
AcDream.App.UI.UiDatFont? vitalsDatFont;
|
||||
lock (_datLock)
|
||||
vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!);
|
||||
Console.WriteLine(vitalsDatFont is not null
|
||||
? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."
|
||||
: "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font.");
|
||||
|
||||
// Phase D.2b — the vitals window is data-driven from the dat LayoutDesc
|
||||
// (0x2100006C) via the LayoutImporter. The former hand-authored vitals.xml
|
||||
// markup path was retired after the importer proved pixel-identical at the
|
||||
// 2026-06-15 A/B gate. MarkupDocument stays for plugin/custom panels.
|
||||
AcDream.App.UI.Layout.ImportedLayout? imported;
|
||||
lock (_datLock)
|
||||
imported = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||
_dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont);
|
||||
if (imported is not null)
|
||||
{
|
||||
AcDream.App.UI.Layout.VitalsController.Bind(imported,
|
||||
healthPct: () => _vitalsVm!.HealthPercent,
|
||||
staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
|
||||
manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
|
||||
healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
|
||||
staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
|
||||
manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
|
||||
// Top-level retail window: user-positioned (Anchors.None so the per-frame
|
||||
// anchor pass doesn't reset it), movable, and horizontally resizable like
|
||||
// retail. On a width change the dat edge-anchors reflow the pieces
|
||||
// (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges +
|
||||
// the three bars stretch, corners stay 5px, the right edge/corners track
|
||||
// the right side. Vertical resize is off (the layout has no vertical stretch).
|
||||
var vitalsRoot = imported.Root;
|
||||
vitalsRoot.Left = 10; vitalsRoot.Top = 30;
|
||||
vitalsRoot.ClickThrough = false;
|
||||
vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
|
||||
vitalsRoot.Draggable = true;
|
||||
vitalsRoot.Resizable = true;
|
||||
vitalsRoot.ResizeX = true;
|
||||
vitalsRoot.ResizeY = false;
|
||||
vitalsRoot.MinWidth = 40f;
|
||||
_uiHost.Root.AddChild(vitalsRoot);
|
||||
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
|
||||
}
|
||||
|
||||
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
|
||||
// the same importer path as vitals. ChatWindowController binds the transcript,
|
||||
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
|
||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||
AcDream.App.UI.Layout.ElementInfo? chatRootInfo;
|
||||
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
|
||||
lock (_datLock)
|
||||
{
|
||||
chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos(
|
||||
_dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId);
|
||||
chatLayout = chatRootInfo is null ? null
|
||||
: AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont);
|
||||
}
|
||||
if (chatRootInfo is not null && chatLayout is not null)
|
||||
{
|
||||
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
|
||||
chatRootInfo, chatLayout, retailChatVm,
|
||||
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance,
|
||||
vitalsDatFont, _debugFont, ResolveChrome);
|
||||
if (chatController is not null)
|
||||
{
|
||||
// Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the
|
||||
// keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard
|
||||
// is set by WireKeyboard above — it is non-null here.
|
||||
chatController.Transcript.Keyboard = _uiHost.Keyboard;
|
||||
chatController.Input.Keyboard = _uiHost.Keyboard;
|
||||
// Wrap the dat content in the universal 8-piece beveled window chrome —
|
||||
// the SAME UiNineSlicePanel the vitals window uses. The chat's own dat
|
||||
// layout only carries flat background sprites, so without this the window
|
||||
// has no retail-style border (the user asked for the vitals border). The
|
||||
// nine-slice IS the movable/resizable window; the dat content fills its
|
||||
// interior, inset by the border. The gmMainChatUI content is authored 490
|
||||
// wide (its transcript/input panels) — KEEP that width + the dat-authored
|
||||
// HEIGHT so the content's child anchors (input-bar-at-bottom, transcript-
|
||||
// fills) capture correct margins on first layout; resizing the frame reflows
|
||||
// them correctly from there.
|
||||
const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||||
var chatRoot = chatController.Root;
|
||||
float contentW = 490f, contentH = chatRoot.Height; // dat-authored height
|
||||
var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
{
|
||||
Left = 10, Top = 440,
|
||||
Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder,
|
||||
MinWidth = 200f, MinHeight = 90f,
|
||||
// Retail chat is translucent — fade the window's backgrounds/chrome
|
||||
// (text stays opaque). Configurable opacity is a later step; 0.75 reads
|
||||
// as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0)
|
||||
Opacity = 0.75f,
|
||||
};
|
||||
chatRoot.Left = chatBorder; chatRoot.Top = chatBorder;
|
||||
chatRoot.Width = contentW; chatRoot.Height = contentH;
|
||||
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
|
||||
chatRoot.Draggable = false; chatRoot.Resizable = false;
|
||||
chatFrame.AddChild(chatRoot);
|
||||
_uiHost.Root.AddChild(chatFrame);
|
||||
// Tab / Enter enters "write mode" by focusing this input (retail's chat
|
||||
// activation); a focused input suppresses character movement (see the
|
||||
// WantsKeyboard gate in the movement poll).
|
||||
_uiHost.Root.DefaultTextInput = chatController.Input;
|
||||
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
|
||||
}
|
||||
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
|
||||
}
|
||||
else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
|
||||
|
||||
// Drain plugin-registered markup panels (buffered before the GL
|
||||
// window opened) into the same UiRoot tree. A faulty plugin markup
|
||||
// file is isolated — logged + skipped, never crashes the client.
|
||||
if (_uiRegistry is not null)
|
||||
{
|
||||
foreach (var p in _uiRegistry.Drain())
|
||||
{
|
||||
try
|
||||
{
|
||||
string pluginXml = System.IO.File.ReadAllText(p.MarkupPath);
|
||||
var pluginPanel = AcDream.App.UI.MarkupDocument.Build(
|
||||
pluginXml, p.Binding, ResolveChrome, controls);
|
||||
_uiHost.Root.AddChild(pluginPanel);
|
||||
Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
||||
// mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
|
||||
// always construct.
|
||||
|
|
@ -7099,6 +7277,11 @@ public sealed class GameWindow : IDisposable
|
|||
// this guard adds defense-in-depth for the per-frame IsActionHeld
|
||||
// movement poll below (typing "walk" into a chat field shouldn't
|
||||
// walk).
|
||||
// ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a
|
||||
// debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block
|
||||
// below still runs so AUTORUN keeps driving the character while you type. Held WASD
|
||||
// is silenced at the source instead — InputDispatcher.IsActionHeld returns false
|
||||
// while WantCaptureKeyboard (which includes a focused chat input) is set.
|
||||
bool suppressGameInput =
|
||||
DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
|
||||
if (suppressGameInput) return;
|
||||
|
|
@ -8476,6 +8659,16 @@ public sealed class GameWindow : IDisposable
|
|||
SkipWorldGeometry: ;
|
||||
}
|
||||
|
||||
// Phase D.2b — retail-look UI tree (render-only; input integration deferred).
|
||||
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
|
||||
// blend/depth state and restores. Drawn before ImGui so the devtools
|
||||
// overlay composites on top during development.
|
||||
if (_options.RetailUi && _uiHost is not null)
|
||||
{
|
||||
_uiHost.Tick(deltaSeconds);
|
||||
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
|
||||
}
|
||||
|
||||
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
|
||||
// so ImGui composites on top. ImGuiController save/restores the
|
||||
// GL state it touches (blend, scissor, VAO, shader, texture); any
|
||||
|
|
@ -12496,6 +12689,7 @@ public sealed class GameWindow : IDisposable
|
|||
_sceneLightingUbo?.Dispose();
|
||||
_particleRenderer?.Dispose();
|
||||
_debugLines?.Dispose();
|
||||
_uiHost?.Dispose();
|
||||
_textRenderer?.Dispose();
|
||||
_debugFont?.Dispose();
|
||||
_dats?.Dispose();
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ uniform sampler2D uTex;
|
|||
uniform int uUseTexture;
|
||||
|
||||
void main() {
|
||||
if (uUseTexture != 0) {
|
||||
if (uUseTexture == 1) {
|
||||
// Font atlas is a single-channel R8 texture; red = coverage alpha.
|
||||
float coverage = texture(uTex, vUv).r;
|
||||
FragColor = vec4(vColor.rgb, vColor.a * coverage);
|
||||
} else if (uUseTexture == 2) {
|
||||
// RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha.
|
||||
FragColor = texture(uTex, vUv) * vColor;
|
||||
} else {
|
||||
FragColor = vColor;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,39 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
private readonly Shader _shader;
|
||||
private readonly uint _vao;
|
||||
private readonly uint _vbo;
|
||||
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
|
||||
private int _vboCapacityBytes;
|
||||
|
||||
private readonly List<float> _textBuf = new(8192);
|
||||
private readonly List<float> _rectBuf = new(1024);
|
||||
// Submission-ordered sprite segments: consecutive DrawSprite calls with the
|
||||
// SAME texture batch into one segment; a texture change starts a new segment.
|
||||
// Drawing segments in submission order preserves painter z-order for
|
||||
// sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture
|
||||
// at its FIRST-insertion point, so later bar sprites covered glyphs emitted
|
||||
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
|
||||
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
|
||||
private readonly List<SpriteSeg> _spriteSegs = new();
|
||||
private int _segUsed;
|
||||
private int _textVerts;
|
||||
private int _rectVerts;
|
||||
private Vector2 _screenSize;
|
||||
|
||||
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
|
||||
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
|
||||
// rect panel backgrounds (which otherwise always win because rects flush after
|
||||
// sprites). Routed by OverlayMode; the UI root sets it for the popup traversal.
|
||||
private readonly List<float> _overlayTextBuf = new(1024);
|
||||
private readonly List<float> _overlayRectBuf = new(256);
|
||||
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
|
||||
private int _overlaySegUsed;
|
||||
private int _overlayTextVerts;
|
||||
private int _overlayRectVerts;
|
||||
|
||||
/// <summary>When true, Draw* calls route to the overlay layer (flushed last, on top
|
||||
/// of all normal-layer geometry). Set by the UI root around the popup/overlay pass.</summary>
|
||||
public bool OverlayMode { get; set; }
|
||||
|
||||
public TextRenderer(GL gl, string shaderDir)
|
||||
{
|
||||
_gl = gl;
|
||||
|
|
@ -56,6 +81,20 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
|
||||
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
|
||||
// background draw UNDER its text in painter order, which DrawRect's separate
|
||||
// bucket cannot (it always composites after all sprites).
|
||||
_whiteTex = _gl.GenTexture();
|
||||
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
|
||||
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
|
||||
fixed (byte* wp = whitePixel)
|
||||
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
|
||||
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
||||
}
|
||||
|
||||
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
|
||||
|
|
@ -64,17 +103,32 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
_screenSize = screenSize;
|
||||
_textBuf.Clear();
|
||||
_rectBuf.Clear();
|
||||
_segUsed = 0; // pool the SpriteSeg objects across frames
|
||||
_textVerts = 0;
|
||||
_rectVerts = 0;
|
||||
_overlayTextBuf.Clear();
|
||||
_overlayRectBuf.Clear();
|
||||
_overlaySegUsed = 0;
|
||||
_overlayTextVerts = 0;
|
||||
_overlayRectVerts = 0;
|
||||
OverlayMode = false;
|
||||
}
|
||||
|
||||
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
|
||||
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
||||
{
|
||||
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
|
||||
_rectVerts += 6;
|
||||
if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
|
||||
else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; }
|
||||
}
|
||||
|
||||
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
|
||||
/// when active), so it composites in painter order with sprites + dat-font text. Use
|
||||
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
|
||||
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
|
||||
/// the text instead.</summary>
|
||||
public void DrawFill(float x, float y, float w, float h, Vector4 color)
|
||||
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
|
||||
|
||||
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
|
||||
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
||||
{
|
||||
|
|
@ -119,16 +173,47 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
if (gw > 0 && gh > 0)
|
||||
{
|
||||
AppendQuad(_textBuf,
|
||||
gx, gy, gw, gh,
|
||||
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
|
||||
color);
|
||||
_textVerts += 6;
|
||||
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
|
||||
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
|
||||
}
|
||||
cursorX += g.Advance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a textured sprite quad in screen pixel space with an explicit
|
||||
/// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per
|
||||
/// GL texture handle; flushed with uUseTexture=2 (RGBA modulate).
|
||||
/// </summary>
|
||||
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
||||
float u0, float v0, float u1, float v1, Vector4 tint)
|
||||
{
|
||||
SpriteSeg seg = OverlayMode
|
||||
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
|
||||
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
|
||||
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
|
||||
}
|
||||
|
||||
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
|
||||
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is
|
||||
/// preserved (painter z-order for sprite-on-sprite UI).</summary>
|
||||
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, uint texture)
|
||||
{
|
||||
if (used > 0 && segs[used - 1].Texture == texture)
|
||||
return segs[used - 1];
|
||||
if (used < segs.Count)
|
||||
{
|
||||
var s = segs[used++];
|
||||
s.Texture = texture;
|
||||
s.Verts.Clear();
|
||||
return s;
|
||||
}
|
||||
var ns = new SpriteSeg { Texture = texture };
|
||||
segs.Add(ns);
|
||||
used++;
|
||||
return ns;
|
||||
}
|
||||
|
||||
private static void AppendQuad(List<float> buf,
|
||||
float x, float y, float w, float h,
|
||||
float u0, float v0, float u1, float v1, Vector4 color)
|
||||
|
|
@ -159,7 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
|
||||
public void Flush(BitmapFont? font)
|
||||
{
|
||||
if (_textVerts == 0 && _rectVerts == 0) return;
|
||||
bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
|
||||
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
|
||||
if (!anyNormal && !anyOverlay) return;
|
||||
|
||||
_shader.Use();
|
||||
_shader.SetVec2("uScreenSize", _screenSize);
|
||||
|
|
@ -171,36 +258,85 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
|
||||
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
|
||||
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
|
||||
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
|
||||
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
|
||||
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
|
||||
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
|
||||
// blending and must own this state (feedback_render_self_contained_gl_state).
|
||||
bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
|
||||
bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample);
|
||||
_gl.Disable(EnableCap.SampleAlphaToCoverage);
|
||||
_gl.Disable(EnableCap.Multisample);
|
||||
_gl.Disable(EnableCap.DepthTest);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.DepthMask(false);
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
|
||||
// Untextured rects first — they form panel backgrounds.
|
||||
if (_rectVerts > 0)
|
||||
// LAYERED compositing for the UI (background → fill → text):
|
||||
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
|
||||
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
|
||||
// 3. Text glyphs — on top
|
||||
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
|
||||
// so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
|
||||
// composite on top, in that order. The OVERLAY layer repeats all three
|
||||
// AFTER the normal layer, so open popups beat even the rect backgrounds.
|
||||
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
|
||||
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
|
||||
|
||||
// Restore GL state.
|
||||
_gl.DepthMask(true);
|
||||
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
||||
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
||||
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
||||
if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage);
|
||||
if (wasMsaa) _gl.Enable(EnableCap.Multisample);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
/// <summary>Draw one compositing layer: sprites (submission order, one call per
|
||||
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
|
||||
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
|
||||
private void DrawLayer(
|
||||
List<SpriteSeg> spriteSegs, int segUsed,
|
||||
List<float> rectBuf, int rectVerts,
|
||||
List<float> textBuf, int textVerts, BitmapFont? font)
|
||||
{
|
||||
// 1. RGBA dat sprites — one draw call per distinct GL texture.
|
||||
if (segUsed > 0)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 0);
|
||||
UploadBuffer(_rectBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
|
||||
_shader.SetInt("uUseTexture", 2);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_shader.SetInt("uTex", 0);
|
||||
for (int i = 0; i < segUsed; i++)
|
||||
{
|
||||
var seg = spriteSegs[i];
|
||||
if (seg.Verts.Count == 0) continue;
|
||||
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
|
||||
UploadBuffer(seg.Verts);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex));
|
||||
}
|
||||
}
|
||||
|
||||
// Textured text glyphs.
|
||||
if (_textVerts > 0 && font is not null)
|
||||
// 2. Untextured rects — widget fills on top of the chrome.
|
||||
if (rectVerts > 0)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 0);
|
||||
UploadBuffer(rectBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
|
||||
}
|
||||
|
||||
// 3. Textured debug-font text glyphs on top.
|
||||
if (textVerts > 0 && font is not null)
|
||||
{
|
||||
_shader.SetInt("uUseTexture", 1);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
|
||||
_shader.SetInt("uTex", 0);
|
||||
UploadBuffer(_textBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
|
||||
UploadBuffer(textBuf);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts);
|
||||
}
|
||||
|
||||
// Restore GL state.
|
||||
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
||||
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
||||
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
private void UploadBuffer(List<float> buf)
|
||||
|
|
@ -223,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
_gl.DeleteTexture(_whiteTex);
|
||||
_gl.DeleteBuffer(_vbo);
|
||||
_gl.DeleteVertexArray(_vao);
|
||||
_shader.Dispose();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
private readonly GL _gl;
|
||||
private readonly DatCollection _dats;
|
||||
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
|
||||
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
|
||||
/// <summary>
|
||||
/// Composite cache for surface-with-override-origtex entries (Phase 5
|
||||
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
|
||||
|
|
@ -30,6 +31,12 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
|
||||
private uint _magentaHandle;
|
||||
|
||||
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
|
||||
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
|
||||
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
|
||||
private readonly Dictionary<uint, uint> _handlesByRenderSurfaceId = new();
|
||||
private readonly Dictionary<uint, (int w, int h)> _rsSizeById = new();
|
||||
|
||||
private readonly Wb.BindlessSupport? _bindless;
|
||||
|
||||
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
|
||||
|
|
@ -80,6 +87,65 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
|
||||
/// pixel dimensions. UI 9-slice geometry needs the source size to
|
||||
/// compute slice UVs. Cached alongside the handle.
|
||||
/// </summary>
|
||||
public uint GetOrUpload(uint surfaceId, out int width, out int height)
|
||||
{
|
||||
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing)
|
||||
&& _sizeBySurfaceId.TryGetValue(surfaceId, out var sz))
|
||||
{
|
||||
width = sz.w; height = sz.h;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
|
||||
uint h = UploadRgba8(decoded);
|
||||
_handlesBySurfaceId[surfaceId] = h;
|
||||
_sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height);
|
||||
width = decoded.Width; height = decoded.Height;
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
|
||||
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
|
||||
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
|
||||
/// for world-geometry materials. This is the correct path for retail UI
|
||||
/// chrome + font glyph sheets, which reference RenderSurface directly.
|
||||
/// Palette is null for now (a paletted INDEX16/P8 UI sprite would return
|
||||
/// Magenta — wire a UI palette when one is actually encountered). Returns a
|
||||
/// 1x1 magenta handle on miss.
|
||||
/// </summary>
|
||||
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
|
||||
{
|
||||
if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing)
|
||||
&& _rsSizeById.TryGetValue(renderSurfaceId, out var sz))
|
||||
{
|
||||
width = sz.w; height = sz.h;
|
||||
return existing;
|
||||
}
|
||||
|
||||
DecodedTexture decoded;
|
||||
if (_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
||||
|| _dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||
{
|
||||
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||||
}
|
||||
else
|
||||
{
|
||||
decoded = DecodedTexture.Magenta;
|
||||
}
|
||||
|
||||
uint h = UploadRgba8(decoded, nearest);
|
||||
_handlesByRenderSurfaceId[renderSurfaceId] = h;
|
||||
_rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height);
|
||||
width = decoded.Width; height = decoded.Height;
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alpha-channel histogram for one decoded texture. Used to diagnose
|
||||
/// "why are clouds not transparent" — if cloud textures come out with
|
||||
|
|
@ -476,7 +542,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
return composed;
|
||||
}
|
||||
|
||||
private uint UploadRgba8(DecodedTexture decoded)
|
||||
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
|
||||
{
|
||||
uint tex = _gl.GenTexture();
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
|
|
@ -493,8 +559,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
|||
PixelType.UnsignedByte,
|
||||
p);
|
||||
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
||||
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
|
||||
// font's small glyphs. Other surfaces use bilinear.
|
||||
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
|
||||
|
||||
|
|
|
|||
|
|
@ -2044,7 +2044,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
|
||||
/// The indoor "no sun" half is already handled by the global sun kill when the
|
||||
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
|
||||
/// (AP-37) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ComputeEntityLightSet(WorldEntity entity)
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ public sealed record RuntimeOptions(
|
|||
bool RetailCloseDegrades,
|
||||
bool DumpSceneryZ,
|
||||
bool DumpLiveSpawns,
|
||||
int? LegacyStreamRadius)
|
||||
int? LegacyStreamRadius,
|
||||
bool RetailUi,
|
||||
string? AcDir)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -81,7 +83,9 @@ public sealed record RuntimeOptions(
|
|||
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
||||
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
||||
// top of the quality preset's radii. Null when unset or invalid.
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
||||
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
||||
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")));
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
|
|
|
|||
65
src/AcDream.App/UI/ControlsIni.cs
Normal file
65
src/AcDream.App/UI/ControlsIni.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal reader for retail's <c>controls.ini</c> — a flat INI with one
|
||||
/// <c>[section]</c> per element type. Colors are <c>#AARRGGBB</c> (alpha
|
||||
/// first). Optional: a missing file yields an empty sheet (callers fall back
|
||||
/// to hardcoded defaults). See the D.2b spec §7.
|
||||
/// </summary>
|
||||
public sealed class ControlsIni
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _sections;
|
||||
|
||||
private ControlsIni(Dictionary<string, Dictionary<string, string>> s) => _sections = s;
|
||||
|
||||
/// <summary>Load from disk; returns an empty sheet if the file is absent.</summary>
|
||||
public static ControlsIni Load(string path)
|
||||
=> System.IO.File.Exists(path)
|
||||
? Parse(System.IO.File.ReadAllText(path))
|
||||
: new ControlsIni(new());
|
||||
|
||||
public static ControlsIni Parse(string text)
|
||||
{
|
||||
var sections = new Dictionary<string, Dictionary<string, string>>(System.StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, string>? cur = null;
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue;
|
||||
if (line[0] == '[' && line[^1] == ']')
|
||||
{
|
||||
var name = line[1..^1].Trim();
|
||||
cur = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
|
||||
sections[name] = cur;
|
||||
continue;
|
||||
}
|
||||
int eq = line.IndexOf('=');
|
||||
if (eq <= 0 || cur is null) continue;
|
||||
cur[line[..eq].Trim()] = line[(eq + 1)..].Trim();
|
||||
}
|
||||
return new ControlsIni(sections);
|
||||
}
|
||||
|
||||
public string? Get(string section, string key)
|
||||
=> _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null;
|
||||
|
||||
/// <summary>Parse a <c>#AARRGGBB</c> token into an RGBA <see cref="Vector4"/>.</summary>
|
||||
public bool TryColor(string section, string key, out Vector4 color)
|
||||
{
|
||||
color = default;
|
||||
var v = Get(section, key);
|
||||
if (v is null || v.Length != 9 || v[0] != '#') return false;
|
||||
if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb))
|
||||
return false;
|
||||
float a = ((argb >> 24) & 0xFF) / 255f;
|
||||
float r = ((argb >> 16) & 0xFF) / 255f;
|
||||
float g = ((argb >> 8) & 0xFF) / 255f;
|
||||
float b = (argb & 0xFF) / 255f;
|
||||
color = new Vector4(r, g, b, a);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
472
src/AcDream.App/UI/Layout/ChatWindowController.cs
Normal file
472
src/AcDream.App/UI/Layout/ChatWindowController.cs
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
||||
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
|
||||
/// by the factory; this controller binds its live data provider in place. The input
|
||||
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible
|
||||
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds
|
||||
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is
|
||||
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
|
||||
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
|
||||
/// and bound in place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatWindowController
|
||||
{
|
||||
public const uint LayoutId = 0x21000006u;
|
||||
|
||||
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
||||
private const uint RootId = 0x1000000Eu;
|
||||
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
|
||||
private const uint TranscriptPanelId = 0x10000010u;
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputBarId = 0x10000013u;
|
||||
private const uint MenuId = 0x10000014u;
|
||||
private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField
|
||||
private const uint SendId = 0x10000019u;
|
||||
private const uint MaxMinId = 0x1000046Fu;
|
||||
|
||||
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
|
||||
private const uint TrackSprite = 0x06004C5Fu;
|
||||
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
|
||||
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
|
||||
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
|
||||
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
|
||||
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
|
||||
|
||||
// Chat input focused-field background (element 0x10000016 Normal_focussed state).
|
||||
private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode
|
||||
|
||||
// Channel menu sprite ids (confirmed in chat element dump).
|
||||
private const uint MenuNormal = 0x06004D65u; // button face
|
||||
private const uint MenuPressed = 0x06004D66u; // button pressed
|
||||
private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C)
|
||||
private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
|
||||
private const uint MenuItemSelected = 0x0600124Du; // active channel row
|
||||
|
||||
// ── Public surface ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
|
||||
public UiElement Root { get; private set; } = null!;
|
||||
|
||||
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
|
||||
public UiText Transcript { get; private set; } = null!;
|
||||
|
||||
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
|
||||
public UiField Input { get; private set; } = null!;
|
||||
|
||||
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
|
||||
public UiScrollbar Scrollbar { get; private set; } = null!;
|
||||
|
||||
/// <summary>Channel-selector menu widget.</summary>
|
||||
public UiMenu Menu { get; private set; } = null!;
|
||||
|
||||
// ── Private state ──────────────────────────────────────────────────────
|
||||
|
||||
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
||||
|
||||
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
||||
|
||||
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
||||
{
|
||||
("Squelch (ignore)", null),
|
||||
("Tell to Selected", null),
|
||||
("Chat to All", ChatChannelKind.Say),
|
||||
("Tell to Fellows", ChatChannelKind.Fellowship),
|
||||
("Tell to General Chat", ChatChannelKind.General),
|
||||
("Tell to LFG Chat", ChatChannelKind.Lfg),
|
||||
("Tell to Society Chat", ChatChannelKind.Society),
|
||||
("Tell to Monarch", ChatChannelKind.Monarch),
|
||||
("Tell to Patron", ChatChannelKind.Patron),
|
||||
("Tell to Vassals", ChatChannelKind.Vassals),
|
||||
("Tell to Allegiance", ChatChannelKind.Allegiance),
|
||||
("Tell to Trade Chat", ChatChannelKind.Trade),
|
||||
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
|
||||
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
|
||||
{
|
||||
ChatChannelKind.Say => "Chat",
|
||||
ChatChannelKind.General => "General",
|
||||
ChatChannelKind.Trade => "Trade",
|
||||
ChatChannelKind.Lfg => "LFG",
|
||||
ChatChannelKind.Fellowship => "Fellow",
|
||||
ChatChannelKind.Allegiance => "Alleg",
|
||||
ChatChannelKind.Patron => "Patron",
|
||||
ChatChannelKind.Vassals => "Vassals",
|
||||
ChatChannelKind.Monarch => "Monarch",
|
||||
ChatChannelKind.Roleplay => "Roleplay",
|
||||
ChatChannelKind.Society => "Society",
|
||||
ChatChannelKind.Olthoi => "Olthoi",
|
||||
_ => "Chat",
|
||||
};
|
||||
|
||||
private static bool ChannelAvailable(ChatChannelKind k)
|
||||
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
||||
|
||||
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
|
||||
private float _normalHeight;
|
||||
/// <summary>Window top before maximize.</summary>
|
||||
private float _normalTop;
|
||||
private bool _maximized;
|
||||
|
||||
// ── Factory ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Bind an imported chat layout to live behavior.
|
||||
///
|
||||
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
|
||||
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
|
||||
/// so rects in the info tree match the widget geometry in the layout tree.
|
||||
///
|
||||
/// Returns <c>null</c> if the essential transcript/input panels are missing from
|
||||
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
|
||||
/// </summary>
|
||||
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
|
||||
/// <see cref="LayoutImporter.ImportInfos"/>.</param>
|
||||
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
|
||||
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
|
||||
/// <param name="busProvider">Factory that returns the live command bus at submit time.
|
||||
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
|
||||
/// even when the live session is established AFTER <see cref="Bind"/> runs
|
||||
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
|
||||
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
|
||||
/// <param name="debugFont">Fallback debug bitmap font (used when
|
||||
/// <paramref name="datFont"/> is null).</param>
|
||||
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
||||
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
|
||||
public static ChatWindowController? Bind(
|
||||
ElementInfo rootInfo,
|
||||
ImportedLayout layout,
|
||||
ChatVM vm,
|
||||
Func<ICommandBus> busProvider,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
// The transcript is built as a UiText by the factory (Type 12).
|
||||
// The input node (0x10000016) is also Type-12 → UiText, but the controller replaces
|
||||
// it with a UiField. Read its rect from the raw ElementInfo tree first.
|
||||
var iInfo = FindInfo(rootInfo, InputId);
|
||||
|
||||
// Their parent panels must exist as real widgets in the layout tree.
|
||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||
var inputBar = layout.FindElement(InputBarId);
|
||||
|
||||
if (iInfo is null || transcriptPanel is null || inputBar is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
||||
$"(iInfo={iInfo is not null}, " +
|
||||
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
||||
$"chat window will not be interactive.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
|
||||
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
|
||||
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
|
||||
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
|
||||
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
|
||||
// so using layout.Root would render the strays overlapping the real window (the
|
||||
// red-striped garbage in the first live render). Use the gmMainChatUI window itself:
|
||||
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
|
||||
// orphaning the strays so they never draw.
|
||||
var window = layout.FindElement(RootId) ?? layout.Root;
|
||||
var c = new ChatWindowController { Root = window };
|
||||
|
||||
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
|
||||
// juts out of the content-width window. The host wraps this content in the
|
||||
// universal nine-slice chrome, whose grips provide the resize affordance.
|
||||
if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar)
|
||||
rbParent.RemoveChild(resizeBar);
|
||||
|
||||
// Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root):
|
||||
// grow the transcript panel up to the window top so its dark bg fills the strip.
|
||||
// Otherwise the root element's brown bg shows through as a sliver along the top.
|
||||
transcriptPanel.Top = 0f;
|
||||
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
||||
|
||||
// ── Transcript ───────────────────────────────────────────────────
|
||||
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
||||
// Find it in the widget tree and bind the live providers — no remove/add needed.
|
||||
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||
c.Transcript.DatFont = datFont;
|
||||
c.Transcript.Font = debugFont;
|
||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// The input element (0x10000016) resolves to Type-12 Text, so the factory built it
|
||||
// as an unbound (invisible) UiText placeholder in the input bar. The editable entry
|
||||
// is a controller-placed UiField at the same rect — drop the placeholder, add the field.
|
||||
if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder)
|
||||
inParent.RemoveChild(inputPlaceholder);
|
||||
c.Input = new UiField
|
||||
{
|
||||
Left = iInfo.X,
|
||||
Top = iInfo.Y,
|
||||
Width = iInfo.Width,
|
||||
Height = iInfo.Height,
|
||||
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
||||
DatFont = datFont,
|
||||
Font = debugFont,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field
|
||||
SpriteResolve = resolve,
|
||||
FocusFieldSprite = InputFocusField,
|
||||
};
|
||||
inputBar.AddChild(c.Input);
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
||||
// directly. Find it, bind it in place — no remove/add needed.
|
||||
var track = layout.FindElement(TrackId);
|
||||
if (track is UiScrollbar bar)
|
||||
{
|
||||
float oldTop = bar.Top;
|
||||
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
||||
bar.Height = bar.Height + oldTop;
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve = resolve;
|
||||
bar.TrackSprite = TrackSprite;
|
||||
bar.ThumbSprite = ThumbSprite;
|
||||
bar.ThumbTopSprite = ThumbTopSprite;
|
||||
bar.ThumbBotSprite = ThumbBotSprite;
|
||||
bar.UpSprite = UpSprite;
|
||||
bar.DownSprite = DownSprite;
|
||||
c.Scrollbar = bar;
|
||||
}
|
||||
|
||||
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
|
||||
if (layout.FindElement(MenuId) is UiMenu menu)
|
||||
{
|
||||
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
||||
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
||||
menu.PopupBgSprite = MenuPopupBg;
|
||||
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
||||
menu.Items = System.Array.ConvertAll(ChannelItems,
|
||||
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
||||
menu.Selected = (object?)c._activeChannel;
|
||||
// Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
|
||||
// retail; only the talk-CHANNEL items grey when unavailable.
|
||||
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
||||
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
||||
// The widget reports the pick; the controller owns Selected. Only a talk-channel
|
||||
// payload updates the active channel + highlight — the null-payload specials are
|
||||
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
|
||||
menu.OnSelect = p =>
|
||||
{
|
||||
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
||||
};
|
||||
c.Menu = menu;
|
||||
}
|
||||
|
||||
// ── Send button — Enter-alternate submit trigger ──────────────────
|
||||
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
|
||||
if (layout.FindElement(SendId) is UiButton sendEl)
|
||||
{
|
||||
sendEl.OnClick = () => c.Input.Submit();
|
||||
// The Send sprite is a blank gold button — retail draws the caption as text.
|
||||
sendEl.Label = "Send";
|
||||
sendEl.LabelFont = datFont;
|
||||
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||
}
|
||||
|
||||
// ── Size the channel button to its label + reflow the input field ─
|
||||
// Retail's talk-focus button autosizes to the selected channel name; the input
|
||||
// field then fills the gap from the button's right edge to the Send button. The
|
||||
// dat authors the button at a fixed 46px (too narrow for "Chat" once the LED +
|
||||
// arrow are accounted for), so widen it to its content and shift the input.
|
||||
// Recompute on every channel change (the button grows/shrinks with the label).
|
||||
if (c.Menu is not null)
|
||||
{
|
||||
float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge
|
||||
void ReflowInputRow()
|
||||
{
|
||||
c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth());
|
||||
c.Menu.ResetAnchorCapture();
|
||||
c.Input.Left = c.Menu.Left + c.Menu.Width;
|
||||
c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
|
||||
c.Input.ResetAnchorCapture();
|
||||
}
|
||||
var onSelect = c.Menu.OnSelect;
|
||||
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
||||
ReflowInputRow();
|
||||
}
|
||||
|
||||
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
|
||||
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
||||
{
|
||||
// The dat puts max/min and the scrollbar up-button at the SAME X (both
|
||||
// right-anchored), so at content width they overlap. Retail shows max/min
|
||||
// just LEFT of the scrollbar column — shift it one button-width left.
|
||||
if (track is not null)
|
||||
maxMinEl.Left = track.Left - maxMinEl.Width;
|
||||
maxMinEl.OnClick = c.ToggleMaximize;
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Max/min implementation ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Toggle between the normal chat window height and an expanded 320px height.
|
||||
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
|
||||
/// retail stores the pre-maximize height and restores it on a second click.
|
||||
/// The 320px expanded size is the approximate retail maximized chat height.
|
||||
/// </summary>
|
||||
private void ToggleMaximize()
|
||||
{
|
||||
if (!_maximized)
|
||||
{
|
||||
_normalHeight = Root.Height;
|
||||
_normalTop = Root.Top;
|
||||
// Expand upward: move the top edge up so the bottom stays anchored.
|
||||
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
|
||||
Root.Height = 320f;
|
||||
_maximized = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Root.Top = _normalTop;
|
||||
Root.Height = _normalHeight;
|
||||
_maximized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
|
||||
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
|
||||
/// </summary>
|
||||
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = FindInfo(child, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the ChatVM's detailed lines to the transcript's
|
||||
/// <see cref="UiText.Line"/> record format, applying retail-faithful
|
||||
/// per-<see cref="ChatKind"/> colors.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UiText.Line> BuildLines(
|
||||
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
|
||||
{
|
||||
var detailed = vm.RecentLinesDetailed();
|
||||
if (detailed.Count == 0) return Array.Empty<UiText.Line>();
|
||||
|
||||
// Word-wrap each message to the transcript's current pixel width (ports retail
|
||||
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
||||
// exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize.
|
||||
float maxW = view.Width - 2f * view.Padding;
|
||||
Func<string, float> measure =
|
||||
datFont is { } df ? s => df.MeasureWidth(s)
|
||||
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
||||
: s => s.Length * 7f;
|
||||
|
||||
var result = new List<UiText.Line>(detailed.Count);
|
||||
foreach (var d in detailed)
|
||||
{
|
||||
var color = RetailChatColor(d.Kind);
|
||||
foreach (var frag in WrapText(d.Text, maxW, measure))
|
||||
result.Add(new UiText.Line(frag, color));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
|
||||
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
|
||||
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
|
||||
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
||||
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
||||
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
|
||||
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
|
||||
/// </summary>
|
||||
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
|
||||
{
|
||||
yield return text ?? string.Empty;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var line = new System.Text.StringBuilder();
|
||||
foreach (var word in text.Split(' '))
|
||||
{
|
||||
string sep = line.Length > 0 ? " " : string.Empty;
|
||||
if (measure(line.ToString() + sep + word) <= maxW)
|
||||
{
|
||||
line.Append(sep).Append(word); // fits on the current line
|
||||
continue;
|
||||
}
|
||||
if (line.Length > 0 && measure(word) <= maxW)
|
||||
{
|
||||
yield return line.ToString(); // word fits alone → push to a new line
|
||||
line.Clear();
|
||||
line.Append(word);
|
||||
continue;
|
||||
}
|
||||
// Word too long for any single line: char-wrap it, packing onto the current
|
||||
// line's remaining space first (keeps the prefix with the message start).
|
||||
if (line.Length > 0) line.Append(' ');
|
||||
foreach (char ch in word)
|
||||
{
|
||||
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
|
||||
{
|
||||
yield return line.ToString();
|
||||
line.Clear();
|
||||
}
|
||||
line.Append(ch);
|
||||
}
|
||||
}
|
||||
if (line.Length > 0) yield return line.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
|
||||
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
|
||||
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
|
||||
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).
|
||||
/// The four common kinds (speech/tell/channel/system) are confirmed by the named
|
||||
/// symbols + universal AC convention; the rarer kinds map to the nearest named color.
|
||||
/// </summary>
|
||||
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
|
||||
{
|
||||
ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite
|
||||
ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout)
|
||||
ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue
|
||||
ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple
|
||||
ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen
|
||||
ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast)
|
||||
ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
||||
ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
||||
ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed
|
||||
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
|
||||
};
|
||||
}
|
||||
202
src/AcDream.App/UI/Layout/DatWidgetFactory.cs
Normal file
202
src/AcDream.App/UI/Layout/DatWidgetFactory.cs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
||||
/// algorithm ports); everything else (and unknown Types) falls back to
|
||||
/// <see cref="UiDatElement"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
|
||||
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
|
||||
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
|
||||
/// prototype elements (no state media, no controller binding) draw nothing because
|
||||
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
|
||||
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
|
||||
/// walks two layers down to extract them: the two Type-3 container children
|
||||
/// ordered by <see cref="ElementInfo.ReadOrder"/> (back behind = lower, front
|
||||
/// on top = higher), then within each container the image children that carry
|
||||
/// a DirectState ("" key) sprite, ordered by their X position to obtain
|
||||
/// left-cap / center-tile / right-cap.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The expand-detail overlay present in the front container carries ONLY named
|
||||
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
|
||||
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
|
||||
/// automatically.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DatWidgetFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
|
||||
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
|
||||
/// </summary>
|
||||
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
|
||||
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
|
||||
/// May be null pre-load — the meter falls back to the debug bitmap font.</param>
|
||||
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
||||
public static UiElement? Create(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
||||
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
||||
// containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT
|
||||
// editable fields — retail draws those as inert media-bearing Fields, which our
|
||||
// UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit
|
||||
// affordance a UiField would add). The one true editable field, the chat input
|
||||
// (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3
|
||||
// stays on the generic fallback here; register it as UiField only when a window
|
||||
// actually carries a factory-built editable Type-3 field (and UiField grows a
|
||||
// background-media draw + an opt-in editable flag at that point). UiField (the widget)
|
||||
// still ships — it just isn't wired into the factory switch yet.
|
||||
UiElement e = info.Type switch
|
||||
{
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
};
|
||||
|
||||
// Propagate position + size (pixel-exact from the dat).
|
||||
e.Left = info.X;
|
||||
e.Top = info.Y;
|
||||
e.Width = info.Width;
|
||||
e.Height = info.Height;
|
||||
|
||||
// Honor the dat's draw order so overlapping pieces (grip overlay over bevel chrome) layer correctly.
|
||||
e.ZOrder = (int)info.ReadOrder;
|
||||
|
||||
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
|
||||
// UI layout engine uses for reflow.
|
||||
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Meter ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="UiMeter"/> and populates its six 3-slice sprite ids by
|
||||
/// reading the meter's grandchild image elements (format doc §11).
|
||||
///
|
||||
/// <para>
|
||||
/// Structure the importer produces for each meter (UIElement_Meter):
|
||||
/// <code>
|
||||
/// meter (Type 7)
|
||||
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
|
||||
/// │ ├── left-cap image (DirectState "" → File = back-left sprite)
|
||||
/// │ ├── center image (DirectState "" → File = back-tile sprite)
|
||||
/// │ └── right-cap image (DirectState "" → File = back-right sprite)
|
||||
/// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
|
||||
/// │ ├── left-cap image (→ front-left sprite)
|
||||
/// │ ├── center image (→ front-tile sprite)
|
||||
/// │ ├── right-cap image (→ front-right sprite)
|
||||
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
|
||||
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController in Task 6)
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
|
||||
/// They are bound to the live stat providers in Task 6 (VitalsController).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static UiMeter BuildMeter(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var m = new UiMeter
|
||||
{
|
||||
SpriteResolve = resolve,
|
||||
DatFont = datFont,
|
||||
};
|
||||
|
||||
// The two 3-slice containers are Type-3 children of the meter element.
|
||||
// ReadOrder determines draw order: the back track has a LOWER ReadOrder
|
||||
// (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top).
|
||||
var containers = info.Children
|
||||
.Where(c => c.Type == 3)
|
||||
.OrderBy(c => c.ReadOrder)
|
||||
.ToList();
|
||||
|
||||
if (containers.Count != 2)
|
||||
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 2) — bars may render as solid-color fallback.");
|
||||
|
||||
if (containers.Count >= 1)
|
||||
{
|
||||
var (l, t, r) = SliceIds(containers[0]);
|
||||
m.BackLeft = l;
|
||||
m.BackTile = t;
|
||||
m.BackRight = r;
|
||||
}
|
||||
|
||||
if (containers.Count >= 2)
|
||||
{
|
||||
var (l, t, r) = SliceIds(containers[1]);
|
||||
m.FrontLeft = l;
|
||||
m.FrontTile = t;
|
||||
m.FrontRight = r;
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (left, tile, right) sprite ids for a 3-slice container,
|
||||
/// extracting them from the container's image children that carry a DirectState
|
||||
/// ("" key) with a non-zero file id, ordered left-to-right by their X position.
|
||||
///
|
||||
/// <para>
|
||||
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
|
||||
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
|
||||
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
|
||||
/// false.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static (uint left, uint tile, uint right) SliceIds(ElementInfo container)
|
||||
{
|
||||
// Only children that have a non-zero DirectState image are slice candidates.
|
||||
// The expand-detail overlay has NO DirectState entry, so it's excluded here.
|
||||
// Project the File during filtering to avoid a second TryGetValue lookup.
|
||||
// Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins.
|
||||
var slices = container.Children
|
||||
.Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0)
|
||||
.Select(c => (c.X, File: c.StateMedia[""].File))
|
||||
.OrderBy(t => t.X)
|
||||
.ToList();
|
||||
|
||||
uint left = slices.Count > 0 ? slices[0].File : 0u;
|
||||
uint tile = slices.Count > 1 ? slices[1].File : 0u;
|
||||
uint right = slices.Count > 2 ? slices[2].File : 0u;
|
||||
|
||||
return (left, tile, right);
|
||||
}
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The element's
|
||||
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
|
||||
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
|
||||
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
|
||||
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary>
|
||||
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||
? m.File : 0u;
|
||||
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
|
||||
}
|
||||
}
|
||||
170
src/AcDream.App/UI/Layout/ElementReader.cs
Normal file
170
src/AcDream.App/UI/Layout/ElementReader.cs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// GL-free, dat-free snapshot of a resolved layout element.
|
||||
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
|
||||
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
|
||||
/// operate on this type so they can be unit-tested without the dats or OpenGL.
|
||||
///
|
||||
/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without
|
||||
/// updating the plan spec and downstream consumers.
|
||||
/// </summary>
|
||||
public sealed class ElementInfo
|
||||
{
|
||||
/// <summary>Dat element id (e.g. <c>0x100000E6</c>).</summary>
|
||||
public uint Id;
|
||||
|
||||
/// <summary>
|
||||
/// Raw element class id as a uint.
|
||||
/// Game-specific ids like <c>0x1000004D</c> (gmVitalsUI root) and <c>0x10000009</c>
|
||||
/// overflow <c>int</c> when treated as signed, so this stays <c>uint</c>.
|
||||
/// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
|
||||
/// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
|
||||
/// </summary>
|
||||
public uint Type;
|
||||
|
||||
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
|
||||
public float X, Y, Width, Height;
|
||||
|
||||
/// <summary>
|
||||
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
|
||||
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
|
||||
/// Values 0–4; map to <see cref="AnchorEdges"/> bit-flags via
|
||||
/// <see cref="ElementReader.ToAnchors"/>.
|
||||
/// </summary>
|
||||
public uint Left, Top, Right, Bottom;
|
||||
|
||||
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
|
||||
public uint ReadOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
|
||||
/// </summary>
|
||||
public uint FontDid;
|
||||
|
||||
/// <summary>
|
||||
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
|
||||
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
|
||||
/// Named states use the <c>UIStateId.ToString()</c> value as the key
|
||||
/// (e.g. <c>"HideDetail"</c>, <c>"ShowDetail"</c>).
|
||||
/// </summary>
|
||||
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
||||
|
||||
/// <summary>
|
||||
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
|
||||
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
|
||||
/// Used by <see cref="UiDatElement"/> to pick which state's sprite to render initially.
|
||||
/// Examples: <c>"Normal"</c> (Send button), <c>"Minimized"</c> (max/min button), <c>""</c> (DirectState).
|
||||
/// </summary>
|
||||
public string DefaultStateName = "";
|
||||
|
||||
/// <summary>
|
||||
/// Resolved child elements (populated by the importer in Task 5).
|
||||
/// Children come from the derived element's own tree, not the base element's.
|
||||
/// </summary>
|
||||
public List<ElementInfo> Children = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
|
||||
/// All methods are static and operate on <see cref="ElementInfo"/> POCOs.
|
||||
/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
|
||||
/// the <see cref="AnchorEdges"/> bit-flag enum from <c>AcDream.App.UI</c>.
|
||||
/// </summary>
|
||||
public static class ElementReader
|
||||
{
|
||||
/// <summary>Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange
|
||||
/// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the
|
||||
/// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right
|
||||
/// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 =
|
||||
/// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier
|
||||
/// format-doc §4 reading, which was wrong (it made every piece fixed-width).</summary>
|
||||
/// <param name="left">LeftEdge dat field value (0–4).</param>
|
||||
/// <param name="top">TopEdge dat field value (0–4).</param>
|
||||
/// <param name="right">RightEdge dat field value (0–4).</param>
|
||||
/// <param name="bottom">BottomEdge dat field value (0–4).</param>
|
||||
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
|
||||
{
|
||||
var a = AnchorEdges.None;
|
||||
if (left == 1 || left == 4) a |= AnchorEdges.Left;
|
||||
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
|
||||
if (top == 1 || top == 4) a |= AnchorEdges.Top;
|
||||
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
|
||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
|
||||
return a;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges a base element snapshot with a derived element snapshot, mirroring
|
||||
/// the <c>BaseElement</c> / <c>BaseLayoutId</c> inheritance chain in the dat.
|
||||
///
|
||||
/// <para>
|
||||
/// Rules:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Scalar fields (<see cref="ElementInfo.Id"/>, <see cref="ElementInfo.Type"/>,
|
||||
/// <see cref="ElementInfo.Width"/>, <see cref="ElementInfo.Height"/>,
|
||||
/// <see cref="ElementInfo.FontDid"/>): derived wins if non-zero; otherwise
|
||||
/// inherited from base.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Position (<see cref="ElementInfo.X"/>, <see cref="ElementInfo.Y"/>) and
|
||||
/// edge flags (<see cref="ElementInfo.Left"/> etc.) and
|
||||
/// <see cref="ElementInfo.ReadOrder"/>: always taken from the derived element
|
||||
/// (derived placement, not the base prototype's geometry).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
|
||||
/// entries override (or add) per state name key.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
|
||||
{
|
||||
var m = new ElementInfo
|
||||
{
|
||||
Id = derived.Id != 0 ? derived.Id : base_.Id,
|
||||
// Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type.
|
||||
// For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 —
|
||||
// which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text
|
||||
// numbers render via UiMeter.Label bound by VitalsController, not a dat text node.
|
||||
// A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable
|
||||
// Width/Height, or explicit handling of Type 0 before the merge).
|
||||
Type = derived.Type != 0 ? derived.Type : base_.Type,
|
||||
X = derived.X,
|
||||
Y = derived.Y,
|
||||
// NOTE: 0 is the "not set, inherit from base" sentinel for Width/Height. This
|
||||
// diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is
|
||||
// indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes).
|
||||
// If a real zero-size derived element ever needs to override a non-zero base in
|
||||
// Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5).
|
||||
Width = derived.Width != 0 ? derived.Width : base_.Width,
|
||||
Height = derived.Height != 0 ? derived.Height : base_.Height,
|
||||
Left = derived.Left,
|
||||
Top = derived.Top,
|
||||
Right = derived.Right,
|
||||
Bottom = derived.Bottom,
|
||||
ReadOrder = derived.ReadOrder,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
|
||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// Children come from the derived element's own tree, not the base prototype's.
|
||||
// Defensive copy: prevent a later mutation of either the merged result or the input
|
||||
// from corrupting the other. Safe for the Task-5 flow (derived.Children is fully
|
||||
// populated by the recursive importer BEFORE Merge is called and never mutated after).
|
||||
Children = new List<ElementInfo>(derived.Children),
|
||||
};
|
||||
// Start with base StateMedia as defaults, then let derived entries override.
|
||||
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
|
||||
foreach (var kv in derived.StateMedia)
|
||||
m.StateMedia[kv.Key] = kv.Value;
|
||||
return m;
|
||||
}
|
||||
}
|
||||
299
src/AcDream.App/UI/Layout/LayoutImporter.cs
Normal file
299
src/AcDream.App/UI/Layout/LayoutImporter.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// The result of importing a retail LayoutDesc: a <see cref="UiElement"/> tree with
|
||||
/// an O(1) lookup table for finding any element by its dat id.
|
||||
/// </summary>
|
||||
public sealed class ImportedLayout
|
||||
{
|
||||
/// <summary>Root widget of the imported tree.</summary>
|
||||
public UiElement Root { get; }
|
||||
|
||||
private readonly Dictionary<uint, UiElement> _byId;
|
||||
|
||||
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
Root = root;
|
||||
_byId = byId;
|
||||
}
|
||||
|
||||
/// <summary>Find a widget by its dat element id (e.g. <c>0x100000E6</c>).
|
||||
/// Returns null if the id was skipped (Type-12 prototype) or not present.</summary>
|
||||
public UiElement? FindElement(uint id)
|
||||
=> _byId.TryGetValue(id, out var e) ? e : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-layer layout importer for retail LayoutDesc dat objects.
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Pure layer</strong> (<see cref="Build"/> / <see cref="BuildFromInfos"/>):
|
||||
/// converts a pre-resolved <see cref="ElementInfo"/> tree into a <see cref="UiElement"/>
|
||||
/// tree via <see cref="DatWidgetFactory"/>. Testable without dats or OpenGL — all tests
|
||||
/// in <c>LayoutImporterTests.cs</c> exercise this layer only.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Dat shell</strong> (<see cref="Import"/>): reads a <see cref="LayoutDesc"/>,
|
||||
/// converts each top-level <see cref="ElementDesc"/> to a fully resolved
|
||||
/// <see cref="ElementInfo"/> (applying <c>BaseElement</c> / <c>BaseLayoutId</c>
|
||||
/// inheritance with a cycle guard), then delegates to <see cref="Build"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Meter elements (Type 7) consume their own dat-children: <see cref="DatWidgetFactory"/>
|
||||
/// reads the grandchild slice-sprite ids during <see cref="UiMeter"/> construction, so the
|
||||
/// children must NOT be added as separate <see cref="UiElement"/> nodes in the tree.
|
||||
/// Every other element type recurses its children generically.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LayoutImporter
|
||||
{
|
||||
// ── Pure layer ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Convenience for tests: attach <paramref name="children"/> to
|
||||
/// <paramref name="rootInfo"/>, then call <see cref="Build"/>.
|
||||
/// The children list is set directly on <paramref name="rootInfo"/>;
|
||||
/// any existing children are replaced.
|
||||
/// </summary>
|
||||
public static ImportedLayout BuildFromInfos(
|
||||
ElementInfo rootInfo,
|
||||
IEnumerable<ElementInfo> children,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
rootInfo.Children = new List<ElementInfo>(children);
|
||||
return Build(rootInfo, resolve, datFont);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure builder: produce the widget tree from a fully resolved
|
||||
/// <see cref="ElementInfo"/> tree (children already attached).
|
||||
/// </summary>
|
||||
public static ImportedLayout Build(
|
||||
ElementInfo rootInfo,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
// Root is never a Type-12 prototype in practice; fall back to a generic
|
||||
// container if the factory returns null for an exotic root type.
|
||||
var root = BuildWidget(rootInfo, resolve, datFont, byId);
|
||||
if (root is null)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
|
||||
root = new UiDatElement(rootInfo, resolve);
|
||||
}
|
||||
return new ImportedLayout(root, byId);
|
||||
}
|
||||
|
||||
private static UiElement? BuildWidget(
|
||||
ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont);
|
||||
if (w is null) return null; // Type-12 style prototype — skip
|
||||
|
||||
if (info.Id != 0) byId[info.Id] = w;
|
||||
|
||||
// Behavioral widgets that draw their full appearance + reproduce their dat
|
||||
// sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps,
|
||||
// Button labels, Scrollbar arrows) CONSUME their dat children — building those as
|
||||
// separate widgets double-draws and lets an invisible child steal pointer/focus
|
||||
// from the behavioral widget (e.g. the channel Menu's label child intercepting the
|
||||
// button click). Only generic containers (UiDatElement, panels) recurse. See
|
||||
// UiElement.ConsumesDatChildren.
|
||||
if (!w.ConsumesDatChildren)
|
||||
{
|
||||
foreach (var child in info.Children)
|
||||
{
|
||||
var cw = BuildWidget(child, resolve, datFont, byId);
|
||||
if (cw is not null) w.AddChild(cw);
|
||||
}
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
// ── Dat shell ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the
|
||||
/// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests.
|
||||
/// Returns null if the layout is missing.
|
||||
/// </summary>
|
||||
/// <param name="dats">The dat collection to read the LayoutDesc from.</param>
|
||||
/// <param name="layoutId">The LayoutDesc dat id to read.</param>
|
||||
public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId)
|
||||
{
|
||||
var ld = dats.Get<LayoutDesc>(layoutId);
|
||||
if (ld is null) return null;
|
||||
|
||||
var tops = new List<ElementInfo>();
|
||||
foreach (var kv in ld.Elements)
|
||||
tops.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||||
|
||||
return tops.Count == 1
|
||||
? tops[0]
|
||||
: new ElementInfo { Id = 0, Type = 3, Children = tops };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
|
||||
/// element, and build the widget tree. Returns null if the layout is absent
|
||||
/// from the dats.
|
||||
/// </summary>
|
||||
public static ImportedLayout? Import(
|
||||
DatCollection dats,
|
||||
uint layoutId,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
var rootInfo = ImportInfos(dats, layoutId);
|
||||
if (rootInfo is null) return null;
|
||||
return Build(rootInfo, resolve, datFont);
|
||||
}
|
||||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
|
||||
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
|
||||
/// (cycle-guarded by <paramref name="baseChain"/>), then resolves + attaches children.
|
||||
/// </summary>
|
||||
private static ElementInfo Resolve(
|
||||
DatCollection dats,
|
||||
ElementDesc d,
|
||||
HashSet<(uint layoutId, uint elementId)> baseChain)
|
||||
{
|
||||
// Read this element's own fields + media (no inheritance, no children yet).
|
||||
var self = ToInfo(d);
|
||||
var result = self;
|
||||
|
||||
// Apply BaseElement / BaseLayoutId inheritance if present.
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0
|
||||
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
|
||||
{
|
||||
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
|
||||
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
|
||||
if (baseDesc is not null)
|
||||
{
|
||||
// Recurse the base chain (already guarded by the HashSet add above).
|
||||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
// Derived fields override the base; result.Children is still empty here
|
||||
// — children are attached below from the DERIVED element's own tree.
|
||||
result = ElementReader.Merge(baseInfo, self);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||||
// the cycle guard is per-element, not shared across siblings.
|
||||
foreach (var kv in d.Children)
|
||||
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
|
||||
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
|
||||
/// attached (the caller handles those).
|
||||
/// </summary>
|
||||
private static ElementInfo ToInfo(ElementDesc d)
|
||||
{
|
||||
// Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when
|
||||
// no default is set; map those to "" so UiDatElement treats them as "no preference".
|
||||
var defState = d.DefaultState.ToString();
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = d.ElementId,
|
||||
Type = d.Type,
|
||||
X = (float)d.X,
|
||||
Y = (float)d.Y,
|
||||
Width = (float)d.Width,
|
||||
Height = (float)d.Height,
|
||||
Left = d.LeftEdge,
|
||||
Top = d.TopEdge,
|
||||
Right = d.RightEdge,
|
||||
Bottom = d.BottomEdge,
|
||||
ReadOrder = d.ReadOrder,
|
||||
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
|
||||
};
|
||||
|
||||
// DirectState (unnamed, key "").
|
||||
if (d.StateDesc is not null)
|
||||
ReadState(d.StateDesc, "", info);
|
||||
|
||||
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
|
||||
foreach (var s in d.States)
|
||||
ReadState(s.Value, s.Key.ToString(), info);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
|
||||
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
|
||||
/// </summary>
|
||||
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
||||
{
|
||||
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
|
||||
// are intentionally skipped — cursor behavior is Plan 2.
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
if (m is MediaDescImage img && img.File != 0)
|
||||
{
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
|
||||
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
|
||||
if (info.FontDid == 0 && sd.Properties is not null
|
||||
&& sd.Properties.TryGetValue(0x1Au, out var raw)
|
||||
&& raw is ArrayBaseProperty arr && arr.Value.Count > 0
|
||||
&& arr.Value[0] is DataIdBaseProperty did)
|
||||
{
|
||||
info.FontDid = did.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Element tree search ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Find an <see cref="ElementDesc"/> by id anywhere in the top-level tree of
|
||||
/// <paramref name="ld"/> (depth-first). Returns null if not found.
|
||||
/// </summary>
|
||||
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
|
||||
{
|
||||
foreach (var kv in ld.Elements)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
|
||||
{
|
||||
if (d.ElementId == id) return d;
|
||||
foreach (var kv in d.Children)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
122
src/AcDream.App/UI/Layout/UiDatElement.cs
Normal file
122
src/AcDream.App/UI/Layout/UiDatElement.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Generic dat element: draws its active state's media by DrawMode (Normal=tile,
|
||||
/// Alphablend/Overlay=blended overlay). The fallback renderer for every element type
|
||||
/// without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips);
|
||||
/// faithful because retail's base element render is exactly "stamp the media per draw-mode".
|
||||
///
|
||||
/// <para>
|
||||
/// For Plan 1, all observed draw modes produce the same alpha-blended tiled quad — the
|
||||
/// sprite shader already alpha-blends, so no per-mode branch is needed here. The named
|
||||
/// constants document the real enum for Plan 2.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// DrawModeType (DatReaderWriter.Enums), stored as int in <see cref="ElementInfo"/> to
|
||||
/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6:
|
||||
/// <c>Undefined=0, Normal=1, Overlay=2, Alphablend=3</c>. There is no Stretch mode.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Tiling uses UV-repeat on BOTH axes (<c>Width/tw</c>, <c>Height/th</c>) so vertical
|
||||
/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too.
|
||||
/// <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/> sets
|
||||
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiDatElement : UiElement
|
||||
{
|
||||
// DrawModeType enum values from DatReaderWriter.Enums.
|
||||
// See docs/research/2026-06-15-layoutdesc-format.md §6.
|
||||
#pragma warning disable IDE0051 // private constants kept for documentation / Plan 2
|
||||
private const int DrawUndefined = 0;
|
||||
private const int DrawNormal = 1;
|
||||
private const int DrawOverlay = 2;
|
||||
private const int DrawAlphablend = 3;
|
||||
#pragma warning restore IDE0051
|
||||
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
|
||||
/// Falls back to DirectState if the named state is absent.</summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
public UiDatElement(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_info = info;
|
||||
_resolve = resolve;
|
||||
ClickThrough = true; // generic decoration; behavioral widgets opt back in
|
||||
|
||||
// Pick the initial active state: retail applies DefaultState when set; falls back
|
||||
// to "Normal" when the element has a Normal-state sprite (retail's implicit default
|
||||
// for stateful elements like tabs and buttons); else the unnamed DirectState ("").
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||
ActiveState = info.DefaultStateName;
|
||||
else if (info.StateMedia.ContainsKey("Normal"))
|
||||
ActiveState = "Normal";
|
||||
// else ActiveState stays "" (DirectState)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (File, DrawMode) for the current <see cref="ActiveState"/>,
|
||||
/// falling back to the DirectState (<c>""</c> key) if the named state is absent.
|
||||
/// Returns (0, 0) if neither exists.
|
||||
/// </summary>
|
||||
// exposed for unit testing
|
||||
public (uint File, int DrawMode) ActiveMedia()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d
|
||||
: (0u, 0);
|
||||
|
||||
/// <summary>Optional click handler. Set by a controller for interactive dat
|
||||
/// elements (e.g. the chat Send / max-min buttons). Requires
|
||||
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Optional centered text label drawn over the sprite (e.g. the "Send"
|
||||
/// button face whose dat sprite is a blank frame). Null = sprite only.</summary>
|
||||
public string? Label { get; set; }
|
||||
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
|
||||
public UiDatFont? LabelFont { get; set; }
|
||||
/// <summary>Label color (default white).</summary>
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var (file, _) = ActiveMedia();
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
{
|
||||
// Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI
|
||||
// texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the
|
||||
// sprite shader already alpha-blends). No Stretch mode exists in DrawModeType.
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
// Centered text label over the sprite (retail draws button captions as text;
|
||||
// their dat sprites are blank frames).
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/AcDream.App/UI/Layout/VitalsController.cs
Normal file
98
src/AcDream.App/UI/Layout/VitalsController.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C).
|
||||
/// Mirrors retail <c>gmVitalsUI::PostInit</c>: grab the three meter elements
|
||||
/// by their dat element ids and bind live data providers (fill fraction + cur/max
|
||||
/// text) to each. This is the ONLY per-window code in the whole importer — pure
|
||||
/// data wiring, not graphics.
|
||||
///
|
||||
/// <para>The slice sprites + dat font on each <see cref="UiMeter"/> are already
|
||||
/// set by <see cref="DatWidgetFactory"/> during tree construction; this controller
|
||||
/// only binds the dynamic vitals data. Do not touch meter rendering fields here.</para>
|
||||
///
|
||||
/// <para>Element ids confirmed from
|
||||
/// <c>docs/research/2026-06-15-layoutdesc-format.md §11</c>
|
||||
/// (vitals window 0x2100006C dump).</para>
|
||||
/// </summary>
|
||||
public static class VitalsController
|
||||
{
|
||||
/// <summary>Dat element id for the Health meter (0x100000E6).</summary>
|
||||
public const uint Health = 0x100000E6;
|
||||
/// <summary>Dat element id for the Stamina meter (0x100000EC).</summary>
|
||||
public const uint Stamina = 0x100000EC;
|
||||
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
|
||||
public const uint Mana = 0x100000EE;
|
||||
|
||||
/// <summary>
|
||||
/// Bind live vitals data providers to the Health, Stamina, and Mana meter
|
||||
/// elements found in <paramref name="layout"/>. Any meter whose id is absent
|
||||
/// from the layout is silently skipped — partial layouts (e.g. test fakes)
|
||||
/// do not cause errors.
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported vitals layout tree.</param>
|
||||
/// <param name="healthPct">Provider returning Health fill fraction [0..1].</param>
|
||||
/// <param name="staminaPct">Provider returning Stamina fill fraction [0..1].</param>
|
||||
/// <param name="manaPct">Provider returning Mana fill fraction [0..1].</param>
|
||||
/// <param name="healthText">Provider returning Health "cur/max" overlay text.</param>
|
||||
/// <param name="staminaText">Provider returning Stamina "cur/max" overlay text.</param>
|
||||
/// <param name="manaText">Provider returning Mana "cur/max" overlay text.</param>
|
||||
public static void Bind(
|
||||
ImportedLayout layout,
|
||||
Func<float> healthPct,
|
||||
Func<float> staminaPct,
|
||||
Func<float> manaPct,
|
||||
Func<string> healthText,
|
||||
Func<string> staminaText,
|
||||
Func<string> manaText)
|
||||
{
|
||||
BindMeter(layout, Health, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, manaPct, manaText);
|
||||
}
|
||||
|
||||
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
|
||||
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
private static void BindMeter(
|
||||
ImportedLayout layout, uint id,
|
||||
Func<float> pct,
|
||||
Func<string> text)
|
||||
{
|
||||
// Silently skip if the id is absent — missing meters are not an error (partial layouts).
|
||||
if (layout.FindElement(id) is not UiMeter m) return;
|
||||
|
||||
m.Fill = () => pct();
|
||||
|
||||
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the
|
||||
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive
|
||||
// decoration) that fills + stretches with the meter, and stop the meter drawing its
|
||||
// own label. UiText.Centered uses the SAME centering formula the meter's overlay did,
|
||||
// so the numbers stay pixel-identical (locked by the visual gate).
|
||||
m.Label = () => null;
|
||||
|
||||
var number = new UiText
|
||||
{
|
||||
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
DatFont = m.DatFont, // the same dat font the meter used for its label
|
||||
ClickThrough = true, // decoration: no focus / selection / drag
|
||||
AcceptsFocus = false,
|
||||
IsEditControl = false,
|
||||
CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, NumberColor) };
|
||||
},
|
||||
};
|
||||
m.AddChild(number);
|
||||
}
|
||||
}
|
||||
159
src/AcDream.App/UI/MarkupDocument.cs
Normal file
159
src/AcDream.App/UI/MarkupDocument.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
66
src/AcDream.App/UI/RetailChromeSprites.cs
Normal file
66
src/AcDream.App/UI/RetailChromeSprites.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0
|
||||
/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded
|
||||
/// DIRECTLY (<see cref="Rendering.TextureCache.GetOrUploadRenderSurface"/>), NOT
|
||||
/// through the Surface→SurfaceTexture chain.
|
||||
///
|
||||
/// <para>
|
||||
/// The universal floating-window bevel is an <b>8-piece border</b> (4 corners
|
||||
/// 5×5 + 4 edges) drawn around a tiled center fill — it is NOT a single
|
||||
/// 9-slice texture. Decoded sizes are in the comments (from the prove-out).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The edge/corner → position mapping below is a reasonable guess pending the
|
||||
/// LayoutDesc 0x21000040 parse (sub-project 3) and is confirmed visually in the
|
||||
/// first vitals-panel render. If a corner's bevel highlight looks wrong, swap
|
||||
/// the four corner constants; if top/bottom or left/right look inverted, swap
|
||||
/// those edge pairs.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailChromeSprites
|
||||
{
|
||||
/// <summary>Tiled interior fill — the shared panel background (48×48).</summary>
|
||||
public const uint CenterFill = 0x06004CC2;
|
||||
|
||||
/// <summary>Horizontal top edge (10×5, tiled across the top span).</summary>
|
||||
public const uint TopEdge = 0x060074BF;
|
||||
/// <summary>Horizontal bottom edge (10×5).</summary>
|
||||
public const uint BottomEdge = 0x060074C1;
|
||||
/// <summary>Vertical left edge (5×10).</summary>
|
||||
public const uint LeftEdge = 0x060074C0;
|
||||
/// <summary>Vertical right edge (5×10).</summary>
|
||||
public const uint RightEdge = 0x060074C2;
|
||||
|
||||
/// <summary>Top-left corner (5×5).</summary>
|
||||
public const uint CornerTL = 0x060074C3;
|
||||
/// <summary>Top-right corner (5×5).</summary>
|
||||
public const uint CornerTR = 0x060074C4;
|
||||
/// <summary>Bottom-left corner (5×5).</summary>
|
||||
public const uint CornerBL = 0x060074C5;
|
||||
/// <summary>Bottom-right corner (5×5).</summary>
|
||||
public const uint CornerBR = 0x060074C6;
|
||||
|
||||
/// <summary>Border thickness in pixels = the corner/edge sprite size (5px).</summary>
|
||||
public const int Border = 5;
|
||||
|
||||
// ── Resize-grip overlay ──────────────────────────────────────────────
|
||||
// A second 8-piece layer drawn ON TOP of the bevel above: the gold ridged
|
||||
// accents + square corner studs that frame a resizable retail window. From
|
||||
// the vitals LayoutDesc 0x2100006C (elements 0x1000063B–0x10000642): each
|
||||
// corner is the same 5×5 stud (0x06006129); the edges are gold double-line
|
||||
// strips tiled along each side. These have transparent gaps, so the bevel
|
||||
// shows through — both layers are needed.
|
||||
/// <summary>Corner grip stud, all four corners (5×5).</summary>
|
||||
public const uint GripCorner = 0x06006129;
|
||||
/// <summary>Top edge grip (10×5, tiled across).</summary>
|
||||
public const uint GripTop = 0x0600612A;
|
||||
/// <summary>Left edge grip (5×10, tiled down).</summary>
|
||||
public const uint GripLeft = 0x0600612B;
|
||||
/// <summary>Bottom edge grip (10×5).</summary>
|
||||
public const uint GripBottom = 0x0600612C;
|
||||
/// <summary>Right edge grip (5×10).</summary>
|
||||
public const uint GripRight = 0x0600612D;
|
||||
}
|
||||
115
src/AcDream.App/UI/UiButton.cs
Normal file
115
src/AcDream.App/UI/UiButton.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic dat-widget button — the production replacement for any dat element of
|
||||
/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create)
|
||||
/// @ acclient_2013_pseudo_c.txt:125828).
|
||||
///
|
||||
/// <para>
|
||||
/// Draws per-state sprite media exactly like <see cref="UiDatElement"/> (same
|
||||
/// <c>ActiveState</c> defaulting, same <c>ActiveMedia()</c> fallback chain, same tiled
|
||||
/// <c>DrawSprite</c> call with UV-repeat so chrome edges tile correctly) plus an
|
||||
/// optional centered text label. The click behavior mirrors <see cref="UiDatElement"/>
|
||||
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
|
||||
/// <c>UiDatElement.OnClick</c> continue to work without behavioral change.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// State selection: picks <see cref="ElementInfo.DefaultStateName"/> if set, then
|
||||
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
|
||||
/// DirectState ("" key) — identical to <see cref="UiDatElement"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Built by <see cref="DatWidgetFactory"/> for Type-1 elements (chat Send 0x10000019,
|
||||
/// Max/Min 0x1000046F). NOT the same as <see cref="UiSimpleButton"/>, which is an
|
||||
/// earlier dev-scaffold widget with no dat sprites.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiButton : UiElement
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
|
||||
public string? Label { get; set; }
|
||||
|
||||
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
|
||||
public UiDatFont? LabelFont { get; set; }
|
||||
|
||||
/// <summary>Label color (default white).</summary>
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>
|
||||
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
|
||||
/// Matches <see cref="UiDatElement.ActiveState"/>.
|
||||
/// </summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_info = info;
|
||||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
||||
|
||||
// State defaulting matches UiDatElement exactly:
|
||||
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||
ActiveState = info.DefaultStateName;
|
||||
else if (info.StateMedia.ContainsKey("Normal"))
|
||||
ActiveState = "Normal";
|
||||
// else ActiveState stays "" (DirectState)
|
||||
}
|
||||
|
||||
/// <summary>The button draws its own face + label; any dat label child is reproduced
|
||||
/// procedurally, so the importer must not build the button's children as widgets.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
|
||||
/// the DirectState ("" key) if the named state is absent.
|
||||
/// Returns 0 if neither exists.
|
||||
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
||||
/// </summary>
|
||||
private uint ActiveFile()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
uint file = ActiveFile();
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
{
|
||||
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
|
||||
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
162
src/AcDream.App/UI/UiDatFont.cs
Normal file
162
src/AcDream.App/UI/UiDatFont.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for
|
||||
/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels +
|
||||
/// background outline/shadow), the per-glyph descriptor table, and the line
|
||||
/// metrics, so <see cref="UiRenderContext.DrawStringDat"/> can blit each glyph
|
||||
/// as two textured quads exactly the way the retail client does.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail render model — <c>SurfaceWindow::DrawCharacter</c>
|
||||
/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for
|
||||
/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the
|
||||
/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the
|
||||
/// requested text color. The pen advances by
|
||||
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> (the function's
|
||||
/// return value, accumulated by the string loop at 0x00467ed4
|
||||
/// <c>edi_3 += var_98</c>), and each glyph is drawn starting at
|
||||
/// <c>penX + HorizontalOffsetBefore</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is
|
||||
/// PFID_A8 — alpha-only. Our <c>SurfaceDecoder</c> expands A8 to RGBA as
|
||||
/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
|
||||
/// <c>uUseTexture==2</c>) MULTIPLIES the sampled texel by the per-vertex tint
|
||||
/// (<c>texture(uTex,vUv) * vColor</c>), so tinting a white+alpha glyph by a
|
||||
/// color gives that color with the glyph's alpha — black for the outline pass,
|
||||
/// text color for the fill pass. No shader change was needed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiDatFont
|
||||
{
|
||||
/// <summary>Retail UI font id (Latin-1, 16x16 max, with outline atlas).</summary>
|
||||
public const uint DefaultFontId = 0x40000000u;
|
||||
|
||||
/// <summary>Foreground (glyph pixels) GL texture handle + atlas pixel size.</summary>
|
||||
public uint ForegroundTexture { get; }
|
||||
public int ForegroundWidth { get; }
|
||||
public int ForegroundHeight { get; }
|
||||
|
||||
/// <summary>Background (outline/shadow) GL texture handle + atlas pixel size.
|
||||
/// 0 when the font has no background atlas (then the outline pass is skipped).</summary>
|
||||
public uint BackgroundTexture { get; }
|
||||
public int BackgroundWidth { get; }
|
||||
public int BackgroundHeight { get; }
|
||||
|
||||
/// <summary>Vertical advance between lines (retail MaxCharHeight).</summary>
|
||||
public float LineHeight { get; }
|
||||
|
||||
/// <summary>Distance from a line's top to its baseline (retail BaselineOffset).</summary>
|
||||
public float BaselineOffset { get; }
|
||||
|
||||
private readonly Dictionary<char, FontCharDesc> _glyphs;
|
||||
|
||||
private UiDatFont(
|
||||
uint fgTex, int fgW, int fgH,
|
||||
uint bgTex, int bgW, int bgH,
|
||||
float lineHeight, float baselineOffset,
|
||||
Dictionary<char, FontCharDesc> glyphs)
|
||||
{
|
||||
ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
|
||||
BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
|
||||
LineHeight = lineHeight;
|
||||
BaselineOffset = baselineOffset;
|
||||
_glyphs = glyphs;
|
||||
}
|
||||
|
||||
/// <summary>True if this font carries a separate outline/shadow atlas
|
||||
/// (retail's <c>m_pBackgroundSurface</c>). When false the outline pass is
|
||||
/// skipped and only the foreground (fill) glyphs are drawn.</summary>
|
||||
public bool HasBackground => BackgroundTexture != 0;
|
||||
|
||||
/// <summary>Look up a glyph descriptor for a character. Returns false for
|
||||
/// characters not present in the font's table (callers skip them).</summary>
|
||||
public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!);
|
||||
|
||||
/// <summary>
|
||||
/// Load Font <paramref name="fontId"/> from the dat collection and upload
|
||||
/// both atlases through the texture cache (the same direct-RenderSurface
|
||||
/// path the D.2b chrome sprites use). Returns null if the Font DBObj is
|
||||
/// missing — callers fall back to the debug bitmap font.
|
||||
/// </summary>
|
||||
public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
|
||||
if (!dats.TryGet<Font>(fontId, out var font) || font is null)
|
||||
return null;
|
||||
|
||||
// Foreground atlas is required; without it there are no glyph pixels.
|
||||
if (font.ForegroundSurfaceDataId == 0)
|
||||
return null;
|
||||
|
||||
// Point-sample the glyph atlases (nearest) so small UI text stays pixel-crisp;
|
||||
// bilinear softens the dat font noticeably (the chat menu/button text "blur").
|
||||
uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH, nearest: true);
|
||||
|
||||
uint bgTex = 0; int bgW = 0, bgH = 0;
|
||||
if (font.BackgroundSurfaceDataId != 0)
|
||||
bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH, nearest: true);
|
||||
|
||||
// Build the char->descriptor lookup. FontCharDesc.Unicode is the code
|
||||
// point; for Latin-1 fonts this is a direct char cast. Last write wins
|
||||
// on the rare duplicate (retail's Font::GetCharDesc does a linear scan
|
||||
// and returns the first match, but the dat tables have no duplicates).
|
||||
var glyphs = new Dictionary<char, FontCharDesc>(font.CharDescs.Count);
|
||||
foreach (var cd in font.CharDescs)
|
||||
glyphs[(char)cd.Unicode] = cd;
|
||||
|
||||
return new UiDatFont(
|
||||
fgTex, fgW, fgH,
|
||||
bgTex, bgW, bgH,
|
||||
lineHeight: font.MaxCharHeight,
|
||||
baselineOffset: font.BaselineOffset,
|
||||
glyphs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total pen advance (in pixels) for <paramref name="text"/>, summing each
|
||||
/// glyph's retail advance. Characters not in the font contribute nothing.
|
||||
/// </summary>
|
||||
public float MeasureWidth(string text)
|
||||
=> MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null);
|
||||
|
||||
/// <summary>
|
||||
/// Pure pen-advance summation seam: total width of <paramref name="text"/>
|
||||
/// given a <paramref name="lookup"/> that maps each char to its descriptor
|
||||
/// (null = not in the font → contributes nothing). Lets the advance math be
|
||||
/// unit-tested with synthetic glyphs, with no GL or dat dependency.
|
||||
/// </summary>
|
||||
public static float MeasureWidth(string? text, Func<char, FontCharDesc?> lookup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lookup);
|
||||
if (string.IsNullOrEmpty(text)) return 0f;
|
||||
float w = 0f;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
if (lookup(text[i]) is { } g)
|
||||
w += GlyphAdvance(g);
|
||||
return w;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retail per-glyph horizontal advance:
|
||||
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>. This is the
|
||||
/// value <c>SurfaceWindow::DrawCharacter</c> returns for proportional text
|
||||
/// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates
|
||||
/// into the pen. Pulled out as a pure static so the math is unit-testable
|
||||
/// without GL or the dat.
|
||||
/// </summary>
|
||||
public static float GlyphAdvance(FontCharDesc g)
|
||||
=> g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
|
||||
}
|
||||
|
|
@ -4,6 +4,11 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
|
||||
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
|
||||
[System.Flags]
|
||||
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
||||
|
||||
/// <summary>
|
||||
/// Base class for every UI widget in the retained-mode tree.
|
||||
///
|
||||
|
|
@ -88,6 +93,39 @@ public abstract class UiElement
|
|||
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
||||
public int ZOrder { get; set; }
|
||||
|
||||
/// <summary>Window opacity (0..1) multiplied into this element's and its
|
||||
/// descendants' background + sprite draws (text stays opaque). 1 = fully opaque.
|
||||
/// Set on a top-level window (e.g. the chat frame) for retail's translucent chat.</summary>
|
||||
public float Opacity { get; set; } = 1f;
|
||||
|
||||
/// <summary>If true, a left-drag on this element (or a non-draggable child of
|
||||
/// it) repositions it as a movable window. Intended for top-level panels,
|
||||
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
|
||||
public bool Draggable { get; set; }
|
||||
|
||||
/// <summary>If true, a left-drag starting near this element's edge/corner
|
||||
/// resizes it (window resize). Intended for top-level panels.</summary>
|
||||
public bool Resizable { get; set; }
|
||||
|
||||
/// <summary>If true, a left-drag starting on this element is delivered to the
|
||||
/// element (e.g. text selection) instead of moving/resizing an ancestor window.
|
||||
/// Edge resize on a resizable ancestor still wins — only the interior move /
|
||||
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
|
||||
public bool CapturesPointerDrag { get; set; }
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
||||
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeX { get; set; } = true;
|
||||
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeY { get; set; } = true;
|
||||
|
||||
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
||||
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
||||
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
|
||||
|
||||
// ── Tree structure ──────────────────────────────────────────────────
|
||||
public UiElement? Parent { get; private set; }
|
||||
|
||||
|
|
@ -108,6 +146,19 @@ public abstract class UiElement
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if this widget draws its full appearance itself and REPRODUCES its dat
|
||||
/// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup
|
||||
/// rows…) — so the <see cref="AcDream.App.UI.Layout.LayoutImporter"/> must NOT build
|
||||
/// those dat child elements as separate widgets (they would double-draw and, worse,
|
||||
/// steal pointer/focus from the behavioral widget). All registered behavioral widgets
|
||||
/// (Meter/Menu/Button/Scrollbar/Text/Field) return <c>true</c>; the generic container
|
||||
/// (<see cref="AcDream.App.UI.Layout.UiDatElement"/>) and panels return <c>false</c>
|
||||
/// and recurse their children normally. Mirrors retail, where each
|
||||
/// <c>UIElement_X::DrawSelf</c> owns its internal structure.
|
||||
/// </summary>
|
||||
public virtual bool ConsumesDatChildren => false;
|
||||
|
||||
// ── Virtual overrides ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -116,6 +167,16 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
protected virtual void OnDraw(UiRenderContext ctx) { }
|
||||
|
||||
/// <summary>
|
||||
/// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this
|
||||
/// element's position in the tree — open menus, dropdowns, tooltips. Called in
|
||||
/// a SECOND traversal after the whole tree's <see cref="OnDraw"/> pass, with the
|
||||
/// same accumulated transform/alpha this element had during its normal draw.
|
||||
/// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly
|
||||
/// this reason; this is the equivalent without reparenting. Default: nothing.
|
||||
/// </summary>
|
||||
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
|
||||
|
||||
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
|
||||
protected virtual void OnTick(double deltaSeconds) { }
|
||||
|
||||
|
|
@ -146,12 +207,18 @@ public abstract class UiElement
|
|||
{
|
||||
if (!Visible) return;
|
||||
|
||||
// Translate into our local space.
|
||||
// Translate into our local space + push this window's opacity (multiplies into
|
||||
// descendants' sprite/rect draws; text bypasses the alpha so it stays sharp).
|
||||
ctx.PushTransform(Left, Top);
|
||||
ctx.PushAlpha(Opacity);
|
||||
try
|
||||
{
|
||||
OnDraw(ctx);
|
||||
|
||||
// Anchor layout: reflow children to this element's current size.
|
||||
for (int i = 0; i < _children.Count; i++)
|
||||
_children[i].ApplyAnchor(Width, Height);
|
||||
|
||||
// Children painted back-to-front (lowest ZOrder first).
|
||||
if (_children.Count > 0)
|
||||
{
|
||||
|
|
@ -164,6 +231,35 @@ public abstract class UiElement
|
|||
}
|
||||
finally
|
||||
{
|
||||
ctx.PopAlpha();
|
||||
ctx.PopTransform();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Second draw traversal: re-walks the tree applying the same
|
||||
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
|
||||
/// <see cref="OnDrawOverlay"/> on each element, so popups composite on top of
|
||||
/// everything drawn in the main pass (dat-font glyphs and sprites share one
|
||||
/// submission-ordered bucket, so later submissions win).</summary>
|
||||
internal void DrawOverlays(UiRenderContext ctx)
|
||||
{
|
||||
if (!Visible) return;
|
||||
ctx.PushTransform(Left, Top);
|
||||
ctx.PushAlpha(Opacity);
|
||||
try
|
||||
{
|
||||
OnDrawOverlay(ctx);
|
||||
if (_children.Count > 0)
|
||||
{
|
||||
var ordered = _children.ToArray();
|
||||
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
|
||||
for (int i = 0; i < ordered.Length; i++)
|
||||
ordered[i].DrawOverlays(ctx);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.PopAlpha();
|
||||
ctx.PopTransform();
|
||||
}
|
||||
}
|
||||
|
|
@ -183,9 +279,14 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
internal UiElement? HitTest(float localX, float localY)
|
||||
{
|
||||
if (!Visible || !Enabled || ClickThrough) return null;
|
||||
if (!Visible || !Enabled) return null;
|
||||
|
||||
// Children first, in reverse Z-order (topmost first).
|
||||
// Children first, in reverse Z-order (topmost first). ClickThrough means
|
||||
// THIS element is transparent to the pointer — but its children are NOT.
|
||||
// A ClickThrough container (e.g. a UiDatElement panel that hosts the chat
|
||||
// input / transcript) must still let the pointer reach its behavioral
|
||||
// children, so the ClickThrough check happens AFTER the child walk, gating
|
||||
// only whether THIS element claims the hit.
|
||||
if (_children.Count > 0)
|
||||
{
|
||||
var ordered = _children.ToArray();
|
||||
|
|
@ -198,6 +299,70 @@ public abstract class UiElement
|
|||
}
|
||||
}
|
||||
|
||||
if (ClickThrough) return null;
|
||||
return OnHitTest(localX, localY) ? this : null;
|
||||
}
|
||||
|
||||
// ── Anchor layout ────────────────────────────────────────────────────
|
||||
|
||||
private bool _anchorCaptured;
|
||||
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
|
||||
|
||||
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
|
||||
/// the margins captured (at first layout / design size) to each anchored edge.
|
||||
/// Called by the parent each frame before drawing children.</summary>
|
||||
internal void ApplyAnchor(float parentW, float parentH)
|
||||
{
|
||||
if (Anchors == AnchorEdges.None) return;
|
||||
if (!_anchorCaptured)
|
||||
{
|
||||
_amL = Left; _amT = Top;
|
||||
_amR = parentW - (Left + Width);
|
||||
_amB = parentH - (Top + Height);
|
||||
_aw0 = Width; _ah0 = Height;
|
||||
_anchorCaptured = true;
|
||||
}
|
||||
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
|
||||
Left = x; Top = y; Width = w; Height = h;
|
||||
}
|
||||
|
||||
/// <summary>Forget the captured anchor margins so the next <see cref="ApplyAnchor"/>
|
||||
/// re-captures them from the CURRENT rect. Call after manually repositioning/resizing
|
||||
/// an anchored element at runtime (e.g. reflowing the chat input when the channel
|
||||
/// button width changes) so the new rect becomes the anchor baseline.</summary>
|
||||
internal void ResetAnchorCapture() => _anchorCaptured = false;
|
||||
|
||||
/// <summary>Walk up to the owning <see cref="UiRoot"/> (the top of the tree), or null
|
||||
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
|
||||
/// a chat input blurring itself (exiting write mode) after submit.</summary>
|
||||
internal UiRoot? FindRoot()
|
||||
{
|
||||
UiElement e = this;
|
||||
while (e.Parent is not null) e = e.Parent;
|
||||
return e as UiRoot;
|
||||
}
|
||||
|
||||
/// <summary>Compute an anchored child rect. Left&Right ⇒ stretch width
|
||||
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
|
||||
/// pin left at fixed width. Same logic vertically.</summary>
|
||||
public static (float x, float y, float w, float h) ComputeAnchoredRect(
|
||||
AnchorEdges a, float mL, float mT, float mR, float mB,
|
||||
float w0, float h0, float parentW, float parentH)
|
||||
{
|
||||
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
|
||||
float x, w;
|
||||
if (l && r) { x = mL; w = parentW - mR - mL; }
|
||||
else if (r) { w = w0; x = parentW - mR - w0; }
|
||||
else { x = mL; w = w0; }
|
||||
|
||||
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
|
||||
float y, h;
|
||||
if (t && b) { y = mT; h = parentH - mB - mT; }
|
||||
else if (b) { h = h0; y = parentH - mB - h0; }
|
||||
else { y = mT; h = h0; }
|
||||
|
||||
if (w < 0) w = 0;
|
||||
if (h < 0) h = 0;
|
||||
return (x, y, w, h);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
420
src/AcDream.App/UI/UiField.cs
Normal file
420
src/AcDream.App/UI/UiField.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic editable one-line field widget. Port of retail <c>UIElement_Field</c>
|
||||
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
|
||||
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
|
||||
/// as stubs for future item-window use.
|
||||
///
|
||||
/// <para>
|
||||
/// Caret is a glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the
|
||||
/// caret. Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and
|
||||
/// held-key auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send)
|
||||
/// fires <see cref="OnSubmit"/>, clears, and pushes history (100-entry cap,
|
||||
/// sentinel 0xFFFFFFFF — port of <c>ChatInterface::ProcessCommand @0x4f5100</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40.
|
||||
/// </summary>
|
||||
public sealed class UiField : UiElement
|
||||
{
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
||||
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
|
||||
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
|
||||
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
||||
public float Padding { get; set; } = 4f;
|
||||
public int MaxCharacters { get; set; } = 0xFFFF;
|
||||
|
||||
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
|
||||
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||
|
||||
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
|
||||
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
|
||||
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
|
||||
public uint FocusFieldSprite { get; set; }
|
||||
|
||||
public Action<string>? OnSubmit { get; set; }
|
||||
|
||||
private string _text = "";
|
||||
private int _caret;
|
||||
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
|
||||
public string Text => _text;
|
||||
public int CaretPos => _caret;
|
||||
|
||||
private readonly List<string> _history = new();
|
||||
private int _historyIndex = -1;
|
||||
public int HistoryCount => _history.Count;
|
||||
|
||||
private bool _focused;
|
||||
private bool _selecting; // mouse drag in progress
|
||||
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
|
||||
|
||||
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
|
||||
private Silk.NET.Input.Key? _repeatKey;
|
||||
private double _repeatTimer;
|
||||
private const double RepeatDelay = 0.40; // s before the first repeat
|
||||
private const double RepeatRate = 0.04; // s between repeats (~25/s)
|
||||
|
||||
public UiField()
|
||||
{
|
||||
AcceptsFocus = true;
|
||||
IsEditControl = true;
|
||||
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
|
||||
}
|
||||
|
||||
/// <summary>The field draws its own background + caret + caps; its dat cap sub-elements
|
||||
/// are reproduced procedurally, so the importer must not build them as widgets.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
// ── Editing primitives ──────────────────────────────────────────────
|
||||
|
||||
public void InsertChar(char c)
|
||||
{
|
||||
if (c < 0x20 || c == 0x7F) return;
|
||||
DeleteSelection();
|
||||
if (_text.Length >= MaxCharacters) return;
|
||||
_text = _text.Insert(_caret, c.ToString());
|
||||
_caret++;
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
public void Backspace()
|
||||
{
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret == 0) return;
|
||||
_text = _text.Remove(_caret - 1, 1);
|
||||
_caret--;
|
||||
}
|
||||
|
||||
public void DeleteForward()
|
||||
{
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret >= _text.Length) return;
|
||||
_text = _text.Remove(_caret, 1);
|
||||
}
|
||||
|
||||
private void MoveCaretTo(int target, bool shift)
|
||||
{
|
||||
target = Math.Clamp(target, 0, _text.Length);
|
||||
if (shift) _selAnchor ??= _caret; // begin/extend selection from the old caret
|
||||
else _selAnchor = null; // plain move collapses any selection
|
||||
_caret = target;
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
/// <summary>Move the caret left (negative) or right (positive) by <paramref name="delta"/>
|
||||
/// glyph positions without extending a selection. Public for test access.</summary>
|
||||
public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false);
|
||||
|
||||
private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift);
|
||||
|
||||
// ── Selection ────────────────────────────────────────────────────────
|
||||
|
||||
private (int lo, int hi) SelSpan()
|
||||
{
|
||||
if (_selAnchor is not { } a || a == _caret) return (_caret, _caret);
|
||||
return (Math.Min(a, _caret), Math.Max(a, _caret));
|
||||
}
|
||||
|
||||
private bool HasSelection => _selAnchor is { } a && a != _caret;
|
||||
|
||||
private string SelectedText()
|
||||
{
|
||||
var (lo, hi) = SelSpan();
|
||||
return hi > lo ? _text.Substring(lo, hi - lo) : "";
|
||||
}
|
||||
|
||||
/// <summary>Remove the selected span (if any). Returns true if it removed anything.</summary>
|
||||
private bool DeleteSelection()
|
||||
{
|
||||
if (!HasSelection) { _selAnchor = null; return false; }
|
||||
var (lo, hi) = SelSpan();
|
||||
_text = _text.Remove(lo, hi - lo);
|
||||
_caret = lo;
|
||||
_selAnchor = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SelectAll()
|
||||
{
|
||||
if (_text.Length == 0) { _selAnchor = null; return; }
|
||||
_selAnchor = 0;
|
||||
_caret = _text.Length;
|
||||
}
|
||||
|
||||
private void CopySelection()
|
||||
{
|
||||
var s = SelectedText();
|
||||
if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s;
|
||||
}
|
||||
|
||||
private void CutSelection()
|
||||
{
|
||||
if (!HasSelection) return;
|
||||
CopySelection();
|
||||
DeleteSelection();
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
private void Paste()
|
||||
{
|
||||
if (Keyboard is null) return;
|
||||
string clip = Keyboard.ClipboardText ?? "";
|
||||
if (clip.Length == 0) return;
|
||||
|
||||
// Single-line field: strip control chars (newlines/tabs) from pasted text.
|
||||
var sb = new System.Text.StringBuilder(clip.Length);
|
||||
foreach (char ch in clip)
|
||||
if (ch >= 0x20 && ch != 0x7F) sb.Append(ch);
|
||||
if (sb.Length == 0) return;
|
||||
|
||||
DeleteSelection();
|
||||
int room = MaxCharacters - _text.Length;
|
||||
if (room <= 0) return;
|
||||
string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString();
|
||||
_text = _text.Insert(_caret, ins);
|
||||
_caret += ins.Length;
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
// ── Submit + history ─────────────────────────────────────────────────
|
||||
|
||||
public void Submit()
|
||||
{
|
||||
var t = _text;
|
||||
if (t.Trim().Length == 0) { Clear(); return; }
|
||||
OnSubmit?.Invoke(t);
|
||||
PushHistory(t);
|
||||
Clear();
|
||||
}
|
||||
|
||||
private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; }
|
||||
|
||||
private void PushHistory(string t)
|
||||
{
|
||||
_history.Add(t);
|
||||
if (_history.Count > 100) _history.RemoveAt(0);
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
public void HistoryPrev()
|
||||
{
|
||||
if (_history.Count == 0) return;
|
||||
_historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1);
|
||||
SetTextFromHistory();
|
||||
}
|
||||
|
||||
public void HistoryNext()
|
||||
{
|
||||
if (_historyIndex < 0) return;
|
||||
_historyIndex++;
|
||||
if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; }
|
||||
SetTextFromHistory();
|
||||
}
|
||||
|
||||
private void SetTextFromHistory()
|
||||
{
|
||||
_text = _history[_historyIndex];
|
||||
_caret = _text.Length;
|
||||
_selAnchor = null;
|
||||
}
|
||||
|
||||
// ── Geometry ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Pixel-X of the caret (Σ glyph advances to <paramref name="i"/>).</summary>
|
||||
private float MeasureTo(int i)
|
||||
{
|
||||
if (i <= 0) return 0f;
|
||||
string s = _text.Substring(0, Math.Min(i, _text.Length));
|
||||
return DatFont is { } df ? df.MeasureWidth(s)
|
||||
: Font is { } bf ? bf.MeasureWidth(s) : 0f;
|
||||
}
|
||||
|
||||
public float CaretPixelX() => MeasureTo(_caret);
|
||||
|
||||
/// <summary>Map a local X (click) to the nearest caret index — retail
|
||||
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.</summary>
|
||||
private int HitCharX(float localX)
|
||||
{
|
||||
float target = localX - Padding + _scrollX;
|
||||
if (target <= 0f) return 0;
|
||||
int best = 0;
|
||||
float bestDist = float.MaxValue;
|
||||
for (int i = 0; i <= _text.Length; i++)
|
||||
{
|
||||
float d = MathF.Abs(MeasureTo(i) - target);
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Draw ─────────────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed).
|
||||
// Unfocused: the flat translucent rect. Both go through the sprite bucket
|
||||
// (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top.
|
||||
bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0;
|
||||
if (lit)
|
||||
{
|
||||
var (tex, tw, th) = SpriteResolve!(FocusFieldSprite);
|
||||
if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
else lit = false;
|
||||
}
|
||||
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
float ty = (Height - lh) * 0.5f;
|
||||
float visibleW = MathF.Max(1f, Width - 2f * Padding);
|
||||
|
||||
// Horizontal scroll: keep the caret inside the field; clamp so we never scroll past
|
||||
// the text. Then draw only the glyph window that lands inside the field — a single-
|
||||
// line text box clips + scrolls (retail UIElement_Text) rather than overflowing the
|
||||
// field (which previously spilled the text out into the 3D world).
|
||||
float caretX = MeasureTo(_caret);
|
||||
float fullW = MeasureTo(_text.Length);
|
||||
if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW;
|
||||
if (caretX < _scrollX) _scrollX = caretX;
|
||||
_scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW));
|
||||
|
||||
// Visible character window [start, end).
|
||||
int start = 0;
|
||||
while (start < _text.Length && MeasureTo(start + 1) <= _scrollX) start++;
|
||||
int end = start;
|
||||
while (end < _text.Length && MeasureTo(end + 1) - _scrollX <= visibleW) end++;
|
||||
|
||||
// Selection highlight BEHIND the text, clipped to the field.
|
||||
if (HasSelection)
|
||||
{
|
||||
var (lo, hi) = SelSpan();
|
||||
float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f);
|
||||
float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW);
|
||||
if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor);
|
||||
}
|
||||
|
||||
if (end > start)
|
||||
{
|
||||
string vis = _text.Substring(start, end - start);
|
||||
float vx = Padding + (MeasureTo(start) - _scrollX);
|
||||
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor);
|
||||
else ctx.DrawString(vis, vx, ty, TextColor, Font);
|
||||
}
|
||||
|
||||
if (_focused)
|
||||
{
|
||||
// Caret on TOP of the text → submitted after the text in the same bucket.
|
||||
float cx = Padding + (caretX - _scrollX);
|
||||
if (cx >= Padding - 1f && cx <= Width - Padding + 1f)
|
||||
ctx.DrawFill(cx, ty, 1f, lh, TextColor);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-repeat ──────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
if (_repeatKey is not { } k) return;
|
||||
_repeatTimer -= deltaSeconds;
|
||||
if (_repeatTimer > 0) return;
|
||||
_repeatTimer = RepeatRate;
|
||||
bool shift = ShiftHeld();
|
||||
switch (k)
|
||||
{
|
||||
case Silk.NET.Input.Key.Backspace: Backspace(); break;
|
||||
case Silk.NET.Input.Key.Delete: DeleteForward(); break;
|
||||
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); break;
|
||||
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); break;
|
||||
default: _repeatKey = null; break;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartRepeat(Silk.NET.Input.Key k) { _repeatKey = k; _repeatTimer = RepeatDelay; }
|
||||
|
||||
private bool CtrlHeld() => Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|
||||
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
|
||||
|
||||
private bool ShiftHeld() => Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftLeft)
|
||||
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftRight));
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.FocusGained: _focused = true; return true;
|
||||
case UiEventType.FocusLost:
|
||||
_focused = false; _historyIndex = -1;
|
||||
_selAnchor = null; _selecting = false; _repeatKey = null;
|
||||
return true;
|
||||
|
||||
case UiEventType.Char:
|
||||
InsertChar((char)e.Data0);
|
||||
return true;
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
_caret = HitCharX(e.Data1);
|
||||
_selAnchor = _caret; // anchor; a drag will extend, a plain click won't
|
||||
_selecting = true;
|
||||
return true;
|
||||
case UiEventType.MouseMove:
|
||||
if (_selecting) _caret = HitCharX(e.Data1);
|
||||
return true;
|
||||
case UiEventType.MouseUp:
|
||||
_selecting = false;
|
||||
return true;
|
||||
|
||||
case UiEventType.KeyUp:
|
||||
if ((Silk.NET.Input.Key)e.Data0 == _repeatKey) _repeatKey = null;
|
||||
return true;
|
||||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
if (CtrlHeld())
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case Silk.NET.Input.Key.A: SelectAll(); return true;
|
||||
case Silk.NET.Input.Key.C: CopySelection(); return true;
|
||||
case Silk.NET.Input.Key.X: CutSelection(); return true;
|
||||
case Silk.NET.Input.Key.V: Paste(); return true;
|
||||
}
|
||||
return true; // swallow other Ctrl combos while typing
|
||||
}
|
||||
|
||||
bool shift = ShiftHeld();
|
||||
switch (key)
|
||||
{
|
||||
case Silk.NET.Input.Key.Enter:
|
||||
case Silk.NET.Input.Key.KeypadEnter:
|
||||
Submit();
|
||||
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
|
||||
return true;
|
||||
case Silk.NET.Input.Key.Backspace: Backspace(); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Delete: DeleteForward(); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); StartRepeat(key); return true;
|
||||
case Silk.NET.Input.Key.Home: MoveCaretTo(0, shift); return true;
|
||||
case Silk.NET.Input.Key.End: MoveCaretTo(_text.Length, shift); return true;
|
||||
case Silk.NET.Input.Key.Up: HistoryPrev(); return true;
|
||||
case Silk.NET.Input.Key.Down: HistoryNext(); return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable
|
|||
public UiRoot Root { get; } = new();
|
||||
public TextRenderer TextRenderer { get; }
|
||||
public BitmapFont? DefaultFont { get; set; }
|
||||
|
||||
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
|
||||
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
|
||||
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiText"/>'s
|
||||
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
|
||||
public IKeyboard? Keyboard { get; private set; }
|
||||
|
||||
private long _startTicks = System.Environment.TickCount64;
|
||||
|
||||
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
||||
|
|
@ -82,6 +89,7 @@ public sealed class UiHost : System.IDisposable
|
|||
|
||||
public void WireKeyboard(IKeyboard kb)
|
||||
{
|
||||
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
|
||||
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
|
||||
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
|
||||
kb.KeyChar += (_, c) => Root.OnChar(c);
|
||||
|
|
|
|||
246
src/AcDream.App/UI/UiMenu.cs
Normal file
246
src/AcDream.App/UI/UiMenu.cs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic dropdown menu. Ports retail <c>UIElement_Menu</c>
|
||||
/// (<c>RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163</c>) +
|
||||
/// <c>UIElement_Menu::MakePopup @0x46d310</c>: the button is labelled with
|
||||
/// the active target; clicking opens a column-major popup on the dat-driven menu
|
||||
/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel
|
||||
/// knowledge are populated by the controller, not baked into this widget. Built
|
||||
/// by <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/> for Type-6 elements.
|
||||
/// </summary>
|
||||
public sealed class UiMenu : UiElement
|
||||
{
|
||||
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
|
||||
public readonly record struct MenuItem(string Label, object? Payload);
|
||||
|
||||
/// <summary>The rows, populated by the controller. Laid out column-major:
|
||||
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
|
||||
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
|
||||
|
||||
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
|
||||
public object? Selected { get; set; }
|
||||
|
||||
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
||||
public Action<object?>? OnSelect { get; set; }
|
||||
|
||||
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
|
||||
public Func<object?, bool>? EnabledProvider { get; set; }
|
||||
|
||||
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||
public Func<string>? ButtonLabelProvider { get; set; }
|
||||
|
||||
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
|
||||
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
|
||||
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
|
||||
|
||||
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
|
||||
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
|
||||
// square; the label starts just past it (box width + small gap) so text aligns with
|
||||
// the box instead of overlapping it.
|
||||
private const float TextIndent = 19f;
|
||||
// The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its
|
||||
// left socket (~x4–20 of the 46px button); the caption starts past it so it doesn't
|
||||
// render over the LED.
|
||||
private const float ButtonTextIndent = 20f;
|
||||
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
// Button face sprites (dat menu element 0x10000014).
|
||||
public uint NormalSprite { get; set; }
|
||||
public uint PressedSprite { get; set; }
|
||||
// Popup chrome sprites (dat menu popup template, layout 0x21000006).
|
||||
public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles)
|
||||
public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
|
||||
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
|
||||
|
||||
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
|
||||
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
|
||||
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
|
||||
public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
/// <summary>Disabled/unavailable item text — retail GREYS these (UIElement state 0xd
|
||||
/// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that
|
||||
/// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat
|
||||
/// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump.</summary>
|
||||
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
|
||||
|
||||
private bool _open;
|
||||
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
|
||||
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
|
||||
private float InteriorW => ColumnCount * ColumnWidth;
|
||||
private float InteriorH => RowsPerColumn * RowHeight;
|
||||
private float OuterW => InteriorW + 2 * Border;
|
||||
private float OuterH => InteriorH + 2 * Border;
|
||||
|
||||
public UiMenu() { CapturesPointerDrag = true; }
|
||||
|
||||
/// <summary>The menu draws its own button face + popup; its dat label/row children
|
||||
/// must NOT be built (an invisible label child would intercept the button click).</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var resolve = SpriteResolve;
|
||||
|
||||
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
|
||||
if (resolve is not null)
|
||||
{
|
||||
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||||
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
|
||||
}
|
||||
DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor);
|
||||
}
|
||||
|
||||
// 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the
|
||||
// round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow
|
||||
// point. Slicing keeps the LED + arrow undistorted when the button widens to its label.
|
||||
private const float FaceCapL = 20f, FaceCapR = 12f;
|
||||
|
||||
private void DrawButtonFace(UiRenderContext ctx, uint tex, float tw)
|
||||
{
|
||||
float uL = FaceCapL / tw, uR = (tw - FaceCapR) / tw;
|
||||
float midDest = Width - FaceCapL - FaceCapR;
|
||||
ctx.DrawSprite(tex, 0f, 0f, FaceCapL, Height, 0f, 0f, uL, 1f, Vector4.One); // LED cap
|
||||
if (midDest > 0f)
|
||||
ctx.DrawSprite(tex, FaceCapL, 0f, midDest, Height, uL, 0f, uR, 1f, Vector4.One); // gold body (stretched)
|
||||
ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap
|
||||
}
|
||||
|
||||
/// <summary>The button width that fits "LED cap + channel label + arrow cap" — retail
|
||||
/// sizes the talk-focus button to its selected label. The controller widens the button
|
||||
/// to this and reflows the input field to start after it.</summary>
|
||||
public float NaturalButtonWidth()
|
||||
{
|
||||
string text = ButtonLabelProvider?.Invoke() ?? "";
|
||||
float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f;
|
||||
return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap
|
||||
}
|
||||
|
||||
/// <summary>The open popup draws in the OVERLAY pass so it sits on top of the whole
|
||||
/// UI — otherwise the translucent chat panel (drawn after this element in the main
|
||||
/// pass) greys out the part of the popup that overlaps it.</summary>
|
||||
protected override void OnDrawOverlay(UiRenderContext ctx)
|
||||
{
|
||||
var resolve = SpriteResolve;
|
||||
if (!_open || resolve is null) return;
|
||||
|
||||
// Column-major popup opening UPWARD from the button, wrapped in the universal
|
||||
// 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a
|
||||
// bevelled floating window). Force OPAQUE (a menu reads solid even though the
|
||||
// chat window is translucent). Draw bevel → panel fill → row sprites → labels,
|
||||
// all through the sprite bucket in submission order so labels land on top.
|
||||
ctx.PushAlphaAbsolute(1f);
|
||||
try
|
||||
{
|
||||
float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
|
||||
float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
|
||||
|
||||
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
|
||||
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows
|
||||
|
||||
for (int i = 0; i < Items.Count; i++)
|
||||
{
|
||||
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||
float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
|
||||
bool selected = Equals(Items[i].Payload, Selected);
|
||||
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight);
|
||||
}
|
||||
|
||||
float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
|
||||
for (int i = 0; i < Items.Count; i++)
|
||||
{
|
||||
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
|
||||
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
|
||||
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
|
||||
avail ? TextColorAvailable : TextColorGhosted);
|
||||
}
|
||||
}
|
||||
finally { ctx.PopAlpha(); }
|
||||
}
|
||||
|
||||
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
|
||||
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
|
||||
/// <paramref name="w"/>,<paramref name="h"/>). Reuses the same geometry +
|
||||
/// <see cref="RetailChromeSprites"/> ids as <see cref="UiNineSlicePanel"/>; no resize
|
||||
/// grips (a menu popup is not resizable).</summary>
|
||||
private void DrawBevel(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
float x, float y, float w, float h)
|
||||
{
|
||||
var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border);
|
||||
void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H);
|
||||
P(RetailChromeSprites.CenterFill, r.Center);
|
||||
P(RetailChromeSprites.TopEdge, r.Top);
|
||||
P(RetailChromeSprites.BottomEdge, r.Bottom);
|
||||
P(RetailChromeSprites.LeftEdge, r.Left);
|
||||
P(RetailChromeSprites.RightEdge, r.Right);
|
||||
P(RetailChromeSprites.CornerTL, r.TL);
|
||||
P(RetailChromeSprites.CornerTR, r.TR);
|
||||
P(RetailChromeSprites.CornerBL, r.BL);
|
||||
P(RetailChromeSprites.CornerBR, r.BR);
|
||||
}
|
||||
|
||||
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
|
||||
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0) return;
|
||||
var (tex, tw, th) = resolve(id);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
// Tile at native size (the panel fill is 191×2; rows are 191×17 = 1:1).
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
|
||||
}
|
||||
|
||||
private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color)
|
||||
{
|
||||
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color);
|
||||
else ctx.DrawString(s, x, y, color, Font);
|
||||
}
|
||||
|
||||
protected override bool OnHitTest(float lx, float ly)
|
||||
=> _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height)
|
||||
: base.OnHitTest(lx, ly);
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type != UiEventType.MouseDown) return false;
|
||||
|
||||
float lx = e.Data1, ly = e.Data2;
|
||||
if (_open && ly < 0) // clicked inside the upward popup
|
||||
{
|
||||
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring
|
||||
// (outside the interior) just close the menu.
|
||||
float ix = lx - Border, iy = ly - (-OuterH + Border);
|
||||
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
|
||||
{
|
||||
int col = (int)(ix / ColumnWidth);
|
||||
int row = (int)(iy / RowHeight);
|
||||
int idx = col * RowsPerColumn + row;
|
||||
// Only pick enabled items.
|
||||
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
|
||||
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
|
||||
{
|
||||
// The widget REPORTS the pick; the controller owns Selected (it sets
|
||||
// Selected only for payloads it acts on). This mirrors retail
|
||||
// UIElement_Menu::NewSelection delegating to the owner rather than
|
||||
// self-selecting — so a deferred/no-op item (e.g. the Squelch /
|
||||
// Tell-to-Selected specials, null payload) leaves the current
|
||||
// selection + highlight unchanged when the controller ignores it.
|
||||
OnSelect?.Invoke(Items[idx].Payload);
|
||||
}
|
||||
}
|
||||
_open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
_open = !_open; // toggle on button click
|
||||
return true;
|
||||
}
|
||||
}
|
||||
171
src/AcDream.App/UI/UiMeter.cs
Normal file
171
src/AcDream.App/UI/UiMeter.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a
|
||||
/// partial-width solid fill, and an optional centered "current/max" numeric
|
||||
/// overlay. <see cref="Fill"/> returns 0..1 (null = no data → empty bar);
|
||||
/// <see cref="Label"/> returns the overlay text (null = no number).
|
||||
///
|
||||
/// <para>
|
||||
/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite
|
||||
/// (glassy center highlight) and the retail dat font are a later polish pass —
|
||||
/// retail's vitals are bars exactly like this, just sprited.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiMeter : UiElement
|
||||
{
|
||||
|
||||
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
|
||||
public Func<float?> Fill { get; set; } = () => 0f;
|
||||
/// <summary>Centered overlay text provider (e.g. "291/291"); null = none.</summary>
|
||||
public Func<string?> Label { get; set; } = () => null;
|
||||
public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f);
|
||||
public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
|
||||
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
|
||||
/// <summary>Retail dat font (Font 0x40000000) for the "cur/max" overlay. When
|
||||
/// set, the label renders through the dat-font two-pass blit (outline + fill);
|
||||
/// when null, the debug <see cref="UiRenderContext.DefaultFont"/> bitmap font
|
||||
/// is used instead. Set by the host when the retail UI is active.</summary>
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
|
||||
/// <summary>Resolver from a RenderSurface DataId to (GL handle, w, h). When set
|
||||
/// with the 9-slice ids below, the bar draws the retail sprites instead of solid color.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
// Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
|
||||
// a TILED gradient middle (the "fill-tile" repeats at native width — it does not
|
||||
// stretch), and a fixed-width right-cap. The "back" slice is the empty track
|
||||
// (drawn full width); the "front" slice is the coloured fill (drawn full-geometry
|
||||
// but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's
|
||||
// shows through when partial). Ids come from the stacked vitals LayoutDesc
|
||||
// (0x2100006C) via the dump-vitals-layout CLI; 0 = none.
|
||||
/// <summary>Empty-track left-cap RenderSurface id.</summary>
|
||||
public uint BackLeft { get; set; }
|
||||
/// <summary>Empty-track middle (tiled gradient) RenderSurface id.</summary>
|
||||
public uint BackTile { get; set; }
|
||||
/// <summary>Empty-track right-cap RenderSurface id.</summary>
|
||||
public uint BackRight { get; set; }
|
||||
/// <summary>Coloured-fill left-cap RenderSurface id.</summary>
|
||||
public uint FrontLeft { get; set; }
|
||||
/// <summary>Coloured-fill middle (tiled gradient) RenderSurface id.</summary>
|
||||
public uint FrontTile { get; set; }
|
||||
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
|
||||
public uint FrontRight { get; set; }
|
||||
|
||||
public UiMeter() { ClickThrough = true; }
|
||||
|
||||
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
|
||||
/// grandchild slice/text elements as separate widgets.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
|
||||
/// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
|
||||
public static (float x, float y, float w, float h) ComputeFillRect(
|
||||
float pct, float w, float h)
|
||||
{
|
||||
if (pct < 0f) pct = 0f;
|
||||
if (pct > 1f) pct = 1f;
|
||||
return (0f, 0f, w * pct, h);
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
float? pct = Fill();
|
||||
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
|
||||
|
||||
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
||||
{
|
||||
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
|
||||
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
|
||||
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
|
||||
// The front carries its own right-cap (shown at 100%); clipping below 100%
|
||||
// removes it and reveals the back track's right-cap — retail's scissor-fill.
|
||||
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Placeholder solid-color fallback.
|
||||
ctx.DrawRect(0, 0, Width, Height, BgColor);
|
||||
if (pct is not null && p > 0f)
|
||||
{
|
||||
var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height);
|
||||
if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor);
|
||||
}
|
||||
}
|
||||
|
||||
string? label = Label();
|
||||
if (!string.IsNullOrEmpty(label))
|
||||
{
|
||||
if (DatFont is { } datFont)
|
||||
{
|
||||
// Retail path: centered cur/max via the dat font's two-pass blit.
|
||||
float tw = datFont.MeasureWidth(label);
|
||||
float tx = (Width - tw) * 0.5f;
|
||||
float ty = (Height - datFont.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor);
|
||||
}
|
||||
else if (ctx.DefaultFont is { } font)
|
||||
{
|
||||
// Fallback: debug bitmap font (no dat font available).
|
||||
float tw = font.MeasureWidth(label);
|
||||
float tx = (Width - tw) * 0.5f;
|
||||
float ty = (Height - font.LineHeight) * 0.5f;
|
||||
ctx.DrawString(label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the full-width horizontal 3-slice (native-width left-cap, stretched
|
||||
/// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED
|
||||
/// so nothing past <paramref name="clipW"/> (local px from the left) is drawn.
|
||||
/// The back track passes <c>clipW = Width</c>; the front fill passes
|
||||
/// <c>clipW = Width * fraction</c>. Clipping UV-crops each slice proportionally,
|
||||
/// so the fill ends cleanly and the back's right-cap shows through when partial.
|
||||
/// A 0 id skips that slice.
|
||||
/// </summary>
|
||||
private void DrawHBar(
|
||||
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint leftId, uint midId, uint rightId, float clipW)
|
||||
{
|
||||
if (clipW <= 0f) return;
|
||||
float w = Width, h = Height;
|
||||
var (lt, lw, _) = resolve(leftId);
|
||||
var (mt, mw, _) = resolve(midId);
|
||||
var (rt, rw, _) = resolve(rightId);
|
||||
|
||||
float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
|
||||
float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
|
||||
float midW = w - capL - capR;
|
||||
|
||||
// Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI
|
||||
// texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their
|
||||
// own native width → a single 1:1 copy. The wide middle spans many native
|
||||
// widths → it TILES, matching retail's "fill-tile" + ImgTex::TileCSI rather
|
||||
// than stretching one copy. (Same UV-repeat the chrome border already uses.)
|
||||
DrawPiece(ctx, lt, 0f, capL, lw, h, clipW);
|
||||
DrawPiece(ctx, mt, capL, midW, mw, h, clipW);
|
||||
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
|
||||
}
|
||||
|
||||
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
|
||||
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
||||
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
|
||||
/// Clipped so nothing past <paramref name="clipW"/> shows. For a cap (span == native)
|
||||
/// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is
|
||||
/// UV-cropped.</summary>
|
||||
private static void DrawPiece(
|
||||
UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW)
|
||||
{
|
||||
if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return;
|
||||
float visibleW = MathF.Min(pieceW, clipW - pieceX);
|
||||
if (visibleW <= 0f) return;
|
||||
float u1 = visibleW / nativeW; // >1 ⇒ texture repeats (tiles); ≤1 ⇒ a partial copy
|
||||
ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
105
src/AcDream.App/UI/UiNineSlicePanel.cs
Normal file
105
src/AcDream.App/UI/UiNineSlicePanel.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="UiPanel"/> whose background is the retail 8-piece window bevel
|
||||
/// (<see cref="RetailChromeSprites"/>): 4 corners + 4 edges around a tiled
|
||||
/// center fill. Retires the flat translucent rect (divergence row TS-30).
|
||||
/// Sprites resolve to (GL handle, width, height) via an injected delegate so
|
||||
/// the widget is testable without GL. In production:
|
||||
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
|
||||
/// </summary>
|
||||
public sealed class UiNineSlicePanel : UiPanel
|
||||
{
|
||||
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
|
||||
public readonly record struct Rect(float X, float Y, float W, float H);
|
||||
|
||||
/// <summary>The nine destination rects for an 8-piece border + center.</summary>
|
||||
public readonly record struct FrameRects(
|
||||
Rect Center, Rect Top, Rect Bottom, Rect Left, Rect Right,
|
||||
Rect TL, Rect TR, Rect BL, Rect BR);
|
||||
|
||||
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
_resolve = resolve;
|
||||
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
|
||||
BorderColor = Vector4.Zero;
|
||||
Draggable = true; // retail windows are movable
|
||||
Resizable = true; // retail windows are resizable
|
||||
// A top-level window is USER-positioned: it must NOT be anchor-managed
|
||||
// by its parent (UiRoot), or the per-frame anchor pass would reset its
|
||||
// Left/Top/Width/Height every frame and undo move/resize. Children
|
||||
// INSIDE the window still anchor to it (the bars stretch with width).
|
||||
Anchors = AnchorEdges.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destination rects (local px) for a frame of (<paramref name="w"/>,
|
||||
/// <paramref name="h"/>) with border thickness <paramref name="b"/>:
|
||||
/// b×b corners, top/bottom edges spanning the interior width at height b,
|
||||
/// left/right edges spanning the interior height at width b, center fills
|
||||
/// the interior.
|
||||
/// </summary>
|
||||
public static FrameRects ComputeFrameRects(float w, float h, int b)
|
||||
{
|
||||
float innerW = w - 2 * b;
|
||||
float innerH = h - 2 * b;
|
||||
return new FrameRects(
|
||||
Center: new Rect(b, b, innerW, innerH),
|
||||
Top: new Rect(b, 0, innerW, b),
|
||||
Bottom: new Rect(b, h - b, innerW, b),
|
||||
Left: new Rect(0, b, b, innerH),
|
||||
Right: new Rect(w - b, b, b, innerH),
|
||||
TL: new Rect(0, 0, b, b),
|
||||
TR: new Rect(w - b, 0, b, b),
|
||||
BL: new Rect(0, h - b, b, b),
|
||||
BR: new Rect(w - b, h - b, b, b));
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
|
||||
// center + edges tile (UV repeat); corners stretch 1:1.
|
||||
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
|
||||
DrawTiled(ctx, RetailChromeSprites.TopEdge, r.Top);
|
||||
DrawTiled(ctx, RetailChromeSprites.BottomEdge, r.Bottom);
|
||||
DrawTiled(ctx, RetailChromeSprites.LeftEdge, r.Left);
|
||||
DrawTiled(ctx, RetailChromeSprites.RightEdge, r.Right);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerTL, r.TL);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
|
||||
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR);
|
||||
|
||||
// Resize-grip overlay (gold ridged edges + square corner studs) drawn on
|
||||
// top of the bevel — the second border layer the vitals LayoutDesc carries
|
||||
// (0x1000063B–0x10000642). Edges tile; the corner stud is the same sprite
|
||||
// at all four corners.
|
||||
DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top);
|
||||
DrawTiled(ctx, RetailChromeSprites.GripBottom, r.Bottom);
|
||||
DrawTiled(ctx, RetailChromeSprites.GripLeft, r.Left);
|
||||
DrawTiled(ctx, RetailChromeSprites.GripRight, r.Right);
|
||||
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TL);
|
||||
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TR);
|
||||
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BL);
|
||||
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BR);
|
||||
}
|
||||
|
||||
private void DrawTiled(UiRenderContext ctx, uint id, Rect d)
|
||||
{
|
||||
if (d.W <= 0 || d.H <= 0) return;
|
||||
var (tex, tw, th) = _resolve(id);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, d.W / tw, d.H / th, Vector4.One);
|
||||
}
|
||||
|
||||
private void DrawStretched(UiRenderContext ctx, uint id, Rect d)
|
||||
{
|
||||
if (d.W <= 0 || d.H <= 0) return;
|
||||
var (tex, _, _) = _resolve(id);
|
||||
if (tex == 0) return;
|
||||
ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, 1, 1, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,14 +57,17 @@ public class UiLabel : UiElement
|
|||
/// callback. Retail equivalent is Keystone's button widget, driven by
|
||||
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
|
||||
/// disabled) from the panel layout.
|
||||
/// Note: the dat-widget button (Type 1 / UIElement_Button) is <see cref="AcDream.App.UI.UiButton"/>
|
||||
/// in <c>UiButton.cs</c> — that is the production widget used by D.2b panels.
|
||||
/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites).
|
||||
/// </summary>
|
||||
public class UiButton : UiPanel
|
||||
public class UiSimpleButton : UiPanel
|
||||
{
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||||
public event System.Action? Click;
|
||||
|
||||
public UiButton()
|
||||
public UiSimpleButton()
|
||||
{
|
||||
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
|
||||
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,29 @@ public sealed class UiRenderContext
|
|||
private readonly System.Collections.Generic.List<Vector2> _stack = new();
|
||||
private Vector2 _current;
|
||||
|
||||
// Alpha (opacity) stack — a window pushes its Opacity so its background/sprite
|
||||
// draws fade (retail's translucent-chat effect). Text draws bypass this (they go
|
||||
// straight to TextRenderer), so text stays sharp over a translucent background.
|
||||
private readonly System.Collections.Generic.List<float> _alphaStack = new();
|
||||
private float _alpha = 1f;
|
||||
|
||||
/// <summary>Current cumulative opacity multiplier applied to sprite + rect draws.</summary>
|
||||
public float AlphaMod => _alpha;
|
||||
|
||||
/// <summary>Multiply <paramref name="a"/> into the running opacity. Pair with <see cref="PopAlpha"/>.</summary>
|
||||
public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; }
|
||||
|
||||
/// <summary>Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays
|
||||
/// that must stay opaque even inside a translucent window. Pair with <see cref="PopAlpha"/>.</summary>
|
||||
public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; }
|
||||
|
||||
public void PopAlpha()
|
||||
{
|
||||
if (_alphaStack.Count == 0) return;
|
||||
_alpha = _alphaStack[^1];
|
||||
_alphaStack.RemoveAt(_alphaStack.Count - 1);
|
||||
}
|
||||
|
||||
public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
|
||||
{
|
||||
TextRenderer = tr;
|
||||
|
|
@ -45,13 +68,33 @@ public sealed class UiRenderContext
|
|||
|
||||
public Vector2 CurrentOrigin => _current;
|
||||
|
||||
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
|
||||
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
|
||||
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
|
||||
public void EndOverlayLayer() => TextRenderer.OverlayMode = false;
|
||||
|
||||
// ── Pass-through draw helpers (add current translate) ──────────────
|
||||
|
||||
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
||||
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color);
|
||||
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
|
||||
|
||||
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
|
||||
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
|
||||
/// all sprites and would cover the text — use this for backgrounds, that for foreground
|
||||
/// fills (carets, vital bars).</summary>
|
||||
public void DrawFill(float x, float y, float w, float h, Vector4 color)
|
||||
=> TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
|
||||
|
||||
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
||||
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness);
|
||||
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness);
|
||||
|
||||
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
||||
float u0, float v0, float u1, float v1, Vector4 tint)
|
||||
=> TextRenderer.DrawSprite(texture,
|
||||
_current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(tint));
|
||||
|
||||
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
|
||||
private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha);
|
||||
|
||||
public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
|
||||
{
|
||||
|
|
@ -59,4 +102,101 @@ public sealed class UiRenderContext
|
|||
if (f is null) return;
|
||||
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
|
||||
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the
|
||||
/// typographic block (in this element's local space). Mirrors retail's
|
||||
/// <c>SurfaceWindow::DrawCharacter</c> (acclient 0x00442bd0): for each glyph
|
||||
/// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
|
||||
/// then the FOREGROUND atlas sub-rect tinted <paramref name="color"/> (the
|
||||
/// fill). The pen advances by
|
||||
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
|
||||
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
|
||||
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
|
||||
/// glyph's OffsetY into the atlas.
|
||||
///
|
||||
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
|
||||
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
|
||||
/// runs the outline pass only when <c>m_bitField & 0x10</c> is set — i.e. the
|
||||
/// element called <c>SetOutline(true)</c> (LayoutDesc property 0xd). The DEFAULT
|
||||
/// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an
|
||||
/// always-on outline shows as a grey halo over the solid menu panel. Pass
|
||||
/// <c>outline:true</c> only for elements retail outlines.</para>
|
||||
/// </summary>
|
||||
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
|
||||
{
|
||||
if (font is null || string.IsNullOrEmpty(text)) return;
|
||||
|
||||
// Baseline of this line in local space; retail draws glyphs whose
|
||||
// descriptor OffsetY already places them relative to the line top, so we
|
||||
// anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore.
|
||||
float originX = _current.X + x;
|
||||
float originY = _current.Y + y;
|
||||
float pen = originX;
|
||||
|
||||
// Snap the LINE baseline to a whole pixel ONCE. Retail's
|
||||
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
|
||||
// (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every
|
||||
// glyph on a line shares one integer baseline. If we instead round EACH glyph's
|
||||
// Y independently and the caller passes a fractional line Y (e.g. a channel-menu
|
||||
// item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round
|
||||
// to different rows and the line looks crooked ("letters dip down"). The vitals
|
||||
// digits never showed it because their bar baseline lands on an integer; chat text
|
||||
// does. Snapping the baseline once, then adding the integer offset, keeps the whole
|
||||
// line on one row and pixel-aligned.
|
||||
float baseY = System.MathF.Round(originY);
|
||||
|
||||
var outlineTint = new Vector4(0f, 0f, 0f, color.W);
|
||||
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (!font.TryGetGlyph(text[i], out var g))
|
||||
continue;
|
||||
|
||||
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
|
||||
// true fractional advance). Vertical: integer baseline + integer per-glyph
|
||||
// offset — never an independent per-glyph round (see baseY note above).
|
||||
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
|
||||
float gy = baseY + g.VerticalOffsetBefore;
|
||||
float gw = g.Width;
|
||||
float gh = g.Height;
|
||||
|
||||
if (gw > 0f && gh > 0f)
|
||||
{
|
||||
// Background (outline) atlas pass, tinted black — drawn behind. Gated by
|
||||
// `outline` (retail's per-element m_bitField & 0x10); off by default so UI
|
||||
// text is crisp fill-only and free of the grey halo over solid panels.
|
||||
if (outline && font.BackgroundTexture != 0)
|
||||
{
|
||||
var (bu0, bv0, bu1, bv1) = AtlasUv(
|
||||
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
||||
font.BackgroundWidth, font.BackgroundHeight);
|
||||
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint);
|
||||
}
|
||||
|
||||
// Foreground (fill) atlas pass, tinted with the requested color.
|
||||
var (fu0, fv0, fu1, fv1) = AtlasUv(
|
||||
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
||||
font.ForegroundWidth, font.ForegroundHeight);
|
||||
TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color);
|
||||
}
|
||||
|
||||
pen += UiDatFont.GlyphAdvance(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
|
||||
/// normalized UVs for an atlas of <paramref name="atlasW"/> x
|
||||
/// <paramref name="atlasH"/>. Guards against a zero-sized atlas.</summary>
|
||||
private static (float u0, float v0, float u1, float v1) AtlasUv(
|
||||
int offsetX, int offsetY, int width, int height, int atlasW, int atlasH)
|
||||
{
|
||||
if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f);
|
||||
float u0 = offsetX / (float)atlasW;
|
||||
float v0 = offsetY / (float)atlasH;
|
||||
float u1 = (offsetX + width) / (float)atlasW;
|
||||
float v1 = (offsetY + height) / (float)atlasH;
|
||||
return (u0, v0, u1, v1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Which edges of a window a resize-drag is affecting (corners combine two).</summary>
|
||||
[System.Flags]
|
||||
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
|
||||
|
||||
/// <summary>
|
||||
/// Top-level UI container. Implements the retail "Device" responsibilities
|
||||
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
|
||||
|
|
@ -40,6 +44,10 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Widget currently receiving keyboard events.</summary>
|
||||
public UiElement? KeyboardFocus { get; private set; }
|
||||
|
||||
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
|
||||
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
|
||||
public UiElement? DefaultTextInput { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Single modal overlay; while set, mouse clicks outside its rect
|
||||
/// are ignored. Retail sets this via Device vtable +0x48.
|
||||
|
|
@ -49,12 +57,30 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Widget with mouse capture (during click-drag).</summary>
|
||||
public UiElement? Captured { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the pointer is over a widget OR a widget holds mouse capture.
|
||||
/// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game
|
||||
/// actions (movement, world-pick) are suppressed while the user interacts with
|
||||
/// a retail window — mirrors ImGui's WantCaptureMouse.
|
||||
/// </summary>
|
||||
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
|
||||
|
||||
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
|
||||
public bool WantsKeyboard => KeyboardFocus is not null;
|
||||
|
||||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||
public UiElement? DragSource { get; private set; }
|
||||
public object? DragPayload { get; private set; }
|
||||
private UiElement? _lastDragHoverTarget;
|
||||
private int _pressX, _pressY;
|
||||
private bool _dragCandidate;
|
||||
private UiElement? _windowDragTarget;
|
||||
private int _windowDragOffX, _windowDragOffY;
|
||||
private UiElement? _resizeTarget;
|
||||
private ResizeEdges _resizeEdges;
|
||||
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
|
||||
private int _resizeMouseX, _resizeMouseY;
|
||||
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
|
||||
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
||||
|
||||
// Hover / tooltip tracking.
|
||||
|
|
@ -109,6 +135,13 @@ public sealed class UiRoot : UiElement
|
|||
// Render children (panels) sorted by z-order — modal last so it
|
||||
// sits on top.
|
||||
DrawSelfAndChildren(ctx);
|
||||
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
|
||||
// chat channel menu isn't greyed by the translucent chat panel that draws
|
||||
// after it in the main pass). Routed to the renderer's overlay layer so it
|
||||
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
|
||||
ctx.BeginOverlayLayer();
|
||||
DrawOverlays(ctx);
|
||||
ctx.EndOverlayLayer();
|
||||
}
|
||||
|
||||
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
||||
|
|
@ -120,6 +153,26 @@ public sealed class UiRoot : UiElement
|
|||
MouseX = x;
|
||||
MouseY = y;
|
||||
|
||||
// Window resize takes precedence over move / drag-drop / hover.
|
||||
if (_resizeTarget is not null)
|
||||
{
|
||||
var (nx, ny, nw, nh) = ResizeRect(
|
||||
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
||||
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
|
||||
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
||||
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
||||
return;
|
||||
}
|
||||
|
||||
// Window-move drag takes precedence over drag-drop / hover / fall-through.
|
||||
if (_windowDragTarget is not null)
|
||||
{
|
||||
_windowDragTarget.Left = x - _windowDragOffX;
|
||||
_windowDragTarget.Top = y - _windowDragOffY;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have capture, deliver MouseMove to the captured widget
|
||||
// AND drive drag state machine; do NOT fall through.
|
||||
if (Captured is not null)
|
||||
|
|
@ -155,19 +208,68 @@ public sealed class UiRoot : UiElement
|
|||
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
|
||||
return;
|
||||
|
||||
var (target, lx, ly) = HitTestTopDown(x, y);
|
||||
var (target, _, _) = HitTestTopDown(x, y);
|
||||
if (target is null)
|
||||
{
|
||||
// Clicking the 3D world exits write mode (no submit) and returns control to
|
||||
// the character — retail blurs the chat input on an outside click.
|
||||
if (btn == UiMouseButton.Left) SetKeyboardFocus(null);
|
||||
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set keyboard focus if target accepts it.
|
||||
if (target.AcceptsFocus) SetKeyboardFocus(target);
|
||||
// Keyboard focus follows a left click: the input bar (an edit control) takes
|
||||
// focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
|
||||
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
|
||||
if (btn == UiMouseButton.Left)
|
||||
SetKeyboardFocus(target.AcceptsFocus ? target : null);
|
||||
|
||||
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
|
||||
SetCapture(target);
|
||||
_dragCandidate = true;
|
||||
|
||||
// Window resize / move: find the window (Draggable or Resizable ancestor).
|
||||
// A left-drag starting near an edge resizes; interior drag repositions;
|
||||
// otherwise it's a normal drag-drop candidate.
|
||||
var window = FindWindow(target);
|
||||
if (btn == UiMouseButton.Left && window is not null)
|
||||
{
|
||||
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
|
||||
if (edges != ResizeEdges.None)
|
||||
{
|
||||
// Edge resize still wins, even over a CapturesPointerDrag child:
|
||||
// a resizable chat window can be resized from its frame.
|
||||
_resizeTarget = window;
|
||||
_resizeEdges = edges;
|
||||
_resizeStartX = window.Left; _resizeStartY = window.Top;
|
||||
_resizeStartW = window.Width; _resizeStartH = window.Height;
|
||||
_resizeMouseX = x; _resizeMouseY = y;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else if (target.CapturesPointerDrag)
|
||||
{
|
||||
// The pressed widget owns interior drags (e.g. text selection):
|
||||
// do NOT move the ancestor window. The already-dispatched MouseDown
|
||||
// event + SetCapture(target) let the target drive its own drag via
|
||||
// the MouseMove events it receives while captured.
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else if (window.Draggable)
|
||||
{
|
||||
_windowDragTarget = window;
|
||||
_windowDragOffX = x - (int)window.Left;
|
||||
_windowDragOffY = y - (int)window.Top;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else { _dragCandidate = true; }
|
||||
}
|
||||
else if (target.CapturesPointerDrag)
|
||||
{
|
||||
// No window ancestor, but the target still owns its interior drag.
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dragCandidate = true;
|
||||
}
|
||||
|
||||
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
|
||||
int rawType = btn switch
|
||||
|
|
@ -177,8 +279,13 @@ public sealed class UiRoot : UiElement
|
|||
UiMouseButton.Middle => UiEventType.MiddleDown,
|
||||
_ => UiEventType.MouseDown,
|
||||
};
|
||||
// Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use
|
||||
// target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL
|
||||
// child, so for a nested target (e.g. the chat view inset inside its window)
|
||||
// they'd be offset by the child's position — which mis-anchored drag-select.
|
||||
var sp = target.ScreenPosition;
|
||||
var e = new UiEvent(target.EventId, target, rawType,
|
||||
Data0: (int)flags, Data1: (int)lx, Data2: (int)ly);
|
||||
Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||
BubbleEvent(target, in e);
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +294,20 @@ public sealed class UiRoot : UiElement
|
|||
MouseX = x; MouseY = y;
|
||||
UpdateButtonFlag(btn, down: false);
|
||||
|
||||
if (_resizeTarget is not null)
|
||||
{
|
||||
_resizeTarget = null;
|
||||
ReleaseCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_windowDragTarget is not null)
|
||||
{
|
||||
_windowDragTarget = null;
|
||||
ReleaseCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (DragSource is not null)
|
||||
{
|
||||
FinishDrag(x, y);
|
||||
|
|
@ -251,6 +372,18 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
public void OnKeyDown(int vk, uint lparam = 0)
|
||||
{
|
||||
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
|
||||
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
|
||||
// also fall through to a game hotkey.
|
||||
if (KeyboardFocus is null && DefaultTextInput is not null
|
||||
&& (vk == (int)Silk.NET.Input.Key.Tab
|
||||
|| vk == (int)Silk.NET.Input.Key.Enter
|
||||
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
|
||||
{
|
||||
SetKeyboardFocus(DefaultTextInput);
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus widget first.
|
||||
if (KeyboardFocus is not null)
|
||||
{
|
||||
|
|
@ -436,6 +569,48 @@ public sealed class UiRoot : UiElement
|
|||
return (null, 0, 0);
|
||||
}
|
||||
|
||||
private static UiElement? FindWindow(UiElement? e)
|
||||
{
|
||||
while (e is not null)
|
||||
{
|
||||
if (e.Draggable || e.Resizable) return e;
|
||||
e = e.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Which edges of <paramref name="w"/>'s screen rect the point
|
||||
/// (<paramref name="x"/>,<paramref name="y"/>) is within <paramref name="grip"/> px of.
|
||||
/// None if the point is outside the grip-expanded box entirely.</summary>
|
||||
internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip)
|
||||
{
|
||||
float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height;
|
||||
if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None;
|
||||
var e = ResizeEdges.None;
|
||||
if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left;
|
||||
if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right;
|
||||
if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top;
|
||||
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
|
||||
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
|
||||
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
|
||||
/// move the origin so the opposite edge stays put.</summary>
|
||||
public static (float x, float y, float w, float h) ResizeRect(
|
||||
float startX, float startY, float startW, float startH,
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH)
|
||||
{
|
||||
float x = startX, y = startY, w = startW, h = startH;
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
|
||||
return (x, y, w, h);
|
||||
}
|
||||
|
||||
private static bool ContainsAbsolute(UiElement e, int x, int y)
|
||||
{
|
||||
var sp = e.ScreenPosition;
|
||||
|
|
|
|||
57
src/AcDream.App/UI/UiScrollable.cs
Normal file
57
src/AcDream.App/UI/UiScrollable.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel-based vertical scroll model. Port of retail <c>UIElement_Scrollable</c>:
|
||||
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) clamped to
|
||||
/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position
|
||||
/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and
|
||||
/// shared by the transcript (UiText) and the scrollbar (UiScrollbar).
|
||||
/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
|
||||
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
|
||||
/// </summary>
|
||||
public sealed class UiScrollable
|
||||
{
|
||||
/// <summary>Total wrapped content height in px (m_iScrollableHeight).</summary>
|
||||
public int ContentHeight { get; set; }
|
||||
/// <summary>Visible viewport height in px.</summary>
|
||||
public int ViewHeight { get; set; }
|
||||
/// <summary>Pixels per text line (scroll quantum). InqScrollDelta line case.</summary>
|
||||
public int LineHeight { get; set; } = 16;
|
||||
|
||||
private int _scrollY;
|
||||
/// <summary>Current scroll offset in px from the top of the content.</summary>
|
||||
public int ScrollY => _scrollY;
|
||||
|
||||
/// <summary>Max scroll = max(0, content - view).</summary>
|
||||
public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight);
|
||||
|
||||
/// <summary>True when content exceeds the view (a scrollbar is warranted).</summary>
|
||||
public bool HasOverflow => ContentHeight > ViewHeight;
|
||||
|
||||
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
|
||||
public bool AtEnd => _scrollY >= MaxScroll;
|
||||
|
||||
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
|
||||
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
|
||||
|
||||
/// <summary>Pin to the bottom (newest content visible).</summary>
|
||||
public void ScrollToEnd() => _scrollY = MaxScroll;
|
||||
|
||||
/// <summary>Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_).</summary>
|
||||
public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight);
|
||||
|
||||
/// <summary>Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_).</summary>
|
||||
public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll;
|
||||
|
||||
/// <summary>Inverse of PositionRatio — used when the user drags the thumb.</summary>
|
||||
public void SetPositionRatio(float ratio)
|
||||
=> SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll));
|
||||
|
||||
/// <summary>Scroll by whole lines (sign: +down/newer, -up/older).</summary>
|
||||
public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight);
|
||||
|
||||
/// <summary>Scroll by a page = one view height (InqScrollDelta page case).</summary>
|
||||
public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight);
|
||||
}
|
||||
210
src/AcDream.App/UI/UiScrollbar.cs
Normal file
210
src/AcDream.App/UI/UiScrollbar.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic scrollbar. Ports retail <c>UIElement_Scrollbar</c>
|
||||
/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137);
|
||||
/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
|
||||
/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains
|
||||
/// the full scrollbar widget with distinct up/down button children:
|
||||
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
|
||||
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
|
||||
/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
|
||||
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
|
||||
/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the
|
||||
/// rendered scrollbar's height; the widget responds to those regions directly via hit
|
||||
/// comparison in OnEvent without requiring separate child elements.
|
||||
/// </remarks>
|
||||
public sealed class UiScrollbar : UiElement
|
||||
{
|
||||
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
|
||||
public UiScrollable? Model { get; set; }
|
||||
|
||||
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
|
||||
public uint TrackSprite { get; set; }
|
||||
|
||||
/// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
|
||||
public uint ThumbSprite { get; set; }
|
||||
|
||||
/// <summary>Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall).</summary>
|
||||
public uint ThumbTopSprite { get; set; }
|
||||
|
||||
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
|
||||
public uint ThumbBotSprite { get; set; }
|
||||
|
||||
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
|
||||
public uint UpSprite { get; set; }
|
||||
|
||||
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
|
||||
public uint DownSprite { get; set; }
|
||||
|
||||
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
|
||||
private const float MinThumb = 8f;
|
||||
|
||||
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
|
||||
private const float CapH = 3f;
|
||||
|
||||
/// <summary>Up/down button height in pixels. Matches element height 16px from
|
||||
/// the up/down button children in base layout 0x2100003E.</summary>
|
||||
private const float ButtonH = 16f;
|
||||
|
||||
private bool _draggingThumb;
|
||||
private float _dragOffsetY;
|
||||
|
||||
public UiScrollbar() { CapturesPointerDrag = true; }
|
||||
|
||||
/// <summary>The scrollbar draws its own track/thumb/arrows; its dat up/down button
|
||||
/// children are reproduced procedurally, so the importer must not build them.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the thumb rectangle (local y origin and height) within the track area
|
||||
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout
|
||||
/// @0x4710d0</c>: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top
|
||||
/// offset = trackTop + (trackLen - thumbH) * PositionRatio.
|
||||
/// </summary>
|
||||
/// <param name="m">The scroll model.</param>
|
||||
/// <param name="trackTop">Y of the top of the usable track area (below up-button).</param>
|
||||
/// <param name="trackLen">Pixel length of the usable track area (between up and down buttons).</param>
|
||||
/// <returns>Local Y of the thumb's top edge, and its pixel height.</returns>
|
||||
public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen)
|
||||
{
|
||||
float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
|
||||
float travel = trackLen - h;
|
||||
float y = trackTop + travel * m.PositionRatio;
|
||||
return (y, h);
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (Model is not { } m || SpriteResolve is not { } resolve) return;
|
||||
|
||||
// Track background — TILED vertically (retail DrawMode=Normal). The native track
|
||||
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
|
||||
// Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1.
|
||||
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
|
||||
|
||||
// Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art.
|
||||
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
|
||||
|
||||
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
|
||||
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
|
||||
// 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset
|
||||
// or the thumb is too short to hold both caps.
|
||||
if (m.HasOverflow)
|
||||
{
|
||||
float trackTop = ButtonH;
|
||||
float trackLen = Height - 2f * ButtonH;
|
||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
|
||||
{
|
||||
DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH);
|
||||
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
|
||||
DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
|
||||
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, _, _) = resolve(id);
|
||||
if (tex == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
|
||||
/// the top scroll button's (down-art) arrow upward.</summary>
|
||||
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, _, _) = resolve(id);
|
||||
if (tex == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One);
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
|
||||
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary>
|
||||
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, tw, th) = resolve(id);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (Model is not { } m) return false;
|
||||
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
|
||||
float ly = e.Data2;
|
||||
|
||||
// Up-button region: top ButtonH rows.
|
||||
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; }
|
||||
|
||||
// Down-button region: bottom ButtonH rows.
|
||||
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; }
|
||||
|
||||
// Track interior: start a thumb drag or page-scroll.
|
||||
float trackTop = ButtonH;
|
||||
float trackLen = Height - 2f * ButtonH;
|
||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||
|
||||
if (ly >= ty && ly <= ty + th)
|
||||
{
|
||||
// Clicked inside the thumb — begin drag with offset from thumb top.
|
||||
_draggingThumb = true;
|
||||
_dragOffsetY = ly - ty;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clicked above or below thumb — page scroll (HandleButtonClick page case).
|
||||
m.ScrollByPage(ly < ty ? -1 : 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove when _draggingThumb:
|
||||
{
|
||||
// Map current local Y (minus drag offset from thumb top) back to a
|
||||
// position ratio across the available travel distance.
|
||||
float trackTop = ButtonH;
|
||||
float trackLen = Height - 2f * ButtonH;
|
||||
float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
|
||||
float travel = MathF.Max(1f, trackLen - thumbH);
|
||||
float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
|
||||
m.SetPositionRatio(newRatio);
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
448
src/AcDream.App/UI/UiText.cs
Normal file
448
src/AcDream.App/UI/UiText.cs
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Scrollable text view for retail UIElement_Text elements
|
||||
/// (<c>RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655</c>).
|
||||
/// Renders the lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
|
||||
/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps
|
||||
/// text inside the window.
|
||||
///
|
||||
/// <para>
|
||||
/// Supports Windows-like text selection: a left-click-drag inside the transcript
|
||||
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out
|
||||
/// stops that interior drag from moving the host window), and Ctrl+C copies the
|
||||
/// selected span to the clipboard. Ctrl+A selects everything.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiText : UiElement
|
||||
{
|
||||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||
public readonly record struct Line(string Text, Vector4 Color);
|
||||
|
||||
/// <summary>A caret position: a line index into the cached line list plus a
|
||||
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
|
||||
public readonly record struct Pos(int Line, int Col);
|
||||
|
||||
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
|
||||
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
|
||||
|
||||
/// <summary>Font for the transcript; falls back to the context default.</summary>
|
||||
public BitmapFont? Font { get; set; }
|
||||
|
||||
/// <summary>Retail dat font (0x40000000) for the transcript. When set, glyphs
|
||||
/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph
|
||||
/// advance; when null, the debug BitmapFont path is used. Set by the controller.</summary>
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
|
||||
/// <summary>Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by
|
||||
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||
|
||||
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound
|
||||
/// UiText (no controller) draws nothing. Set to the retail translucent value by
|
||||
/// the controller (e.g. <c>ChatWindowController</c>).</summary>
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
|
||||
|
||||
/// <summary>Optional dat state-sprite background (the element's own media), drawn
|
||||
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
|
||||
public uint BackgroundSprite { get; set; }
|
||||
|
||||
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
|
||||
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>Highlight colour painted behind a selected character span.</summary>
|
||||
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
||||
|
||||
/// <summary>Inner text inset from the view edges, px.</summary>
|
||||
public float Padding { get; set; } = 4f;
|
||||
|
||||
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center
|
||||
/// justification): draws the FIRST line centered horizontally AND vertically in the
|
||||
/// element rect, with NO scroll/selection machinery. Used for static labels such as
|
||||
/// the vitals cur/max numbers. The centering formula is IDENTICAL to
|
||||
/// <see cref="UiMeter"/>'s former number overlay so those numbers stay pixel-identical
|
||||
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
|
||||
public bool Centered { get; set; }
|
||||
|
||||
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
|
||||
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
|
||||
private bool _pinBottom = true;
|
||||
|
||||
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
|
||||
|
||||
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
|
||||
private IReadOnlyList<Line> _lastLines = Array.Empty<Line>();
|
||||
private BitmapFont? _lastFont;
|
||||
private UiDatFont? _lastDatFont;
|
||||
private float _lastLineHeight = 16f;
|
||||
private float _lastBaseY; // top Y of line 0 in local space
|
||||
private float _lastPadding = 4f;
|
||||
|
||||
// ── Selection state ──────────────────────────────────────────────────
|
||||
private Pos? _selAnchor; // where the drag started
|
||||
private Pos? _selCaret; // where the drag currently is
|
||||
private bool _selecting;
|
||||
|
||||
public UiText()
|
||||
{
|
||||
AcceptsFocus = true;
|
||||
IsEditControl = true; // absorb keys (Ctrl+C) while focused
|
||||
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
|
||||
}
|
||||
|
||||
/// <summary>The text view draws its own lines + background; any dat sub-elements
|
||||
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>
|
||||
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
|
||||
/// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests.
|
||||
/// </summary>
|
||||
public static float ClampScroll(float scroll, float contentHeight, float viewHeight)
|
||||
{
|
||||
float max = Math.Max(0f, contentHeight - viewHeight);
|
||||
if (scroll < 0f) return 0f;
|
||||
return scroll > max ? max : scroll;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Optional dat state-sprite background drawn UNDER everything else.
|
||||
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||
{
|
||||
var (tex, tw, th) = sr(BackgroundSprite);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
|
||||
// Background must draw UNDER the transcript text. DrawStringDat emits into the
|
||||
// sprite bucket which flushes BEFORE rects, so a DrawRect background would wash
|
||||
// over the text. DrawFill routes the background through the sprite bucket too,
|
||||
// submitted first → text on top.
|
||||
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
|
||||
// line centered H+V with the SAME formula UIElement_Meter used for its label, then
|
||||
// skip the scroll/selection machinery entirely.
|
||||
if (Centered)
|
||||
{
|
||||
var cLines = LinesProvider();
|
||||
if (cLines.Count == 0) return;
|
||||
var line0 = cLines[0];
|
||||
if (DatFont is { } cdf)
|
||||
{
|
||||
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
|
||||
float cy = (Height - cdf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
|
||||
}
|
||||
else if ((Font ?? ctx.DefaultFont) is { } cbf)
|
||||
{
|
||||
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
|
||||
float cy = (Height - cbf.LineHeight) * 0.5f;
|
||||
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the retail dat font when set; fall back to BitmapFont.
|
||||
var datFont = DatFont;
|
||||
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
|
||||
if (datFont is null && bitmapFont is null) return;
|
||||
|
||||
var lines = LinesProvider();
|
||||
|
||||
// Cache the geometry OnEvent will hit-test against. Even when there are no
|
||||
// lines we record the font/padding so a stray hit-test is harmless.
|
||||
_lastLines = lines;
|
||||
_lastDatFont = datFont;
|
||||
_lastFont = bitmapFont;
|
||||
_lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight;
|
||||
_lastPadding = Padding;
|
||||
|
||||
if (lines.Count == 0) return;
|
||||
|
||||
float lh = _lastLineHeight;
|
||||
float top = Padding, bottom = Height - Padding;
|
||||
float innerH = bottom - top;
|
||||
float contentH = lines.Count * lh;
|
||||
|
||||
// Drive the shared scroll model with the current geometry.
|
||||
Scroll.LineHeight = (int)MathF.Round(lh);
|
||||
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
|
||||
Scroll.ViewHeight = (int)MathF.Floor(innerH);
|
||||
if (_pinBottom) Scroll.ScrollToEnd();
|
||||
|
||||
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
|
||||
// Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
|
||||
// Invert: baseY = bottom - contentH + (MaxScroll - ScrollY).
|
||||
// With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓
|
||||
// Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓
|
||||
float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY);
|
||||
_lastBaseY = baseY;
|
||||
|
||||
// Normalised selection span (start <= end), if any.
|
||||
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd);
|
||||
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
float y = baseY + i * lh;
|
||||
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
||||
|
||||
string text = lines[i].Text;
|
||||
|
||||
// Selection highlight behind this line's selected character span.
|
||||
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
|
||||
{
|
||||
int c0 = i == selStart.Line ? selStart.Col : 0;
|
||||
int c1 = i == selEnd.Line ? selEnd.Col : text.Length;
|
||||
c0 = Math.Clamp(c0, 0, text.Length);
|
||||
c1 = Math.Clamp(c1, 0, text.Length);
|
||||
if (c1 > c0)
|
||||
{
|
||||
float hx, hw;
|
||||
if (datFont is not null)
|
||||
{
|
||||
hx = Padding + datFont.MeasureWidth(text.Substring(0, c0));
|
||||
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
else
|
||||
{
|
||||
hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0));
|
||||
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
// Highlight sits BEHIND the line's text → sprite bucket, submitted
|
||||
// before this line's DrawStringDat.
|
||||
ctx.DrawFill(hx, y, hw, lh, SelectionColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (datFont is not null)
|
||||
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
|
||||
else
|
||||
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.Scroll:
|
||||
{
|
||||
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
|
||||
// ScrollByLines sign: +down/newer, -up/older.
|
||||
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
|
||||
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
|
||||
_pinBottom = Scroll.AtEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
|
||||
var p = HitChar(e.Data1, e.Data2);
|
||||
_selAnchor = p;
|
||||
_selCaret = p;
|
||||
_selecting = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove:
|
||||
{
|
||||
if (_selecting)
|
||||
{
|
||||
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
|
||||
_selCaret = HitChar(e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
{
|
||||
_selecting = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
bool ctrl = Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|
||||
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
|
||||
if (ctrl && key == Silk.NET.Input.Key.C)
|
||||
{
|
||||
// Only touch the clipboard when there's a selection — an empty
|
||||
// copy must NOT clobber what the user previously copied.
|
||||
if (Keyboard is not null)
|
||||
{
|
||||
string sel = SelectedText();
|
||||
if (sel.Length > 0) Keyboard.ClipboardText = sel;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ctrl && key == Silk.NET.Input.Key.A)
|
||||
{
|
||||
SelectAll();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Selection helpers ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Select the entire cached transcript (Ctrl+A).</summary>
|
||||
private void SelectAll()
|
||||
{
|
||||
var lines = _lastLines;
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
_selAnchor = _selCaret = null;
|
||||
return;
|
||||
}
|
||||
int last = lines.Count - 1;
|
||||
_selAnchor = new Pos(0, 0);
|
||||
_selCaret = new Pos(last, lines[last].Text.Length);
|
||||
}
|
||||
|
||||
/// <summary>Normalise (anchor, caret) into ordered (start, end). False if no
|
||||
/// selection or it is empty (anchor == caret).</summary>
|
||||
private bool TryGetOrderedSelection(out Pos start, out Pos end)
|
||||
{
|
||||
start = default; end = default;
|
||||
if (_selAnchor is not { } a || _selCaret is not { } c) return false;
|
||||
(start, end) = Order(a, c);
|
||||
return !(start.Line == end.Line && start.Col == end.Col);
|
||||
}
|
||||
|
||||
/// <summary>The currently-selected text against the cached lines. Empty when
|
||||
/// nothing is selected.</summary>
|
||||
public string SelectedText()
|
||||
{
|
||||
if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty;
|
||||
return SelectedText(_lastLines, start, end);
|
||||
}
|
||||
|
||||
// ── Pure, testable logic (no GL / no font texture) ───────────────────
|
||||
|
||||
/// <summary>Order two caret positions so the first is <= the second (by line,
|
||||
/// then column).</summary>
|
||||
public static (Pos start, Pos end) Order(Pos a, Pos b)
|
||||
{
|
||||
if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b);
|
||||
return (b, a);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assemble the selected substring spanning <paramref name="start"/> ..
|
||||
/// <paramref name="end"/> (inclusive of start.Col, exclusive of end.Col) from
|
||||
/// <paramref name="lines"/>. Multi-line selections are joined with "\n":
|
||||
/// the first line from start.Col to its end, whole middle lines, and the last
|
||||
/// line up to end.Col. Pure — unit-testable without GL.
|
||||
/// </summary>
|
||||
public static string SelectedText(IReadOnlyList<Line> lines, Pos start, Pos end)
|
||||
{
|
||||
if (lines.Count == 0) return string.Empty;
|
||||
(start, end) = Order(start, end);
|
||||
|
||||
int sl = Math.Clamp(start.Line, 0, lines.Count - 1);
|
||||
int el = Math.Clamp(end.Line, 0, lines.Count - 1);
|
||||
|
||||
if (sl == el)
|
||||
{
|
||||
string t = lines[sl].Text;
|
||||
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||
if (c1 <= c0) return string.Empty;
|
||||
return t.Substring(c0, c1 - c0);
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// First line: from start.Col to its end.
|
||||
{
|
||||
string t = lines[sl].Text;
|
||||
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||
sb.Append(t.AsSpan(c0));
|
||||
}
|
||||
|
||||
// Whole middle lines.
|
||||
for (int i = sl + 1; i < el; i++)
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(lines[i].Text);
|
||||
}
|
||||
|
||||
// Last line: up to end.Col.
|
||||
{
|
||||
sb.Append('\n');
|
||||
string t = lines[el].Text;
|
||||
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||
sb.Append(t.AsSpan(0, c1));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a local-space point to a caret <see cref="Pos"/> against the cached
|
||||
/// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped
|
||||
/// to the line range; col via <see cref="CharIndexAt"/>.
|
||||
/// </summary>
|
||||
private Pos HitChar(float localX, float localY)
|
||||
{
|
||||
var lines = _lastLines;
|
||||
if (lines.Count == 0) return new Pos(0, 0);
|
||||
|
||||
float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight;
|
||||
int line = (int)MathF.Floor((localY - _lastBaseY) / lh);
|
||||
line = Math.Clamp(line, 0, lines.Count - 1);
|
||||
|
||||
string text = lines[line].Text;
|
||||
int col = _lastDatFont is { } df
|
||||
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
|
||||
localX - _lastPadding)
|
||||
: (_lastFont is { } bf
|
||||
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
|
||||
localX - _lastPadding)
|
||||
: 0);
|
||||
return new Pos(line, col);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caret column for a horizontal position <paramref name="x"/> (already
|
||||
/// adjusted for the left padding, so x=0 is the start of the text). Walks the
|
||||
/// string accumulating each glyph's advance and snaps the caret to whichever
|
||||
/// side of the glyph midpoint <paramref name="x"/> falls on — natural
|
||||
/// Windows-like caret placement. Pure — unit-testable with a synthetic advance.
|
||||
/// </summary>
|
||||
/// <param name="text">The line text.</param>
|
||||
/// <param name="advanceOf">Per-character advance (pixels) lookup.</param>
|
||||
/// <param name="x">Horizontal position relative to the text's left edge.</param>
|
||||
public static int CharIndexAt(string text, Func<char, float> advanceOf, float x)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || x <= 0f) return 0;
|
||||
|
||||
float cursor = 0f;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
float adv = advanceOf(text[i]);
|
||||
float mid = cursor + adv * 0.5f;
|
||||
if (x < mid) return i; // caret sits before this glyph
|
||||
cursor += adv;
|
||||
}
|
||||
return text.Length; // past the last glyph → end caret
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue