Second seam of the GameWorldView decomposition: the view no longer owns a scroll
position, only the pan intent and the bounds.
WorldCamera works purely in world units — tiles and tiles per second, never
pixels. That is what keeps it independent of WorldCoordinates: the two meet only
where GameWorldView feeds getViewCenterXTiles() into the transform, and neither
knows the other exists.
Two things are passed in rather than reached for, both so the camera stays a
plain value with no simulation dependency:
- ScrollBounds, because the pan limits move with asteroid expansion and with
pushes. The camera clamps on every advance(), not only when panning, so the
view follows the bounds inward when they shrink.
- PanDirection, because pan intent is not the camera's business. Today
GameWorldView collapses its two held-key flags into it; if controls become
rebindable the camera's interface does not change.
The config structs are referenced, not copied: they live inside the Simulation's
GameConfig, which is assigned in place on restart (REQ-CFG-RELOAD), so reloaded
scroll tuning takes effect without rebuilding the camera. A test pins that.
The pan-speed curve (REQ-UI-SCROLL-SPEED) had no coverage at all and is the
least obvious code in the file — two ramps combined by min, with a peak below
the fast speed where the bands overlap in a narrow contest zone, and a hard step
when the band width is zero. All of that is now tested.
Behaviour is unchanged, including the cases worth naming: holding both keys
still cancels out, and the moved/not-moved result that drives the box-select
refresh still counts movement caused purely by the bounds changing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
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