acdream/src/AcDream.App/UI/UiRenderContext.cs
Erik 3441a71833 feat(ui): mark store-only option rows dimmed (user-directed, gate 2)
User directive (gate 2, verbatim): "mark all options that are not
implemented now, so I can clearly see what is not implemented." Store-only
rows keep full interactivity (still persist/send) but render their caption
in a shared dimmed grey (UiRenderContext.StoreOnlyCaptionColor, matching
the existing UiMenu.TextColorGhosted convention) instead of white/DAT
color. No invented marker text anywhere -- the dim IS the marker.

Config tab (ConfigOptionsPageController, 21 of 27 rows dimmed):
  Sound Features menu, Interface Sound trio, Play Sound Only When Active
  (AP-199); Screen Brightness, Automatic Degrades, Graphics Performance,
  Degrade Distance, the four Rendering Quality menus, Building Detail
  Textures, Multi-Pass Alpha (AP-198); Camera Stiffness, Camera Adjustment
  Speed, Align To Slope, Mouse Look Sensitivity, Invert Mouselook Y Axis,
  Use Mouse Turning (TS-74); Chat Font Face/Size (AP-200). NOT dimmed:
  Sound/Ambient trios, Resolution, Full Screen (LIVE), VSync and Field of
  View (NEXT-LAUNCH -- still implemented, just deferred to next process
  start, per the controller's own doc).

Character tab (CharacterOptionsPageController, 35 of 50 rows dimmed):
  every Group A (wire+store only) and Group D (deferred) row, plus the
  Group B rows the OP4 gate script's own step 16 confirms are unbound
  (ShowTooltips, SideBySideVitals, SpellDuration, AdvancedCombatUI,
  StayInChatMode, DisableMostWeatherEffects, PersistentAtDay,
  FilterLanguage, MainPackPreferred). NOT dimmed (15 rows): the six
  ListenTo*Chat ids (TurbineChatMembershipGate), DisableDistanceFog/
  DisplayTimeStamps/ToggleRun (bound at GameWindow.cs), the Group-C
  re-point (ViewCombatTarget/VividTargetingIndicator/CoordinatesOnRadar/
  AutoTarget/AutoRepeatAttack), and DragItemOnPlayerOpensSecureTrade
  (TS-48). Cross-checked against actual shipped consumers via source grep,
  not just the research doc's Group table, since OP4 only wired a subset
  of the doc's aspirational Group B.

Configure Keyboard (KeyboardConfigController): a row whose
RetailActionIdentityTable lookup fails (MappedAction null -- AP-203's
Emote/CharacterSettings set) dims its synthesized caption; the key
buttons stay fully bindable/persisted/conflict-checked.

Chat tab (ChatOptionsPageController): audited, zero store-only rows --
every filter block and both opacity sliders already have a live consumer
(ChatWindowState / RetailWindowOpacityController).

Ambiguity flagged, not guessed: the character-options-map.md research doc
lists AcceptLootPermits in BOTH Group A and Group C; its only code site
(LiveSessionRuntimeFactory.cs, the /consent command) is a second setter
for the same server bit, not a behavioral reader, so it is classified
Group A / dimmed here.

Register: AD-78 documents the convention (retail dims nothing; this is a
deliberate acdream-only divergence that retires as consumers land).

New per-surface conformance tests pin the exact dimmed/live set against a
literal expected list, so wiring a future consumer without also flipping
its row's literal fails the build:
CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly
+ Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows,
ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly,
KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite.

Build green; full Release suite 13,086 passed / 4 skipped / 0 failed
(baseline 13,082/4/0 -- delta is exactly the four new tests above).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:52:20 +02:00

469 lines
22 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
internal readonly record struct UiClipRect(float Left, float Top, float Right, float Bottom)
{
public bool IsEmpty => Right <= Left || Bottom <= Top;
public static UiClipRect Intersect(UiClipRect a, UiClipRect b)
=> new(
MathF.Max(a.Left, b.Left),
MathF.Max(a.Top, b.Top),
MathF.Min(a.Right, b.Right),
MathF.Min(a.Bottom, b.Bottom));
public static bool TryClipSprite(
UiClipRect clip,
ref float x, ref float y, ref float w, ref float h,
ref float u0, ref float v0, ref float u1, ref float v1)
=> QuadClipper.TryClip(
clip.Left, clip.Top, clip.Right, clip.Bottom,
ref x, ref y, ref w, ref h,
ref u0, ref v0, ref u1, ref v1);
}
/// <summary>
/// Per-frame drawing context passed through the <see cref="UiElement"/>
/// tree. Wraps a <see cref="TextRenderer"/> (our 2D sprite batcher) and a
/// transform stack so elements can draw in local coordinates.
///
/// Retail equivalent: the implicit context <c>FUN_005da8f0</c> walks with
/// when iterating the UI tree. Our version is explicit so it plugs
/// cleanly into Silk.NET.
/// </summary>
public sealed class UiRenderContext
{
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
public Vector2 ScreenSize { get; }
// Transform stack — simple 2D translate (no rotation/scale for UI).
private readonly System.Collections.Generic.List<Vector2> _stack = new();
private Vector2 _current;
private readonly System.Collections.Generic.List<UiClipRect?> _clipStack = new();
private UiClipRect? _clip;
// Alpha (opacity) stack — a window pushes its Opacity so EVERY draw under it
// (sprite, rect/fill, AND text) fades together. Retail's ChatInterface::SetOpacity
// (0x004F3120) sets one alpha on the window's whole composited render surface —
// chrome, background, and glyphs all fade as one unit, not text-stays-sharp over a
// translucent panel. Campaign CH slice CH6c ported this: DrawStringDat and
// DrawString both route through ApplyAlpha exactly like DrawSprite/DrawRect/DrawFill.
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;
ScreenSize = screenSize;
DefaultFont = defaultFont;
}
/// <summary>Push a relative translate. Must be paired with <see cref="PopTransform"/>.</summary>
public void PushTransform(float dx, float dy)
{
_stack.Add(_current);
_current += new Vector2(dx, dy);
}
public void PopTransform()
{
if (_stack.Count == 0) return;
_current = _stack[^1];
_stack.RemoveAt(_stack.Count - 1);
}
public Vector2 CurrentOrigin => _current;
/// <summary>Intersect descendant drawing with a local-space viewport.</summary>
public void PushClip(float x, float y, float w, float h)
{
_clipStack.Add(_clip);
var next = new UiClipRect(
_current.X + x,
_current.Y + y,
_current.X + x + MathF.Max(0f, w),
_current.Y + y + MathF.Max(0f, h));
_clip = _clip is { } current
? UiClipRect.Intersect(current, next)
: next;
}
public void PopClip()
{
if (_clipStack.Count == 0) return;
_clip = _clipStack[^1];
_clipStack.RemoveAt(_clipStack.Count - 1);
}
/// <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)
{
x += _current.X;
y += _current.Y;
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
TextRenderer.DrawRect(x, 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)
{
x += _current.X;
y += _current.Y;
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
TextRenderer.DrawFill(x, y, w, h, ApplyAlpha(color));
}
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
if (thickness <= 0f || w <= 0f || h <= 0f) return;
float t = MathF.Min(thickness, MathF.Min(w, h) * 0.5f);
DrawRect(x, y, w, t, color);
DrawRect(x, y + h - t, w, t, color);
DrawRect(x, y + t, t, h - 2f * t, color);
DrawRect(x + w - t, y + t, t, h - 2f * t, color);
}
public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint)
{
x += _current.X;
y += _current.Y;
DrawSpriteAbsolute(texture, x, y, w, h, u0, v0, u1, v1, tint, applyAlpha: true);
}
private void DrawSpriteAbsolute(
uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint, bool applyAlpha)
{
if (_clip is { } clip
&& !UiClipRect.TryClipSprite(
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1))
return;
TextRenderer.DrawSprite(
texture, x, y, w, h, u0, v0, u1, v1,
applyAlpha ? ApplyAlpha(tint) : tint);
}
private bool ClipRect(ref float x, ref float y, ref float w, ref float h)
{
if (_clip is not { } clip)
return w > 0f && h > 0f;
float u0 = 0f, v0 = 0f, u1 = 1f, v1 = 1f;
return UiClipRect.TryClipSprite(
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1);
}
/// <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)
{
var f = font ?? DefaultFont;
if (f is null) return;
float screenX = _current.X + x;
float screenY = _current.Y + y;
Vector4 alphaColor = ApplyAlpha(color);
if (_clip is { } clip)
{
TextRenderer.DrawStringClipped(
f, text, screenX, screenY, alphaColor,
clip.Left, clip.Top, clip.Right, clip.Bottom);
return;
}
TextRenderer.DrawString(f, text, screenX, screenY, alphaColor);
}
/// <summary>Retail <c>UIElement_Text</c>'s constructor outline color default
/// (<c>RGBAColor_Black</c>, <c>UIElement_Text::UIElement_Text @0x004686cb</c>).
/// Used by <see cref="DrawStringDat"/> when the caller doesn't supply an
/// explicit <c>outlineColor</c> (LayoutDesc property 0x22 is authored on only
/// 9 elements in the whole DAT set — every other outlined element uses this).</summary>
public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f);
/// <summary>
/// AD-78 (user-directed, 2026-08-11, Campaign OP gate 2): the shared caption
/// color for Options-panel / Configure-Keyboard rows that are store-only —
/// they persist (and, where auto-save, send the wire bit) but drive nothing
/// observable in acdream yet. Retail dims nothing here (every retail row has
/// a live consumer by construction); this is a deliberate acdream-only
/// convention so a store-only row is visually distinguishable from a live
/// one without any invented marker text. Same neutral grey as the existing
/// disabled/ghosted convention (<see cref="UiMenu.TextColorGhosted"/>,
/// retail's disabled StateDesc grey) — reused rather than a new color so the
/// "this doesn't do anything yet" signal reads consistently across the whole
/// UI. See AD-78 for the full citation list; the row retires as consumers
/// land (each landing un-dims its own rows via the owning controller's
/// conformance test).
/// </summary>
public static readonly Vector4 StoreOnlyCaptionColor = new(0.5f, 0.5f, 0.5f, 1f);
/// <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). 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</c> via the glyph's OffsetY into
/// the atlas.
///
/// <para>
/// <b>Two-pass outline model</b> — <c>UIElement_Text::DrawSelf</c>
/// (acclient 0x00467aa0): <c>var_b0 = (m_bitField &amp; 0x10) ? 0 : 1;</c> — when
/// the outline bit is CLEAR the loop runs once (fill only, <c>var_b0==1</c>);
/// when SET it runs twice, pass 0 (outline) for the WHOLE STRING first, then
/// pass 1 (fill) for the whole string. Fills therefore always paint over every
/// neighbour's outline — this method reproduces that by iterating the string
/// twice rather than interleaving outline+fill per glyph, so on tight kerning
/// glyph N+1's outline never covers glyph N's fill (nor vice versa).
/// </para>
///
/// <para>
/// <paramref name="outline"/> gates the outline passes. Retail decides this PER
/// text element: the bit is <c>m_bitField &amp; 0x10</c>, set by LayoutDesc
/// property <b>0x21</b> (<c>SetOutline @0x0046a81c</c>) — NOT property 0xd (an
/// earlier comment here named the wrong id: 0xd is the *switch-case index* inside
/// <c>UIElement_Text::OnSetAttribute</c>, not the authored property). The DEFAULT
/// is OFF (one fill-only pass, ctor <c>m_bitField=0x300</c> clears bit 0x10):
/// outlining is opt-in per element (~100 authored rows across 15 layouts).
/// <paramref name="outlineColor"/> is the element's <c>m_curOutlineColor</c>
/// (LayoutDesc property 0x22, ctor default <see cref="DefaultOutlineColor"/> —
/// black; only 9 elements in the whole DAT set author a non-black value).
/// </para>
///
/// <para>
/// <b>Outline mechanism</b> — retail picks by data, per font
/// (<c>feedback_retail_dispatch_is_data_driven</c> shape): when
/// <see cref="UiDatFont.HasBackground"/> is true, the outline pass blits the
/// BACKGROUND (dilated) atlas sub-rect, inflated by
/// <see cref="UiDatFont.BorderX"/>/<see cref="UiDatFont.BorderY"/> on every
/// side of both the source AND destination rect
/// (<c>SurfaceWindow::DrawCharacter @0x00442d3a</c> +
/// <c>CreateCharRectPair @0x00441480</c>) — the un-inflated rect crops the
/// dilation away, making the outline invisible even with the flag on. When the
/// font has NO background atlas (the CJK/unicode family), retail falls back to
/// 8 neighbour blits of the FOREGROUND glyph at ±1px
/// (<c>UIElement_Text::DrawSelf</c> 0x00467d7e-0x00467e14).
/// </para>
/// </summary>
public void DrawStringDat(
UiDatFont font, string text, float x, float y, Vector4 color,
bool outline = false, Vector4? outlineColor = null)
{
if (font is null || string.IsNullOrEmpty(text)) return;
if (outline)
{
// PASS 0 — outline, whole string (acclient 0x00467aa0, var_b0 starts at 0).
// The outline tint uses the outline color's OWN alpha (round-5 review N3):
// an earlier version substituted the FILL color's alpha here, which is not
// what retail's DrawSelf does (the outline and fill passes tint independent
// RGBA colors, m_curOutlineColor and m_curTextColor) and made a translucent
// outline color always render at the fill's opacity instead of its own.
DrawStringDatPass(font, text, x, y, outlineColor ?? DefaultOutlineColor, isOutlinePass: true);
}
// PASS 1 (or the only pass, when outline is off) — fill, whole string.
DrawStringDatPass(font, text, x, y, color, isOutlinePass: false);
}
/// <summary>
/// One whole-string pass of <see cref="DrawStringDat"/>'s two-pass outline+fill
/// model: either every glyph's outline (<paramref name="isOutlinePass"/> true) or
/// every glyph's fill. Recomputes the pen from scratch — a pure function of
/// <paramref name="font"/>/<paramref name="text"/>/<paramref name="x"/>/
/// <paramref name="y"/>, so repeated calls advance identically and stay in
/// lock-step without sharing mutable state.
///
/// <para>
/// <b>Exposed for BLOCK-level batching</b> (round-5 review S1): a caller that draws
/// several lines or runs sharing ONE <c>Outline</c> flag (<see cref="AcDream.App.UI.UiText"/>'s
/// multi-line transcript and colored-run label) must submit EVERY line's outline
/// pass before ANY line's fill pass — retail's <c>UIElement_Text::DrawSelf</c>
/// (0x00467aa0) walks every glyph of the whole block in the outline pass before any
/// fill. Calling <see cref="DrawStringDat"/> once per line instead (outline+fill,
/// outline+fill, ...) lets line N+1's outline draw AFTER line N's fill and notch a
/// descender that pokes into the line above. A caller with several lines should
/// call this once per line with <paramref name="isOutlinePass"/>=true for every
/// line, THEN once per line with <paramref name="isOutlinePass"/>=false for every
/// line. A single independent line can still call <see cref="DrawStringDat"/> directly.
/// </para>
/// </summary>
public void DrawStringDatPass(
UiDatFont font, string text, float x, float y, Vector4 tint, bool isOutlinePass)
{
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;
// 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);
float pen = originX;
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's 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)
{
if (isOutlinePass)
DrawOutlineGlyph(font, g, gx, gy, gw, gh, tint);
else
DrawFillGlyph(font, g, gx, gy, gw, gh, tint);
}
pen += UiDatFont.GlyphAdvance(g);
}
}
/// <summary>Foreground (fill) atlas blit, tinted with the requested text color.
/// Both passes route through <c>DrawSpriteAbsolute(..., applyAlpha: true)</c> so a
/// window's opacity fades glyphs exactly like its chrome/background sprites —
/// retail's <c>ChatInterface::SetOpacity</c> (0x004F3120) fades the whole
/// composited surface.</summary>
private void DrawFillGlyph(
UiDatFont font, DatReaderWriter.Types.FontCharDesc g,
float gx, float gy, float gw, float gh, Vector4 tint)
{
var (fu0, fv0, fu1, fv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.ForegroundWidth, font.ForegroundHeight);
DrawSpriteAbsolute(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, tint, applyAlpha: true);
}
/// <summary>Outline-pass blit for one glyph: the background (dilated) atlas
/// sub-rect inflated by the font's border-pixel margin when the font carries
/// one, else retail's 8-neighbour ±1px foreground-glyph fallback.</summary>
private void DrawOutlineGlyph(
UiDatFont font, DatReaderWriter.Types.FontCharDesc g,
float gx, float gy, float gw, float gh, Vector4 tint)
{
if (font.BackgroundTexture != 0)
{
// Background (dilated) plane, inflated by (BorderX, BorderY) on every side of
// BOTH the source sub-rect and the destination rect — CreateCharRectPair's
// background call (0x00442d3a) inflates both symmetrically before the atlas
// blit. The un-inflated rect (acdream's prior behavior) crops the dilation
// away entirely, leaving only a single stray pixel where a descender pokes out.
int bx = font.BorderX, by = font.BorderY;
float ix = gx - bx;
float iy = gy - by;
float iw = gw + 2f * bx;
float ih = gh + 2f * by;
// Clamp the inflated SOURCE rect to the atlas bounds (round-5 review N4):
// a glyph whose art sits at the edge of the background atlas can inflate
// past [0, BackgroundWidth/Height], which produced UV coordinates outside
// [0,1] and could sample a neighbouring glyph's texels. Retail's own
// CreateCharRectPair (0x00441480) clamps both rects the same way — when a
// side of the SOURCE rect would fall outside the atlas, that side is pulled
// back to the edge and the DESTINATION rect shrinks by the same amount, so
// the visible outline is simply cropped rather than mis-sampled. A no-op
// (byte-identical output) for every glyph that doesn't sit on the atlas edge.
float srcX = g.OffsetX - bx, srcY = g.OffsetY - by;
float srcW = iw, srcH = ih;
float destX = ix, destY = iy, destW = iw, destH = ih;
if (srcX < 0f) { destX -= srcX; destW += srcX; srcW += srcX; srcX = 0f; }
if (srcY < 0f) { destY -= srcY; destH += srcY; srcH += srcY; srcY = 0f; }
if (srcX + srcW > font.BackgroundWidth)
{
float over = srcX + srcW - font.BackgroundWidth;
srcW -= over; destW -= over;
}
if (srcY + srcH > font.BackgroundHeight)
{
float over = srcY + srcH - font.BackgroundHeight;
srcH -= over; destH -= over;
}
if (srcW <= 0f || srcH <= 0f) return;
var (bu0, bv0, bu1, bv1) = AtlasUv(
(int)srcX, (int)srcY, (int)srcW, (int)srcH,
font.BackgroundWidth, font.BackgroundHeight);
DrawSpriteAbsolute(font.BackgroundTexture, destX, destY, destW, destH, bu0, bv0, bu1, bv1, tint, applyAlpha: true);
}
else
{
// No background plane (retail's 0-border fonts — the CJK/unicode family):
// fall back to retail's 8-neighbour ±1px foreground-glyph blit
// (UIElement_Text::DrawSelf 0x00467d7e-0x00467e14).
var (fu0, fv0, fu1, fv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.ForegroundWidth, font.ForegroundHeight);
for (int dy = -1; dy <= 1; dy++)
{
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
DrawSpriteAbsolute(
font.ForegroundTexture, gx + dx, gy + dy, gw, gh,
fu0, fv0, fu1, fv1, tint, applyAlpha: true);
}
}
}
}
/// <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);
}
}