fix(ui): Campaign LA gate round 2 — character-select scales as one authored canvas

Third iteration on the screen, completing AD-98. The previous substitution
stretched only the root BACKGROUND while the child widgets stayed at their
authored 800x600 pixel positions - and the background painting carries
visual anchors (the World/Characters captions are art), so the user gate
showed captions overlapping the listbox and every widget misaligned
against the stretched art.

Retail model (established at 71bf24fb): fixed-canvas pre-world screens
render at authored 800x600 and the whole composed frame stretches once at
presentation; the blitter has no stretch mode. Our equivalent now does the
same one stage earlier:

- UiRoot.FixedCanvasSize: while the char-select screen is active, the
  retained tree lays out in its authored canvas and Draw scopes a uniform
  scale onto TextRenderer.CanvasScale; the mouse entry points apply the
  exact inverse so MouseX/MouseY and every hit test live in canvas space.
- TextRenderer.AppendQuad is the single emission chokepoint - sprites,
  rects, AND glyphs scale together, including retail-authentic non-uniform
  aspect distortion and stretched text. World-space HUD stays native (the
  scale resets outside UiRoot.Draw).
- CharacterManagementUiController stops resizing Root to the viewport;
  activate/deactivate/dispose set and clear the host canvas.
- UiDatElement returns to retail-pure copy-or-tile; the interim
  StretchOwnBackgroundToFill flag is deleted.
- AD-98 updated to describe the completed substitution.

Tests: canvas-scale quad math, inverse input mapping (window click lands
on the canvas-space widget), degenerate-size guards, controller keeps
authored extent + sets/clears the canvas. App suite 5085/6 skips; live-DAT
char-select probes 3/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 10:39:19 +02:00
parent 71bf24fb6f
commit 73041d7015
8 changed files with 277 additions and 106 deletions

View file

@ -0,0 +1,121 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign LA gate round 2 (register AD-98): retail renders fixed-canvas
/// pre-world screens (char select, authored 800×600) at authored size and
/// stretches the whole composed frame at presentation — its UI blitter has no
/// stretch mode. acdream's equivalent is <see cref="UiRoot.FixedCanvasSize"/>:
/// draw applies one uniform scale at the renderer's quad chokepoint, and the
/// mouse entry points apply the exact inverse so hit-testing lives in canvas
/// space. These tests pin the scale math and the inverse input mapping — the
/// half that, if wrong, makes the user click on art and hit nothing.
/// </summary>
public class UiRootFixedCanvasTests
{
[Fact]
public void CanvasScale_IsOne_WithoutFixedCanvas()
{
var root = new UiRoot { Width = 1920f, Height = 1080f };
Assert.Equal(Vector2.One, root.CanvasScale);
}
[Fact]
public void CanvasScale_IsWindowOverCanvas_WhenFixed()
{
var root = new UiRoot
{
Width = 1920f,
Height = 1080f,
FixedCanvasSize = new Vector2(800f, 600f),
};
Assert.Equal(new Vector2(2.4f, 1.8f), root.CanvasScale);
}
[Fact]
public void CanvasScale_IsOne_ForDegenerateCanvasOrWindow()
{
var zeroCanvas = new UiRoot
{
Width = 1920f,
Height = 1080f,
FixedCanvasSize = new Vector2(0f, 600f),
};
Assert.Equal(Vector2.One, zeroCanvas.CanvasScale);
var zeroWindow = new UiRoot
{
Width = 0f,
Height = 0f,
FixedCanvasSize = new Vector2(800f, 600f),
};
Assert.Equal(Vector2.One, zeroWindow.CanvasScale);
}
/// <summary>
/// The load-bearing inverse: a click at WINDOW coordinates must land on the
/// widget whose authored CANVAS rect the user visually clicked. The button
/// sits at canvas (300,400)+(120×40); at a 1920×1080 window over an 800×600
/// canvas it appears at window (720,720)-(1008,792). Clicking window
/// (860,750) — canvas (358,417) — must click it; clicking window (300,400)
/// — canvas (125,222), visually empty — must not.
/// </summary>
[Fact]
public void MouseInput_MapsWindowCoordsToCanvasSpace()
{
var root = new UiRoot
{
Width = 1920f,
Height = 1080f,
FixedCanvasSize = new Vector2(800f, 600f),
};
int clicks = 0;
var button = new UiButton(
new ElementInfo { Width = 120, Height = 40 },
_ => (0u, 0, 0))
{
Left = 300f,
Top = 400f,
Width = 120f,
Height = 40f,
OnClick = () => clicks++,
};
root.AddChild(button);
root.OnMouseDown(UiMouseButton.Left, 860, 750);
root.OnMouseUp(UiMouseButton.Left, 860, 750);
Assert.Equal(1, clicks);
Assert.Equal(358, root.MouseX);
Assert.Equal(417, root.MouseY);
root.OnMouseDown(UiMouseButton.Left, 300, 400);
root.OnMouseUp(UiMouseButton.Left, 300, 400);
Assert.Equal(1, clicks);
}
[Fact]
public void MouseInput_IsUntouched_WithoutFixedCanvas()
{
var root = new UiRoot { Width = 1920f, Height = 1080f };
int clicks = 0;
var button = new UiButton(
new ElementInfo { Width = 120, Height = 40 },
_ => (0u, 0, 0))
{
Left = 300f,
Top = 400f,
Width = 120f,
Height = 40f,
OnClick = () => clicks++,
};
root.AddChild(button);
root.OnMouseDown(UiMouseButton.Left, 360, 420);
root.OnMouseUp(UiMouseButton.Left, 360, 420);
Assert.Equal(1, clicks);
}
}