ArenaView carried its own copy of the same five transforms. It differs from the
game view in exactly two respects: the arena is a fixed world shown whole, so
the tile size is the tighter of the two axis fits rather than the height fit,
and there is no scrolling, so the left edge is always 0.
That is small enough to absorb into WorldCoordinates as a second named factory.
The raw constructor becomes private and both call sites go through
scrolling(...) or fitToWorld(...), which also reads better than three
positional ints at the call site.
ArenaView is threaded the same way GameWorldView was: paintGL builds one
snapshot, every draw takes a const WorldCoordinates&, and mousePressEvent takes
its own.
ArenaView::widgetToWorld guarded against a near-zero tile size; that guard now
lives in WorldCoordinates as a tilePx fallback to 1.0, alongside the existing
degenerate-world-size fallback, so both factories are total and no conversion
can divide by zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
The nine coordinate helpers on GameWorldView were the first seam of the
planned decomposition: stateless math that every draw method, the placement
validation, and the belt-drag code reached into private state to get at.
WorldCoordinates is an immutable value built from the viewport size, the world
height, and the scroll center. It lives in lib/core rather than ui, following
BeltDragPath and TunnelCompletion — UI-only helpers kept there so Catch2 can
reach them. That is what lets the transform math be unit-tested at all, since
the test target links lib only.
Rather than leave delegating one-liners behind, the snapshot is threaded
through the call graph: paintGL builds one per frame, every world-space draw
takes a const WorldCoordinates&, and the screen-space draws (vignettes, replay
overlay, debug text) take none. Those methods no longer touch m_scrollXTiles,
width(), or height(), which is most of the groundwork for extracting
WorldRenderer later. The mouse handlers each take their own snapshot.
The snapshot is deliberately not cached in a member: a resize or a scroll
would silently invalidate it, and a stale transform surfaces as misaligned
hit-testing rather than as a visible failure.
widgetToTile and widgetToWorld had the same arithmetic inlined separately;
the former now goes through the latter.
The architecture doc's coordinate section was already stale — it described a
painter.translate approach and a hardcoded tilePx = 20, neither of which was
true — so it is rewritten to match the code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K