Four defects from the first in-world look, three of them with a definite root cause rather than a plausible one. **The Buff button did nothing.** Not a hit-testing problem -- the pointer found the button perfectly. UiRoot's press handling asks the pressed widget whether it owns the pointer; a widget that does not claim the press falls through to "move the ancestor window", and a window drag returns early on release without ever emitting a Click. UiButton and UiClickablePanel both override HandlesClick for exactly this reason; UiSimpleButton never did. Latent since that class was written, and invisible until it was put inside a draggable window -- which is precisely what a markup plugin panel is. Found by reproducing it headlessly through the real UiRoot dispatcher rather than by reasoning about it: MarkupPanelClickTests drives press-and-release over the button and asserts the bound action ran, with a separate test asserting the pointer finds the button at all, so a future failure says which half broke. My earlier guess -- that a modal at character select was swallowing the click -- was wrong, and the screenshot of the panel live in world disproved it. **"0 trained skills".** The skill-name table was read in OnLoad *before* GameWindowCompositionPipeline.Run, which is what publishes the DAT collection, so _dats was still null, the whole block was skipped, and the surface reported an empty skill list with nothing to explain it. Bound in PublishDatCollection instead -- the moment the data exists -- so it cannot run early again whatever the phase ordering does, and a genuinely missing SkillTable now says so. **Plugin text used the development bitmap font.** UiLabel and UiSimpleButton gained a DatFont, and MarkupDocument now takes the retail interface font from the host, so plugin panels render through the same glyph path (including retail's two-plane outline) as authored panels. **MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's ClientLocal log type (0x1A) -- the channel the client uses for its own notices, local to this client, so a plugin cannot speak in the player's name. MossTank announces the start, the finish with a cast count, and a stall. Not addressed here: the cursor showing blue rather than amber. Traced but not fixed -- CursorFeedbackController picks the cursor family from combat mode, and CombatMode.Magic selects the blue Magic cursor where Default is amber. That is a combat-mode question, unrelated to this change, and worth its own look rather than a speculative fix folded in here. Solution builds clean; 14,437 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
12 KiB
C#
282 lines
12 KiB
C#
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Rectangular container with an optional translucent background and
|
||
/// border. Used as the base of every retail panel (attributes, chat,
|
||
/// inventory, login, etc.).
|
||
///
|
||
/// Retail has panel background art stored as 9-slice sprite assets in
|
||
/// the <c>0x06xxxxxx</c> RenderSurface range, and composed via
|
||
/// <c>LayoutDesc</c> (<c>0x21xxxxxx</c>) trees. Until our
|
||
/// <c>AcFont</c>/<c>UiSpriteBatch</c> consumes those directly, we draw a
|
||
/// simple translucent rectangle so panels are visible during development.
|
||
/// </summary>
|
||
public class UiPanel : UiElement
|
||
{
|
||
/// <summary>Background fill color. Set <see cref="Vector4.Zero"/> to skip.</summary>
|
||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.55f);
|
||
|
||
/// <summary>Border color. Set <see cref="Vector4.Zero"/> to skip.</summary>
|
||
public Vector4 BorderColor { get; set; } = new(0.15f, 0.15f, 0.2f, 0.8f);
|
||
|
||
public float BorderThickness { get; set; } = 1f;
|
||
|
||
/// <summary>Optional dat RenderSurface id for the panel background sprite, drawn
|
||
/// in place of (or alongside) <see cref="BackgroundColor"/>. 0 = none.
|
||
/// When set, the sprite is stretched to fill the panel rect.
|
||
/// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6).</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; }
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
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);
|
||
}
|
||
else if (BackgroundColor.W > 0f)
|
||
{
|
||
// Panel fills are backgrounds. Draw them through the sprite/fill
|
||
// bucket so children that render as sprites/dat-font glyphs stay on
|
||
// top in painter order; DrawRect flushes after sprites and would
|
||
// cover text/icons.
|
||
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||
}
|
||
|
||
if (BorderColor.W > 0f && BorderThickness > 0f)
|
||
ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Static text label. Draws a single line of text using the context's
|
||
/// default font (or an override). Does not consume input.
|
||
///
|
||
/// Equivalent retail primitive: wide-string appended to a CString via
|
||
/// <c>FUN_0040b8f0</c> then drawn by the widget's draw method through
|
||
/// <c>FUN_00698330</c>.
|
||
/// </summary>
|
||
public class UiLabel : UiElement
|
||
{
|
||
public string Text { get; set; } = string.Empty;
|
||
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||
|
||
/// <summary>
|
||
/// Optional live text reader, evaluated each draw and preferred over
|
||
/// <see cref="Text"/> when set. Markup <c>{Binding}</c> labels use this so
|
||
/// the displayed value tracks the binding object instead of freezing at
|
||
/// whatever it held when the panel was built — which is what a plugin
|
||
/// status line needs.
|
||
/// </summary>
|
||
public Func<string?>? TextSource { get; set; }
|
||
|
||
/// <summary>
|
||
/// Retail dat font. When set the label renders through the same glyph path
|
||
/// every authored panel uses, so plugin text matches the rest of the
|
||
/// interface instead of falling back to the development bitmap font.
|
||
/// </summary>
|
||
public UiDatFont? DatFont { get; set; }
|
||
|
||
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
||
public bool Outline { get; set; } = true;
|
||
|
||
public UiLabel() { ClickThrough = true; }
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
string text = TextSource?.Invoke() ?? Text;
|
||
if (DatFont is { } dat)
|
||
ctx.DrawStringDat(dat, text, 0, 0, TextColor, Outline);
|
||
else
|
||
ctx.DrawString(text, 0, 0, TextColor);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Simple clickable button: panel background + centered label + click
|
||
/// 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 UiSimpleButton : UiPanel
|
||
{
|
||
public string Text { get; set; } = string.Empty;
|
||
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||
|
||
/// <summary>
|
||
/// Optional live caption reader, preferred over <see cref="Text"/> when
|
||
/// set, so a markup-bound button can change its own label (Buff / Stop)
|
||
/// without the binding object touching UI objects.
|
||
/// </summary>
|
||
public Func<string?>? TextSource { get; set; }
|
||
|
||
/// <summary>Retail dat font for the caption; see <see cref="UiLabel.DatFont"/>.</summary>
|
||
public UiDatFont? DatFont { get; set; }
|
||
|
||
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
||
public bool Outline { get; set; } = true;
|
||
|
||
public event System.Action? Click;
|
||
|
||
/// <summary>
|
||
/// Without this the button is unclickable inside any draggable window.
|
||
/// UiRoot's press handling asks the pressed widget whether it owns the
|
||
/// pointer; a widget that does not claim the press falls through to
|
||
/// "move the ancestor window", which swallows the release and never emits
|
||
/// a Click. <see cref="UiButton"/> and <see cref="UiClickablePanel"/>
|
||
/// already declare it — this one did not, which is why a markup plugin
|
||
/// panel's button did nothing while its hit-test was perfectly fine.
|
||
/// </summary>
|
||
public override bool HandlesClick => true;
|
||
|
||
public UiSimpleButton()
|
||
{
|
||
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
|
||
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);
|
||
}
|
||
|
||
public override bool OnEvent(in UiEvent e)
|
||
{
|
||
if (e.Type == UiEventType.Click && Enabled)
|
||
{
|
||
Click?.Invoke();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
base.OnDraw(ctx);
|
||
string caption = TextSource?.Invoke() ?? Text;
|
||
if (caption.Length == 0) return;
|
||
|
||
if (DatFont is { } dat)
|
||
{
|
||
float datW = dat.MeasureWidth(caption);
|
||
ctx.DrawStringDat(
|
||
dat, caption,
|
||
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
|
||
TextColor, Outline);
|
||
return;
|
||
}
|
||
|
||
if (ctx.DefaultFont is null) return;
|
||
float textW = ctx.DefaultFont.MeasureWidth(caption);
|
||
float tx = (Width - textW) * 0.5f;
|
||
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
|
||
ctx.DrawString(caption, tx, ty, TextColor);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A <see cref="UiPanel"/> that fires an <see cref="OnClick"/> callback when the user
|
||
/// left-clicks it. Used for the attribute-list rows in the Character window — each row
|
||
/// is a transparent container that needs to respond to pointer hits while its children
|
||
/// (icon, name, value) are ClickThrough decorations.
|
||
///
|
||
/// <para>Retail analog: the <c>AttributeInfoRegion</c> row widget in <c>gmAttributeUI</c>
|
||
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
|
||
/// parent window. In acdream we wire the equivalent via this action callback instead of
|
||
/// the retail message bus.</para>
|
||
///
|
||
/// <para>When <see cref="UseSelectionBars"/> is true and <see cref="UiPanel.BackgroundSprite"/>
|
||
/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of
|
||
/// the row (not stretched to fill). This matches retail's selection highlight which shows
|
||
/// a horizontal dark bar on both the top and bottom edge of the selected attribute row,
|
||
/// with NO left/right end-caps. Bar height is <see cref="SelectionBarHeight"/> pixels
|
||
/// (default 3px).</para>
|
||
/// </summary>
|
||
public class UiClickablePanel : UiPanel
|
||
{
|
||
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
|
||
public Action? OnClick { get; set; }
|
||
|
||
/// <summary>When true and <see cref="UiPanel.BackgroundSprite"/> is non-zero, draws
|
||
/// the sprite as a thin horizontal bar at the top AND bottom edges of the panel,
|
||
/// NOT as a full-height stretched fill. Matches retail's selected-row highlight
|
||
/// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill).
|
||
/// Default false (preserves legacy full-stretch behavior).</summary>
|
||
public bool UseSelectionBars { get; set; }
|
||
|
||
/// <summary>Height in pixels of each selection bar (top and bottom). Default 3px.
|
||
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
|
||
public float SelectionBarHeight { get; set; } = 3f;
|
||
|
||
/// <summary>Settable tooltip, surfaced through the shared
|
||
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
|
||
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). TS-85's
|
||
/// character-panel gap: retail's <c>AttributeInfoRegion</c> /
|
||
/// <c>Attribute2ndInfoRegion</c> / <c>SkillInfoRegion</c> row constructors
|
||
/// (<c>UIElement::SetTooltip</c> at 0x004f1617 / 0x004f1777 / 0x004f222f) stamp
|
||
/// this once per row at construction — retail never updates it afterward, so a
|
||
/// plain settable string (not a live provider) matches.</summary>
|
||
public string? TooltipText { get; set; }
|
||
|
||
/// <inheritdoc />
|
||
public override string? GetTooltipText() =>
|
||
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
||
|
||
public UiClickablePanel()
|
||
{
|
||
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
|
||
// which is the UiElement base default). Explicit for clarity.
|
||
ClickThrough = false;
|
||
}
|
||
|
||
/// <summary>HandlesClick = true ensures this row receives its own Click even when
|
||
/// it is nested inside a Draggable ancestor window frame (e.g. the future character
|
||
/// window with a whole-window drag handle). Without this, the press would be consumed
|
||
/// by the ancestor's drag logic and the Click would never fire.</summary>
|
||
public override bool HandlesClick => true;
|
||
|
||
public override bool OnEvent(in UiEvent e)
|
||
{
|
||
if (e.Type == UiEventType.Click && Enabled)
|
||
{
|
||
OnClick?.Invoke();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||
{
|
||
// Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row.
|
||
// The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at
|
||
// native height (SelectionBarHeight), stretched to full panel width (UV tile
|
||
// horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat).
|
||
var (tex, tw, th) = sr(BackgroundSprite);
|
||
if (tex != 0 && tw > 0 && th > 0)
|
||
{
|
||
float barH = SelectionBarHeight;
|
||
float uTile = tw > 0 ? Width / tw : 1f;
|
||
// Top bar: shows the top barH px of the sprite (v = 0 → barH/th).
|
||
float vBot = th > 0 ? barH / th : 1f;
|
||
ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One);
|
||
// Bottom bar: shows the bottom barH px of the sprite (v = 1−barH/th → 1).
|
||
float vTop2 = th > 0 ? 1f - barH / th : 0f;
|
||
ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One);
|
||
}
|
||
// Selection-bar mode draws no border (rows have BorderColor=Zero by design).
|
||
}
|
||
else
|
||
{
|
||
// Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border.
|
||
base.OnDraw(ctx);
|
||
}
|
||
}
|
||
}
|