@
feat(D.2b): chat polish — typing fix, opacity, scrollbar 3-slice, retail channel menu Visual-iteration batch (decomp-grounded), each fix verified against the retail screenshots: - typing: UiElement.HitTest aborted on ClickThrough BEFORE walking children, so the ClickThrough UiDatElement panels blocked hit-testing to the input/transcript inside them. Check ClickThrough AFTER the child walk (it only gates whether THIS element claims the hit). Restores input focus + typing. - opacity: UiElement.Opacity + a UiRenderContext alpha stack applied to sprite/rect draws (text bypasses it, stays sharp); chat frame Opacity=0.75 → translucent chat. - brown sliver: grow the transcript panel up 9px to cover the dropped resize-bar strip. - scrollbar: real 3-slice thumb (caps 0x06004C60/66 + tiled mid) + tiled track. - max/min: shifted one button-width left of the scrollbar (dat right-anchors collide). - system text now green (retail ChatMessageType 5; was yellow). - word-wrap: transcript lines wrap to the panel width (greedy, ports GlyphList::Recalculate). - channel menu reworked to retail gmMainChatUI::InitTalkFocusMenu: "Chat" button + a TWO-COLUMN popup of the 14 talk-focus items (Squelch, Tell to Selected, Chat to All, Tell to Fellows, ...) on a tan panel; channel items set the active outbound channel. Build + 392 App tests green. Visual confirmation in progress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
parent
0ec36f6197
commit
1da697ec2a
7 changed files with 329 additions and 126 deletions
|
|
@ -1857,19 +1857,36 @@ public sealed class GameWindow : IDisposable
|
||||||
// Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers.
|
// Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers.
|
||||||
// _uiHost.Keyboard is set by WireKeyboard above — it is non-null here.
|
// _uiHost.Keyboard is set by WireKeyboard above — it is non-null here.
|
||||||
chatController.Transcript.Keyboard = _uiHost.Keyboard;
|
chatController.Transcript.Keyboard = _uiHost.Keyboard;
|
||||||
// Top-level retail window: user-positioned at the bottom-left, movable + resizable.
|
// Wrap the dat content in the universal 8-piece beveled window chrome —
|
||||||
// KEEP the dat-authored size (do NOT override Width/Height) so the child anchors
|
// the SAME UiNineSlicePanel the vitals window uses. The chat's own dat
|
||||||
// capture their dat margins on the first layout — the same reason the vitals root
|
// layout only carries flat background sprites, so without this the window
|
||||||
// keeps its dat size. The user resizes/moves from there.
|
// 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;
|
var chatRoot = chatController.Root;
|
||||||
chatRoot.Left = 10;
|
float contentW = 490f, contentH = chatRoot.Height; // dat-authored height
|
||||||
chatRoot.Top = 460; // bottom-left default; pending the user's visual review
|
var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||||
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
|
{
|
||||||
chatRoot.Draggable = true;
|
Left = 10, Top = 440,
|
||||||
chatRoot.Resizable = true;
|
Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder,
|
||||||
chatRoot.MinWidth = 200f;
|
MinWidth = 200f, MinHeight = 90f,
|
||||||
chatRoot.MinHeight = 80f;
|
// Retail chat is translucent — fade the window's backgrounds/chrome
|
||||||
_uiHost.Root.AddChild(chatRoot);
|
// (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);
|
||||||
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
|
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: required role elements missing in 0x21000006.");
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ public sealed class ChatWindowController
|
||||||
|
|
||||||
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
||||||
private const uint RootId = 0x1000000Eu;
|
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 TranscriptPanelId = 0x10000010u;
|
||||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||||
private const uint TrackId = 0x10000012u;
|
private const uint TrackId = 0x10000012u;
|
||||||
|
|
@ -41,10 +42,12 @@ public sealed class ChatWindowController
|
||||||
private const uint MaxMinId = 0x1000046Fu;
|
private const uint MaxMinId = 0x1000046Fu;
|
||||||
|
|
||||||
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
|
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
|
||||||
private const uint TrackSprite = 0x06004C5Fu;
|
private const uint TrackSprite = 0x06004C5Fu;
|
||||||
private const uint ThumbSprite = 0x06004C63u;
|
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
|
||||||
private const uint UpSprite = 0x06004C69u;
|
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
|
||||||
private const uint DownSprite = 0x06004C6Cu;
|
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
|
||||||
|
private const uint UpSprite = 0x06004C69u;
|
||||||
|
private const uint DownSprite = 0x06004C6Cu;
|
||||||
|
|
||||||
// Channel menu sprite ids (confirmed in chat element dump).
|
// Channel menu sprite ids (confirmed in chat element dump).
|
||||||
private const uint MenuNormal = 0x06004D65u;
|
private const uint MenuNormal = 0x06004D65u;
|
||||||
|
|
@ -130,7 +133,29 @@ public sealed class ChatWindowController
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var c = new ChatWindowController { Root = layout.Root };
|
// 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 ───────────────────────────────────────────────────
|
// ── Transcript ───────────────────────────────────────────────────
|
||||||
// Place the behavioral transcript widget inside the transcript panel at the
|
// Place the behavioral transcript widget inside the transcript panel at the
|
||||||
|
|
@ -144,7 +169,7 @@ public sealed class ChatWindowController
|
||||||
Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom),
|
Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom),
|
||||||
DatFont = datFont,
|
DatFont = datFont,
|
||||||
Font = debugFont,
|
Font = debugFont,
|
||||||
LinesProvider = () => BuildLines(vm),
|
LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont),
|
||||||
};
|
};
|
||||||
transcriptPanel.AddChild(c.Transcript);
|
transcriptPanel.AddChild(c.Transcript);
|
||||||
|
|
||||||
|
|
@ -178,10 +203,12 @@ public sealed class ChatWindowController
|
||||||
Anchors = track.Anchors,
|
Anchors = track.Anchors,
|
||||||
Model = c.Transcript.Scroll,
|
Model = c.Transcript.Scroll,
|
||||||
SpriteResolve = resolve,
|
SpriteResolve = resolve,
|
||||||
TrackSprite = TrackSprite,
|
TrackSprite = TrackSprite,
|
||||||
ThumbSprite = ThumbSprite,
|
ThumbSprite = ThumbSprite,
|
||||||
UpSprite = UpSprite,
|
ThumbTopSprite = ThumbTopSprite,
|
||||||
DownSprite = DownSprite,
|
ThumbBotSprite = ThumbBotSprite,
|
||||||
|
UpSprite = UpSprite,
|
||||||
|
DownSprite = DownSprite,
|
||||||
};
|
};
|
||||||
trackParent.RemoveChild(track);
|
trackParent.RemoveChild(track);
|
||||||
trackParent.AddChild(c.Scrollbar);
|
trackParent.AddChild(c.Scrollbar);
|
||||||
|
|
@ -220,6 +247,11 @@ public sealed class ChatWindowController
|
||||||
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
|
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
|
||||||
if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)
|
if (layout.FindElement(MaxMinId) is UiDatElement 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.ClickThrough = false;
|
maxMinEl.ClickThrough = false;
|
||||||
maxMinEl.OnClick = c.ToggleMaximize;
|
maxMinEl.OnClick = c.ToggleMaximize;
|
||||||
}
|
}
|
||||||
|
|
@ -276,17 +308,66 @@ public sealed class ChatWindowController
|
||||||
/// <see cref="UiChatView.Line"/> record format, applying retail-faithful
|
/// <see cref="UiChatView.Line"/> record format, applying retail-faithful
|
||||||
/// per-<see cref="ChatKind"/> colors.
|
/// per-<see cref="ChatKind"/> colors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<UiChatView.Line> BuildLines(ChatVM vm)
|
private static IReadOnlyList<UiChatView.Line> BuildLines(
|
||||||
|
ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont)
|
||||||
{
|
{
|
||||||
var detailed = vm.RecentLinesDetailed();
|
var detailed = vm.RecentLinesDetailed();
|
||||||
if (detailed.Count == 0) return Array.Empty<UiChatView.Line>();
|
if (detailed.Count == 0) return Array.Empty<UiChatView.Line>();
|
||||||
|
|
||||||
var result = new UiChatView.Line[detailed.Count];
|
// Word-wrap each message to the transcript's current pixel width (ports retail
|
||||||
for (int i = 0; i < detailed.Count; i++)
|
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
||||||
result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind));
|
// 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<UiChatView.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 UiChatView.Line(frag, color));
|
||||||
|
}
|
||||||
return result;
|
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 single word longer than the width overflows its own line (retail does not
|
||||||
|
/// hyphenate chat). Mirrors retail GlyphList::Recalculate's per-GlyphLine emission.
|
||||||
|
/// </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;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var line = new System.Text.StringBuilder();
|
||||||
|
foreach (var word in text.Split(' '))
|
||||||
|
{
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
line.Append(word);
|
||||||
|
}
|
||||||
|
else if (measure(line.ToString() + " " + word) > maxW)
|
||||||
|
{
|
||||||
|
yield return line.ToString();
|
||||||
|
line.Clear();
|
||||||
|
line.Append(word);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
line.Append(' ').Append(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.Length > 0) yield return line.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-<see cref="ChatKind"/> text color matching retail AC's channel coloring
|
/// Per-<see cref="ChatKind"/> text color matching retail AC's channel coloring
|
||||||
/// (observed from retail client screenshots and holtburger's chat.rs coloring).
|
/// (observed from retail client screenshots and holtburger's chat.rs coloring).
|
||||||
|
|
@ -297,7 +378,7 @@ public sealed class ChatWindowController
|
||||||
ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout
|
ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout
|
||||||
ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text
|
ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text
|
||||||
ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper
|
ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper
|
||||||
ChatKind.System => new(1f, 1f, 0.45f, 1f), // yellow — system messages
|
ChatKind.System => new(0f, 1f, 0f, 1f), // green — system messages (retail ChatMessageType 5; AC2D eGreen {0,255,0})
|
||||||
ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast
|
ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast
|
||||||
ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote
|
ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote
|
||||||
ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote
|
ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote
|
||||||
|
|
|
||||||
|
|
@ -5,32 +5,44 @@ using AcDream.UI.Abstractions;
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Chat channel selector (the "Chat ▸" button). Port of retail
|
/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail
|
||||||
/// <c>UIElement_Menu</c> as used by <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c>:
|
/// <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c>: the button is labelled "Chat";
|
||||||
/// a button whose label is the active channel; clicking opens a popup of channels;
|
/// clicking opens a TWO-COLUMN popup of 14 talk-focus items (Squelch, Tell to Selected,
|
||||||
/// selecting one calls <c>SetTalkFocus</c> (here: <see cref="OnChannelChanged"/>).
|
/// Chat to All, Tell to Fellows, …). Selecting a channel item sets the active outbound
|
||||||
|
/// channel (retail <c>SetTalkFocus</c>; here <see cref="OnChannelChanged"/>). The items
|
||||||
|
/// are code-populated exactly as retail populates them, not a dat-layout port.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UiChannelMenu : UiElement
|
public sealed class UiChannelMenu : UiElement
|
||||||
{
|
{
|
||||||
public readonly record struct Item(string Label, ChatChannelKind Channel);
|
/// <summary>One menu row: its label + the channel it selects (null = special/no-op
|
||||||
|
/// item such as Squelch or Tell-to-Selected, deferred).</summary>
|
||||||
|
public readonly record struct Item(string Label, ChatChannelKind? Channel);
|
||||||
|
|
||||||
/// <summary>Retail talk-focus channels (subset acdream's ChatInputParser routes).</summary>
|
/// <summary>The 14 retail talk-focus items in retail order — left column rows 0–6,
|
||||||
public static readonly Item[] Channels =
|
/// right column rows 7–13 (matching the live retail menu).</summary>
|
||||||
|
public static readonly Item[] Items =
|
||||||
{
|
{
|
||||||
new("Say", ChatChannelKind.Say),
|
new("Squelch (ignore)", null), // 0 special (squelch — deferred)
|
||||||
new("General", ChatChannelKind.General),
|
new("Tell to Selected", null), // 1 special (selected target — deferred)
|
||||||
new("Trade", ChatChannelKind.Trade),
|
new("Chat to All", ChatChannelKind.Say), // 2
|
||||||
new("LFG", ChatChannelKind.Lfg),
|
new("Tell to Fellows", ChatChannelKind.Fellowship), // 3
|
||||||
new("Fellowship", ChatChannelKind.Fellowship),
|
new("Tell to General Chat", ChatChannelKind.General), // 4
|
||||||
new("Allegiance", ChatChannelKind.Allegiance),
|
new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5
|
||||||
new("Patron", ChatChannelKind.Patron),
|
new("Tell to Society Chat", ChatChannelKind.Society), // 6
|
||||||
new("Vassals", ChatChannelKind.Vassals),
|
new("Tell to Monarch", ChatChannelKind.Monarch), // 7
|
||||||
new("Monarch", ChatChannelKind.Monarch),
|
new("Tell to Patron", ChatChannelKind.Patron), // 8
|
||||||
new("Roleplay", ChatChannelKind.Roleplay),
|
new("Tell to Vassals", ChatChannelKind.Vassals), // 9
|
||||||
new("Society", ChatChannelKind.Society),
|
new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10
|
||||||
new("Olthoi", ChatChannelKind.Olthoi),
|
new("Tell to Trade Chat", ChatChannelKind.Trade), // 11
|
||||||
|
new("Tell to Roleplay Chat", ChatChannelKind.Roleplay), // 12
|
||||||
|
new("Tell to Olthoi Chat", ChatChannelKind.Olthoi), // 13
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private const int Rows = 7; // items per column
|
||||||
|
private const float ItemH = 16f; // row height
|
||||||
|
private const float ColW = 150f; // column width (fits "Tell to Roleplay Chat")
|
||||||
|
|
||||||
|
/// <summary>The channel the player's typed text currently goes to.</summary>
|
||||||
public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say;
|
public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say;
|
||||||
public Action<ChatChannelKind>? OnChannelChanged { get; set; }
|
public Action<ChatChannelKind>? OnChannelChanged { get; set; }
|
||||||
|
|
||||||
|
|
@ -39,41 +51,40 @@ public sealed class UiChannelMenu : UiElement
|
||||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||||
public uint NormalSprite { get; set; }
|
public uint NormalSprite { get; set; }
|
||||||
public uint PressedSprite { get; set; }
|
public uint PressedSprite { get; set; }
|
||||||
public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f);
|
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
|
||||||
|
/// <summary>Popup panel fill — the retail talk-focus menu is a warm tan/orange.</summary>
|
||||||
|
public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 0.97f);
|
||||||
|
|
||||||
private bool _open;
|
private bool _open;
|
||||||
private const float ItemH = 16f;
|
private static float PopupW => 2 * ColW;
|
||||||
private const float PopupW = 90f;
|
private static float PopupH => Rows * ItemH;
|
||||||
|
|
||||||
public UiChannelMenu() { CapturesPointerDrag = true; }
|
public UiChannelMenu() { CapturesPointerDrag = true; }
|
||||||
|
|
||||||
private string Label => FindLabel(Selected);
|
|
||||||
private static string FindLabel(ChatChannelKind k)
|
|
||||||
{
|
|
||||||
foreach (var it in Channels) if (it.Channel == k) return it.Label;
|
|
||||||
return "Chat";
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDraw(UiRenderContext ctx)
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
|
// Button face + the "Chat" label (retail labels the talk-focus button "Chat").
|
||||||
if (SpriteResolve is { } resolve)
|
if (SpriteResolve is { } resolve)
|
||||||
{
|
{
|
||||||
var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite);
|
var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||||||
if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||||
}
|
}
|
||||||
DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f);
|
DrawLabel(ctx, "Chat", 4f, (Height - LineH()) * 0.5f);
|
||||||
|
|
||||||
if (_open)
|
if (!_open) return;
|
||||||
|
|
||||||
|
// Two-column popup opening UPWARD from the button (chat sits at screen bottom).
|
||||||
|
float top = -PopupH;
|
||||||
|
ctx.DrawRect(0, top, PopupW, PopupH, PopupColor);
|
||||||
|
for (int i = 0; i < Items.Length; i++)
|
||||||
{
|
{
|
||||||
float h = Channels.Length * ItemH;
|
int col = i / Rows, row = i % Rows;
|
||||||
float top = -h; // popup opens UPWARD (chat sits at screen bottom)
|
DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH);
|
||||||
ctx.DrawRect(0, top, MathF.Max(Width, PopupW), h, new(0f, 0f, 0f, 0.85f));
|
|
||||||
for (int i = 0; i < Channels.Length; i++)
|
|
||||||
DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||||
|
|
||||||
private void DrawLabel(UiRenderContext ctx, string s, float x, float y)
|
private void DrawLabel(UiRenderContext ctx, string s, float x, float y)
|
||||||
{
|
{
|
||||||
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor);
|
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor);
|
||||||
|
|
@ -81,29 +92,30 @@ public sealed class UiChannelMenu : UiElement
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override bool OnHitTest(float lx, float ly)
|
protected override bool OnHitTest(float lx, float ly)
|
||||||
=> _open ? (lx >= 0 && lx < MathF.Max(Width, PopupW)
|
=> _open ? (lx >= 0 && lx < PopupW && ly >= -PopupH && ly < Height)
|
||||||
&& ly >= -Channels.Length * ItemH && ly < Height)
|
|
||||||
: base.OnHitTest(lx, ly);
|
: base.OnHitTest(lx, ly);
|
||||||
|
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
{
|
{
|
||||||
if (e.Type == UiEventType.MouseDown)
|
if (e.Type != UiEventType.MouseDown) return false;
|
||||||
|
|
||||||
|
float lx = e.Data1, ly = e.Data2;
|
||||||
|
if (_open && ly < 0) // clicked an item in the upward popup
|
||||||
{
|
{
|
||||||
float ly = e.Data2;
|
int col = lx < ColW ? 0 : 1;
|
||||||
if (_open && ly < 0)
|
int row = (int)((ly + PopupH) / ItemH);
|
||||||
|
int idx = col * Rows + row;
|
||||||
|
if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length
|
||||||
|
&& Items[idx].Channel is { } ch)
|
||||||
{
|
{
|
||||||
int idx = (int)((ly + Channels.Length * ItemH) / ItemH);
|
Selected = ch;
|
||||||
if (idx >= 0 && idx < Channels.Length)
|
OnChannelChanged?.Invoke(ch);
|
||||||
{
|
|
||||||
Selected = Channels[idx].Channel;
|
|
||||||
OnChannelChanged?.Invoke(Selected);
|
|
||||||
}
|
|
||||||
_open = false;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
_open = !_open;
|
_open = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
_open = !_open; // toggle on button click
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,15 @@ public sealed class UiChatScrollbar : UiElement
|
||||||
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
|
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
|
||||||
public uint TrackSprite { get; set; }
|
public uint TrackSprite { get; set; }
|
||||||
|
|
||||||
/// <summary>Thumb sprite id (3-slice middle tile: 0x06004C63; the widget draws
|
/// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
|
||||||
/// a single stretched sprite for simplicity — Task H can upgrade to 3-slice).</summary>
|
|
||||||
public uint ThumbSprite { get; set; }
|
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>
|
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
|
||||||
public uint UpSprite { get; set; }
|
public uint UpSprite { get; set; }
|
||||||
|
|
||||||
|
|
@ -46,6 +51,9 @@ public sealed class UiChatScrollbar : UiElement
|
||||||
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
|
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
|
||||||
private const float MinThumb = 8f;
|
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
|
/// <summary>Up/down button height in pixels. Matches element height 16px from
|
||||||
/// the up/down button children in base layout 0x2100003E.</summary>
|
/// the up/down button children in base layout 0x2100003E.</summary>
|
||||||
private const float ButtonH = 16f;
|
private const float ButtonH = 16f;
|
||||||
|
|
@ -77,34 +85,59 @@ public sealed class UiChatScrollbar : UiElement
|
||||||
{
|
{
|
||||||
if (Model is not { } m || SpriteResolve is not { } resolve) return;
|
if (Model is not { } m || SpriteResolve is not { } resolve) return;
|
||||||
|
|
||||||
// Track background, full element bounds.
|
// Track background — TILED vertically (retail DrawMode=Normal). The native track
|
||||||
DrawSprite(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
// 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.
|
// Up button — top ButtonH rows (directional arrow art, drawn 1:1).
|
||||||
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
|
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
|
||||||
|
|
||||||
// Down button — bottom ButtonH rows.
|
// Down button — bottom ButtonH rows.
|
||||||
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
|
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
|
||||||
|
|
||||||
// Thumb — only when content overflows the view.
|
// 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)
|
if (m.HasOverflow)
|
||||||
{
|
{
|
||||||
float trackTop = ButtonH;
|
float trackTop = ButtonH;
|
||||||
float trackLen = Height - 2f * ButtonH;
|
float trackLen = Height - 2f * ButtonH;
|
||||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||||
DrawSprite(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
|
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,
|
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
uint id, float x, float y, float w, float h)
|
uint id, float x, float y, float w, float h)
|
||||||
{
|
{
|
||||||
if (id == 0) return;
|
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||||
var (tex, _, _) = resolve(id);
|
var (tex, _, _) = resolve(id);
|
||||||
if (tex == 0) return;
|
if (tex == 0) return;
|
||||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, 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)
|
public override bool OnEvent(in UiEvent e)
|
||||||
{
|
{
|
||||||
if (Model is not { } m) return false;
|
if (Model is not { } m) return false;
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,11 @@ public abstract class UiElement
|
||||||
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
||||||
public int ZOrder { get; set; }
|
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
|
/// <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,
|
/// it) repositions it as a movable window. Intended for top-level panels,
|
||||||
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
|
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
|
||||||
|
|
@ -179,8 +184,10 @@ public abstract class UiElement
|
||||||
{
|
{
|
||||||
if (!Visible) return;
|
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.PushTransform(Left, Top);
|
||||||
|
ctx.PushAlpha(Opacity);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
OnDraw(ctx);
|
OnDraw(ctx);
|
||||||
|
|
@ -201,6 +208,7 @@ public abstract class UiElement
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
ctx.PopAlpha();
|
||||||
ctx.PopTransform();
|
ctx.PopTransform();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,9 +228,14 @@ public abstract class UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal UiElement? HitTest(float localX, float localY)
|
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)
|
if (_children.Count > 0)
|
||||||
{
|
{
|
||||||
var ordered = _children.ToArray();
|
var ordered = _children.ToArray();
|
||||||
|
|
@ -235,6 +248,7 @@ public abstract class UiElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ClickThrough) return null;
|
||||||
return OnHitTest(localX, localY) ? this : null;
|
return OnHitTest(localX, localY) ? this : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,25 @@ public sealed class UiRenderContext
|
||||||
private readonly System.Collections.Generic.List<Vector2> _stack = new();
|
private readonly System.Collections.Generic.List<Vector2> _stack = new();
|
||||||
private Vector2 _current;
|
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; }
|
||||||
|
|
||||||
|
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)
|
public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
|
||||||
{
|
{
|
||||||
TextRenderer = tr;
|
TextRenderer = tr;
|
||||||
|
|
@ -48,15 +67,18 @@ public sealed class UiRenderContext
|
||||||
// ── Pass-through draw helpers (add current translate) ──────────────
|
// ── Pass-through draw helpers (add current translate) ──────────────
|
||||||
|
|
||||||
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
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));
|
||||||
|
|
||||||
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
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,
|
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
||||||
float u0, float v0, float u1, float v1, Vector4 tint)
|
float u0, float v0, float u1, float v1, Vector4 tint)
|
||||||
=> TextRenderer.DrawSprite(texture,
|
=> TextRenderer.DrawSprite(texture,
|
||||||
_current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint);
|
_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)
|
public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Linq;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
|
@ -6,42 +7,40 @@ namespace AcDream.App.Tests.UI;
|
||||||
public class UiChannelMenuTests
|
public class UiChannelMenuTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Channels_HasExpected12Entries()
|
public void Items_HasExpected14Entries()
|
||||||
{
|
{
|
||||||
Assert.Equal(12, UiChannelMenu.Channels.Length);
|
// Retail gmMainChatUI::InitTalkFocusMenu: squelch + tell-selected + 12 channels.
|
||||||
|
Assert.Equal(14, UiChannelMenu.Items.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Channels_FirstEntry_IsSay()
|
public void Items_FirstEntry_IsSquelch_Special()
|
||||||
{
|
{
|
||||||
Assert.Equal(ChatChannelKind.Say, UiChannelMenu.Channels[0].Channel);
|
Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label);
|
||||||
Assert.Equal("Say", UiChannelMenu.Channels[0].Label);
|
Assert.Null(UiChannelMenu.Items[0].Channel); // special item, no channel
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Channels_LastEntry_IsOlthoi()
|
public void Items_LastEntry_IsOlthoi()
|
||||||
{
|
{
|
||||||
var last = UiChannelMenu.Channels[^1];
|
var last = UiChannelMenu.Items[^1];
|
||||||
|
Assert.Equal("Tell to Olthoi Chat", last.Label);
|
||||||
Assert.Equal(ChatChannelKind.Olthoi, last.Channel);
|
Assert.Equal(ChatChannelKind.Olthoi, last.Channel);
|
||||||
Assert.Equal("Olthoi", last.Label);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Channels_ContainsAllExpectedKinds()
|
public void Items_ContainAll12ChannelKinds()
|
||||||
{
|
{
|
||||||
var kinds = new HashSet<ChatChannelKind>(UiChannelMenu.Channels.Select(c => c.Channel));
|
var kinds = new HashSet<ChatChannelKind>(
|
||||||
Assert.Contains(ChatChannelKind.Say, kinds);
|
UiChannelMenu.Items.Where(i => i.Channel is not null).Select(i => i.Channel!.Value));
|
||||||
Assert.Contains(ChatChannelKind.General, kinds);
|
foreach (var k in new[]
|
||||||
Assert.Contains(ChatChannelKind.Trade, kinds);
|
{
|
||||||
Assert.Contains(ChatChannelKind.Lfg, kinds);
|
ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg,
|
||||||
Assert.Contains(ChatChannelKind.Fellowship, kinds);
|
ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron,
|
||||||
Assert.Contains(ChatChannelKind.Allegiance, kinds);
|
ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay,
|
||||||
Assert.Contains(ChatChannelKind.Patron, kinds);
|
ChatChannelKind.Society, ChatChannelKind.Olthoi,
|
||||||
Assert.Contains(ChatChannelKind.Vassals, kinds);
|
})
|
||||||
Assert.Contains(ChatChannelKind.Monarch, kinds);
|
Assert.Contains(k, kinds);
|
||||||
Assert.Contains(ChatChannelKind.Roleplay, kinds);
|
|
||||||
Assert.Contains(ChatChannelKind.Society, kinds);
|
|
||||||
Assert.Contains(ChatChannelKind.Olthoi, kinds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -52,25 +51,50 @@ public class UiChannelMenuTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OnChannelChanged_FiredWhenSelectionMadeViaEvent()
|
public void Select_LeftColumnItem_FiresChannel()
|
||||||
{
|
{
|
||||||
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
|
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
|
||||||
|
var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); // open
|
||||||
// Open the popup (click inside the button area — Data2 >= 0).
|
|
||||||
var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5);
|
|
||||||
Assert.True(menu.OnEvent(openEvt));
|
Assert.True(menu.OnEvent(openEvt));
|
||||||
|
|
||||||
// Click on the second item (General) in the upward popup.
|
|
||||||
// Popup renders UPWARD: top = -(12 * 16) = -192.
|
|
||||||
// Item i=1 (General) occupies y in [-192 + 16, -192 + 32) = [-176, -160).
|
|
||||||
// A click at ly = -176 + 8 = -168 hits item index = (int)((-168 + 192) / 16) = (int)(24/16) = 1.
|
|
||||||
ChatChannelKind? fired = null;
|
ChatChannelKind? fired = null;
|
||||||
menu.OnChannelChanged = k => fired = k;
|
menu.OnChannelChanged = k => fired = k;
|
||||||
|
|
||||||
var selectEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -168);
|
// PopupH = 7*16 = 112, top = -112. "Chat to All" (Say) is index 2 = left col, row 2:
|
||||||
Assert.True(menu.OnEvent(selectEvt));
|
// y in [-112+32, -112+48) = [-80,-64). Click (lx=10 < ColW, ly=-72) → idx 2 → Say.
|
||||||
|
var selEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -72);
|
||||||
|
Assert.True(menu.OnEvent(selEvt));
|
||||||
|
Assert.Equal(ChatChannelKind.Say, fired);
|
||||||
|
Assert.Equal(ChatChannelKind.Say, menu.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ChatChannelKind.General, fired);
|
[Fact]
|
||||||
Assert.Equal(ChatChannelKind.General, menu.Selected);
|
public void Select_RightColumnItem_FiresChannel()
|
||||||
|
{
|
||||||
|
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||||
|
|
||||||
|
ChatChannelKind? fired = null;
|
||||||
|
menu.OnChannelChanged = k => fired = k;
|
||||||
|
|
||||||
|
// "Tell to Monarch" is index 7 = right col (lx >= ColW 150), row 0:
|
||||||
|
// y in [-112, -96). Click (lx=160, ly=-104) → col 1, row 0 → idx 7 → Monarch.
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 160, -104)));
|
||||||
|
Assert.Equal(ChatChannelKind.Monarch, fired);
|
||||||
|
Assert.Equal(ChatChannelKind.Monarch, menu.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Select_SpecialItem_DoesNotFire()
|
||||||
|
{
|
||||||
|
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
|
||||||
|
|
||||||
|
int fired = 0;
|
||||||
|
menu.OnChannelChanged = _ => fired++;
|
||||||
|
|
||||||
|
// "Squelch (ignore)" is index 0 = left col, row 0: y in [-112, -96). No channel.
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -104)));
|
||||||
|
Assert.Equal(0, fired); // special item is a no-op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue