fix(ui): render live component counts
Port retail text-surface clipping so the 15px component count field remains visible under the 16px DAT font. Cover SetStackSize decrements and final component removal while preserving desired restock values. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
a1175e6aac
commit
0527325b25
7 changed files with 217 additions and 36 deletions
|
|
@ -360,6 +360,14 @@ UpdateComponents(change):
|
||||||
row[0x1000046B].inputFilter = NumberInputFilter
|
row[0x1000046B].inputFilter = NumberInputFilter
|
||||||
insertion++
|
insertion++
|
||||||
|
|
||||||
|
SyncComponentRegionWithData(row, componentData):
|
||||||
|
row[0x1000046A].text = componentData.numItems
|
||||||
|
|
||||||
|
UIElement_Text::DrawSelf(rowText, visibleSurface):
|
||||||
|
position each glyph line using CalcJustification
|
||||||
|
draw intersecting glyphs clipped to visibleSurface
|
||||||
|
do not discard a line merely because its line box exceeds the surface
|
||||||
|
|
||||||
ListenToElementMessage(message):
|
ListenToElementMessage(message):
|
||||||
if selection changed:
|
if selection changed:
|
||||||
if selected entry is a category:
|
if selected entry is a category:
|
||||||
|
|
@ -388,6 +396,12 @@ equivalent of `AddItemFromTemplateList`. Controllers bind only the live icon,
|
||||||
texts, selection, and edit callbacks. They do not duplicate the row sprites,
|
texts, selection, and edit callbacks. They do not duplicate the row sprites,
|
||||||
fonts, or geometry.
|
fonts, or geometry.
|
||||||
|
|
||||||
|
The owned-count child is 15 pixels high while installed font `0x40000000`
|
||||||
|
advances 16 pixels. Retail's `UIElement_Text::DrawSelf @ 0x00467AA0` clips the
|
||||||
|
glyph blits against the supplied visible surface, so the count remains visible.
|
||||||
|
A retained renderer that accepts only fully contained lines suppresses this
|
||||||
|
field and is not retail-conformant.
|
||||||
|
|
||||||
### Spellbook row, selection, filtering, dragging, and deletion
|
### Spellbook row, selection, filtering, dragging, and deletion
|
||||||
|
|
||||||
Named-retail anchors:
|
Named-retail anchors:
|
||||||
|
|
|
||||||
51
src/AcDream.App/Rendering/QuadClipper.cs
Normal file
51
src/AcDream.App/Rendering/QuadClipper.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clips one screen-space textured quad and remaps its UV rectangle to the surviving
|
||||||
|
/// pixels. Shared by retained DAT sprites and fallback bitmap-font glyphs so every UI
|
||||||
|
/// draw path applies the same edge semantics.
|
||||||
|
/// </summary>
|
||||||
|
internal static class QuadClipper
|
||||||
|
{
|
||||||
|
public static bool TryClip(
|
||||||
|
float clipLeft,
|
||||||
|
float clipTop,
|
||||||
|
float clipRight,
|
||||||
|
float clipBottom,
|
||||||
|
ref float x,
|
||||||
|
ref float y,
|
||||||
|
ref float w,
|
||||||
|
ref float h,
|
||||||
|
ref float u0,
|
||||||
|
ref float v0,
|
||||||
|
ref float u1,
|
||||||
|
ref float v1)
|
||||||
|
{
|
||||||
|
if (clipRight <= clipLeft || clipBottom <= clipTop || w <= 0f || h <= 0f)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
float left = MathF.Max(x, clipLeft);
|
||||||
|
float top = MathF.Max(y, clipTop);
|
||||||
|
float right = MathF.Min(x + w, clipRight);
|
||||||
|
float bottom = MathF.Min(y + h, clipBottom);
|
||||||
|
if (right <= left || bottom <= top)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
float oldX = x, oldY = y, oldW = w, oldH = h;
|
||||||
|
float oldU0 = u0, oldV0 = v0, oldU1 = u1, oldV1 = v1;
|
||||||
|
float tx0 = (left - oldX) / oldW;
|
||||||
|
float tx1 = (right - oldX) / oldW;
|
||||||
|
float ty0 = (top - oldY) / oldH;
|
||||||
|
float ty1 = (bottom - oldY) / oldH;
|
||||||
|
|
||||||
|
x = left;
|
||||||
|
y = top;
|
||||||
|
w = right - left;
|
||||||
|
h = bottom - top;
|
||||||
|
u0 = oldU0 + (oldU1 - oldU0) * tx0;
|
||||||
|
u1 = oldU0 + (oldU1 - oldU0) * tx1;
|
||||||
|
v0 = oldV0 + (oldV1 - oldV0) * ty0;
|
||||||
|
v1 = oldV0 + (oldV1 - oldV0) * ty1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -144,6 +144,41 @@ public sealed unsafe class TextRenderer : IDisposable
|
||||||
/// typographic block. Handles '\n' as a line break.
|
/// typographic block. Handles '\n' as a line break.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
|
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
|
||||||
|
=> DrawStringCore(
|
||||||
|
font, text, x, y, color,
|
||||||
|
clip: false, 0f, 0f, 0f, 0f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draw a bitmap-font string clipped to an absolute screen-space rectangle.
|
||||||
|
/// The retained UI uses this overload when a text element intersects its authored
|
||||||
|
/// surface edge. Retail <c>UIElement_Text::DrawSelf @ 0x00467AA0</c> clips the
|
||||||
|
/// individual glyph blits instead of discarding the whole line.
|
||||||
|
/// </summary>
|
||||||
|
internal void DrawStringClipped(
|
||||||
|
BitmapFont font,
|
||||||
|
string text,
|
||||||
|
float x,
|
||||||
|
float y,
|
||||||
|
Vector4 color,
|
||||||
|
float clipLeft,
|
||||||
|
float clipTop,
|
||||||
|
float clipRight,
|
||||||
|
float clipBottom)
|
||||||
|
=> DrawStringCore(
|
||||||
|
font, text, x, y, color,
|
||||||
|
clip: true, clipLeft, clipTop, clipRight, clipBottom);
|
||||||
|
|
||||||
|
private void DrawStringCore(
|
||||||
|
BitmapFont font,
|
||||||
|
string text,
|
||||||
|
float x,
|
||||||
|
float y,
|
||||||
|
Vector4 color,
|
||||||
|
bool clip,
|
||||||
|
float clipLeft,
|
||||||
|
float clipTop,
|
||||||
|
float clipRight,
|
||||||
|
float clipBottom)
|
||||||
{
|
{
|
||||||
float cursorX = x;
|
float cursorX = x;
|
||||||
// The caller provides top-y; shift to baseline for glyph offset math.
|
// The caller provides top-y; shift to baseline for glyph offset math.
|
||||||
|
|
@ -170,11 +205,19 @@ public sealed unsafe class TextRenderer : IDisposable
|
||||||
float gy = baseline + g.OffsetY;
|
float gy = baseline + g.OffsetY;
|
||||||
float gw = g.Width;
|
float gw = g.Width;
|
||||||
float gh = g.Height;
|
float gh = g.Height;
|
||||||
|
float u0 = g.UvMinX;
|
||||||
|
float v0 = g.UvMinY;
|
||||||
|
float u1 = g.UvMaxX;
|
||||||
|
float v1 = g.UvMaxY;
|
||||||
|
|
||||||
if (gw > 0 && gh > 0)
|
if (gw > 0 && gh > 0
|
||||||
|
&& (!clip || QuadClipper.TryClip(
|
||||||
|
clipLeft, clipTop, clipRight, clipBottom,
|
||||||
|
ref gx, ref gy, ref gw, ref gh,
|
||||||
|
ref u0, ref v0, ref u1, ref v1)))
|
||||||
{
|
{
|
||||||
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
|
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _overlayTextVerts += 6; }
|
||||||
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
|
else { AppendQuad(_textBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _textVerts += 6; }
|
||||||
}
|
}
|
||||||
cursorX += g.Advance;
|
cursorX += g.Advance;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,34 +18,10 @@ internal readonly record struct UiClipRect(float Left, float Top, float Right, f
|
||||||
UiClipRect clip,
|
UiClipRect clip,
|
||||||
ref float x, ref float y, ref float w, ref float h,
|
ref float x, ref float y, ref float w, ref float h,
|
||||||
ref float u0, ref float v0, ref float u1, ref float v1)
|
ref float u0, ref float v0, ref float u1, ref float v1)
|
||||||
{
|
=> QuadClipper.TryClip(
|
||||||
if (clip.IsEmpty || w <= 0f || h <= 0f)
|
clip.Left, clip.Top, clip.Right, clip.Bottom,
|
||||||
return false;
|
ref x, ref y, ref w, ref h,
|
||||||
|
ref u0, ref v0, ref u1, ref v1);
|
||||||
float left = MathF.Max(x, clip.Left);
|
|
||||||
float top = MathF.Max(y, clip.Top);
|
|
||||||
float right = MathF.Min(x + w, clip.Right);
|
|
||||||
float bottom = MathF.Min(y + h, clip.Bottom);
|
|
||||||
if (right <= left || bottom <= top)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
float oldX = x, oldY = y, oldW = w, oldH = h;
|
|
||||||
float oldU0 = u0, oldV0 = v0, oldU1 = u1, oldV1 = v1;
|
|
||||||
float tx0 = (left - oldX) / oldW;
|
|
||||||
float tx1 = (right - oldX) / oldW;
|
|
||||||
float ty0 = (top - oldY) / oldH;
|
|
||||||
float ty1 = (bottom - oldY) / oldH;
|
|
||||||
|
|
||||||
x = left;
|
|
||||||
y = top;
|
|
||||||
w = right - left;
|
|
||||||
h = bottom - top;
|
|
||||||
u0 = oldU0 + (oldU1 - oldU0) * tx0;
|
|
||||||
u1 = oldU0 + (oldU1 - oldU0) * tx1;
|
|
||||||
v0 = oldV0 + (oldV1 - oldV0) * ty0;
|
|
||||||
v1 = oldV0 + (oldV1 - oldV0) * ty1;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -210,7 +186,16 @@ public sealed class UiRenderContext
|
||||||
{
|
{
|
||||||
var f = font ?? DefaultFont;
|
var f = font ?? DefaultFont;
|
||||||
if (f is null) return;
|
if (f is null) return;
|
||||||
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
|
float screenX = _current.X + x;
|
||||||
|
float screenY = _current.Y + y;
|
||||||
|
if (_clip is { } clip)
|
||||||
|
{
|
||||||
|
TextRenderer.DrawStringClipped(
|
||||||
|
f, text, screenX, screenY, color,
|
||||||
|
clip.Left, clip.Top, clip.Right, clip.Bottom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TextRenderer.DrawString(f, text, screenX, screenY, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,25 @@ public sealed class UiText : UiElement, IUiDatStateful
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawText(UiRenderContext ctx)
|
private void DrawText(UiRenderContext ctx)
|
||||||
|
{
|
||||||
|
// Retail UIElement_Text::DrawSelf @ 0x00467AA0 receives the text element's
|
||||||
|
// visible surface as arg3 and clips each glyph blit to that rectangle. This is
|
||||||
|
// observable in LayoutDesc 0x21000033: the owned component count is a 15px-high
|
||||||
|
// text element using a 16px DAT font, so rejecting a partially visible line makes
|
||||||
|
// the value disappear entirely. The shared render context clips both DAT and
|
||||||
|
// bitmap glyph quads and composes this bound with any list/window ancestor clip.
|
||||||
|
ctx.PushClip(0f, 0f, Width, Height);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DrawClippedText(ctx);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ctx.PopClip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawClippedText(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
|
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
|
||||||
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
|
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
|
||||||
|
|
@ -451,7 +470,7 @@ public sealed class UiText : UiElement, IUiDatStateful
|
||||||
for (int i = 0; i < lines.Count; i++)
|
for (int i = 0; i < lines.Count; i++)
|
||||||
{
|
{
|
||||||
float y = baseY + i * lh;
|
float y = baseY + i * lh;
|
||||||
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
if (!LineIntersectsViewport(y, lh, top, bottom)) continue;
|
||||||
|
|
||||||
string text = lines[i].Text;
|
string text = lines[i].Text;
|
||||||
float lineX = HorizontalOffset(text, datFont, bitmapFont);
|
float lineX = HorizontalOffset(text, datFont, bitmapFont);
|
||||||
|
|
@ -489,6 +508,19 @@ public sealed class UiText : UiElement, IUiDatStateful
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when any vertical portion of a line intersects a text viewport. Retail
|
||||||
|
/// clips the glyphs at the viewport edge; it does not require the full line box to fit.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool LineIntersectsViewport(
|
||||||
|
float lineTop,
|
||||||
|
float lineHeight,
|
||||||
|
float viewportTop,
|
||||||
|
float viewportBottom)
|
||||||
|
=> lineHeight > 0f
|
||||||
|
&& lineTop < viewportBottom
|
||||||
|
&& lineTop + lineHeight > viewportTop;
|
||||||
|
|
||||||
private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
|
private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
|
||||||
{
|
{
|
||||||
float width = datFont is not null
|
float width = datFont is not null
|
||||||
|
|
|
||||||
|
|
@ -323,9 +323,12 @@ public sealed class SpellbookWindowControllerTests
|
||||||
Assert.Equal("Copper Scarab", Assert.Single(
|
Assert.Equal("Copper Scarab", Assert.Single(
|
||||||
Assert.IsType<UiText>(copper.Content.FindElement(
|
Assert.IsType<UiText>(copper.Content.FindElement(
|
||||||
ComponentBookTemplateFactory.NameId)).LinesProvider()).Text);
|
ComponentBookTemplateFactory.NameId)).LinesProvider()).Text);
|
||||||
Assert.Equal("2", Assert.Single(
|
UiText ownedCount = Assert.IsType<UiText>(copper.Content.FindElement(
|
||||||
Assert.IsType<UiText>(copper.Content.FindElement(
|
ComponentBookTemplateFactory.OwnedCountId));
|
||||||
ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text);
|
Assert.Equal((224f, 0f, 60f, 15f),
|
||||||
|
(ownedCount.Left, ownedCount.Top, ownedCount.Width, ownedCount.Height));
|
||||||
|
Assert.False(ownedCount.OneLine);
|
||||||
|
Assert.Equal("2", Assert.Single(ownedCount.LinesProvider()).Text);
|
||||||
UiField desiredField = Assert.IsType<UiField>(copper.Content.FindElement(
|
UiField desiredField = Assert.IsType<UiField>(copper.Content.FindElement(
|
||||||
ComponentBookTemplateFactory.DesiredCountId));
|
ComponentBookTemplateFactory.DesiredCountId));
|
||||||
Assert.Equal((224f, 15f, 60f, 15f),
|
Assert.Equal((224f, 15f, 60f, 15f),
|
||||||
|
|
@ -361,6 +364,27 @@ public sealed class SpellbookWindowControllerTests
|
||||||
Assert.False(copper.Selected);
|
Assert.False(copper.Selected);
|
||||||
selection.Select(0xDEADBEEFu, SelectionChangeSource.World);
|
selection.Select(0xDEADBEEFu, SelectionChangeSource.World);
|
||||||
Assert.False(amaranth.Selected);
|
Assert.False(amaranth.Selected);
|
||||||
|
|
||||||
|
// ACE consumes components through SetStackSize (0x0197). The object table
|
||||||
|
// publishes ObjectUpdated; the controller rebuilds the tracker total on Tick.
|
||||||
|
Assert.True(objects.UpdateStackSize(10u, stackSize: 1, value: 0));
|
||||||
|
controller.Tick();
|
||||||
|
UiTemplateListSlot updatedCopper = Assert.IsType<UiTemplateListSlot>(list.GetItem(1));
|
||||||
|
Assert.Equal("1", Assert.Single(
|
||||||
|
Assert.IsType<UiText>(updatedCopper.Content.FindElement(
|
||||||
|
ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text);
|
||||||
|
Assert.Equal("21", Assert.IsType<UiField>(updatedCopper.Content.FindElement(
|
||||||
|
ComponentBookTemplateFactory.DesiredCountId)).Text);
|
||||||
|
|
||||||
|
// Consuming the final item removes its object. The row disappears while the
|
||||||
|
// category remains because Diamond Scarab is still owned.
|
||||||
|
Assert.True(objects.Remove(10u));
|
||||||
|
controller.Tick();
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
Enumerable.Range(0, list.GetNumUIItems())
|
||||||
|
.Select(list.GetItem)
|
||||||
|
.OfType<UiTemplateListSlot>(),
|
||||||
|
slot => slot.EntryId == 100u);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SpellbookWindowController? Bind(
|
private static SpellbookWindowController? Bind(
|
||||||
|
|
|
||||||
|
|
@ -277,4 +277,36 @@ public class UiTextTests
|
||||||
|
|
||||||
Assert.Equal(9f, y);
|
Assert.Equal(9f, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LineIntersectsViewport_PartialLineRemainsDrawable()
|
||||||
|
{
|
||||||
|
// Component owned-count boxes are 15px tall while Font 0x40000000
|
||||||
|
// advances 16px. Retail clips the final pixel instead of dropping the line.
|
||||||
|
Assert.True(UiText.LineIntersectsViewport(
|
||||||
|
lineTop: 0f,
|
||||||
|
lineHeight: 16f,
|
||||||
|
viewportTop: 0f,
|
||||||
|
viewportBottom: 15f));
|
||||||
|
|
||||||
|
Assert.True(UiText.LineIntersectsViewport(
|
||||||
|
lineTop: -1f,
|
||||||
|
lineHeight: 16f,
|
||||||
|
viewportTop: 0f,
|
||||||
|
viewportBottom: 15f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-16f, 16f)]
|
||||||
|
[InlineData(15f, 16f)]
|
||||||
|
public void LineIntersectsViewport_FullyClippedLineIsSkipped(
|
||||||
|
float lineTop,
|
||||||
|
float lineHeight)
|
||||||
|
{
|
||||||
|
Assert.False(UiText.LineIntersectsViewport(
|
||||||
|
lineTop,
|
||||||
|
lineHeight,
|
||||||
|
viewportTop: 0f,
|
||||||
|
viewportBottom: 15f));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue