feat(D.2b): channel menu — retail colors, 8-piece border, checkbox align, autosize button
Match the talk-focus menu + button to retail (decomp-verified): - Menu item text is FILL-ONLY (retail UIElement_Text outlines only when SetOutline(true); the talk-focus items don't) — kills the grey halo. Available items render white; UNAVAILABLE items render grey (not the salmon colorPink, which is a chat-MESSAGE color we'd misapplied). Special items (Squelch / Tell-to-Selected) render white. Labels indent past the baked checkbox in the row sprite (0600124E empty box / 0600124D white checkmark) instead of overlapping it. - The popup is wrapped in the universal 8-piece window bevel (the menu sprite family has no border) and draws in OnDrawOverlay so the translucent chat panel no longer greys its right column. - The button face (0600124D/66, a fixed 46px LED+arrow sprite) is now 3-sliced (LED cap / stretch / arrow cap) and autosizes to its label via NaturalButtonWidth, so "Chat" fits in the body instead of running into the arrow. The status LED (red Normal / green Pressed) is no longer overdrawn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ebfeaff840
commit
260507e33c
1 changed files with 110 additions and 28 deletions
|
|
@ -43,6 +43,15 @@ public sealed class UiChannelMenu : UiElement
|
||||||
private const int Rows = 7; // items per column
|
private const int Rows = 7; // items per column
|
||||||
private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17)
|
private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17)
|
||||||
private const float ColW = 191f; // column width (dat item template W=191)
|
private const float ColW = 191f; // column width (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;
|
||||||
|
|
||||||
/// <summary>The channel the player's typed text currently goes to.</summary>
|
/// <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;
|
||||||
|
|
@ -65,14 +74,21 @@ public sealed class UiChannelMenu : UiElement
|
||||||
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
|
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
|
||||||
|
|
||||||
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
|
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
|
||||||
/// <summary>Available item text (retail white #FFFFFF).</summary>
|
/// <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);
|
public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
|
||||||
/// <summary>Greyed/unavailable item text (retail colorPink salmon, acclient 0x81c528).</summary>
|
/// <summary>Disabled/unavailable item text — retail GREYS these (UIElement state 0xd
|
||||||
public Vector4 TextColorGhosted { get; set; } = new(1f, 0.588f, 0.588f, 1f);
|
/// 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;
|
private bool _open;
|
||||||
private static float PopupW => 2 * ColW;
|
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
|
||||||
private static float PopupH => Rows * ItemH;
|
private static float InteriorW => 2 * ColW; // 382
|
||||||
|
private static float InteriorH => Rows * ItemH; // 119
|
||||||
|
private static float OuterW => InteriorW + 2 * Border;
|
||||||
|
private static float OuterH => InteriorH + 2 * Border;
|
||||||
|
|
||||||
public UiChannelMenu() { CapturesPointerDrag = true; }
|
public UiChannelMenu() { CapturesPointerDrag = true; }
|
||||||
|
|
||||||
|
|
@ -108,44 +124,104 @@ public sealed class UiChannelMenu : UiElement
|
||||||
{
|
{
|
||||||
var resolve = SpriteResolve;
|
var resolve = SpriteResolve;
|
||||||
|
|
||||||
// Button face + the active-target label.
|
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
|
||||||
if (resolve is not null)
|
if (resolve is not null)
|
||||||
{
|
{
|
||||||
var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite);
|
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||||||
if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
|
||||||
}
|
}
|
||||||
DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f, TextColor);
|
DrawLabel(ctx, ButtonText, 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()
|
||||||
|
{
|
||||||
|
float textW = DatFont?.MeasureWidth(ButtonText) ?? Font?.MeasureWidth(ButtonText) ?? ButtonText.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;
|
if (!_open || resolve is null) return;
|
||||||
|
|
||||||
// Two-column popup opening UPWARD from the button. Force OPAQUE (a menu reads
|
// Two-column popup opening UPWARD from the button, wrapped in the universal
|
||||||
// solid even though the chat window is translucent). Draw the dat row sprites
|
// 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a
|
||||||
// first, then the labels — both go through the sprite bucket in submission order,
|
// bevelled floating window). Force OPAQUE (a menu reads solid even though the
|
||||||
// so the labels land on top (a DrawRect bg would composite over the text instead).
|
// 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);
|
ctx.PushAlphaAbsolute(1f);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
float top = -PopupH;
|
float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
|
||||||
DrawSprite(ctx, resolve, PopupBgSprite, 0f, top, PopupW, PopupH); // panel base
|
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.Length; i++)
|
for (int i = 0; i < Items.Length; i++)
|
||||||
{
|
{
|
||||||
int col = i / Rows, row = i % Rows;
|
int col = i / Rows, row = i % Rows;
|
||||||
float x = col * ColW, y = top + row * ItemH;
|
float x = inX + col * ColW, y = inY + row * ItemH;
|
||||||
bool selected = Items[i].Channel is { } c && c == Selected;
|
bool selected = Items[i].Channel is { } c && c == Selected;
|
||||||
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH);
|
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH);
|
||||||
}
|
}
|
||||||
|
|
||||||
float textY = (ItemH - LineH()) * 0.5f; // center the label in its row
|
float textY = (ItemH - LineH()) * 0.5f; // center the label in its row
|
||||||
for (int i = 0; i < Items.Length; i++)
|
for (int i = 0; i < Items.Length; i++)
|
||||||
{
|
{
|
||||||
int col = i / Rows, row = i % Rows;
|
int col = i / Rows, row = i % Rows;
|
||||||
bool avail = Items[i].Channel is { } c && IsAvailable(c);
|
// Channel items grey out when unavailable; the special items (Squelch /
|
||||||
DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH + textY,
|
// Tell-to-Selected, null channel) are normal white items in retail.
|
||||||
|
bool avail = Items[i].Channel is not { } c || IsAvailable(c);
|
||||||
|
DrawLabel(ctx, Items[i].Label, inX + col * ColW + TextIndent, inY + row * ItemH + textY,
|
||||||
avail ? TextColorAvailable : TextColorGhosted);
|
avail ? TextColorAvailable : TextColorGhosted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally { ctx.PopAlpha(); }
|
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 float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -165,7 +241,7 @@ 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 < PopupW && ly >= -PopupH && ly < Height)
|
=> _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height)
|
||||||
: base.OnHitTest(lx, ly);
|
: base.OnHitTest(lx, ly);
|
||||||
|
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
|
|
@ -173,17 +249,23 @@ public sealed class UiChannelMenu : UiElement
|
||||||
if (e.Type != UiEventType.MouseDown) return false;
|
if (e.Type != UiEventType.MouseDown) return false;
|
||||||
|
|
||||||
float lx = e.Data1, ly = e.Data2;
|
float lx = e.Data1, ly = e.Data2;
|
||||||
if (_open && ly < 0) // clicked an item in the upward popup
|
if (_open && ly < 0) // clicked inside the upward popup
|
||||||
{
|
{
|
||||||
int col = lx < ColW ? 0 : 1;
|
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring
|
||||||
int row = (int)((ly + PopupH) / ItemH);
|
// (outside the interior) just close the menu.
|
||||||
int idx = col * Rows + row;
|
float ix = lx - Border, iy = ly - (-OuterH + Border);
|
||||||
// Only pick available channel items (special + greyed items are inert).
|
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
|
||||||
if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length
|
|
||||||
&& Items[idx].Channel is { } ch && IsAvailable(ch))
|
|
||||||
{
|
{
|
||||||
Selected = ch;
|
int col = ix < ColW ? 0 : 1;
|
||||||
OnChannelChanged?.Invoke(ch);
|
int row = (int)(iy / ItemH);
|
||||||
|
int idx = col * Rows + row;
|
||||||
|
// Only pick available channel items (special + greyed items are inert).
|
||||||
|
if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length
|
||||||
|
&& Items[idx].Channel is { } ch && IsAvailable(ch))
|
||||||
|
{
|
||||||
|
Selected = ch;
|
||||||
|
OnChannelChanged?.Invoke(ch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_open = false;
|
_open = false;
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue