extract the scroll position into WorldCamera

This commit is contained in:
2026-08-05 19:48:06 +02:00
parent fa9dbd62ad
commit f6df95abb2
8 changed files with 423 additions and 58 deletions

View File

@@ -15,6 +15,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
PARENT_SCOPE
)
@@ -27,6 +28,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
PARENT_SCOPE
)

View File

@@ -0,0 +1,72 @@
#include "WorldCamera.h"
#include <algorithm>
namespace
{
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
{
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
return valueAt0 + (valueAt1 - valueAt0) * t;
}
}
WorldCamera::WorldCamera(const WorldScroll& scroll, const WorldRegions& regions)
: m_scroll(&scroll)
, m_regions(&regions)
, m_viewCenterXTiles(0.0f)
{
}
bool WorldCamera::advance(PanDirection direction, qint64 elapsedMs,
ScrollBounds bounds)
{
const float before = m_viewCenterXTiles;
if (direction != PanDirection::None)
{
const float distance =
getPanSpeedTilesPerSecondAt(m_viewCenterXTiles, bounds.rightTiles)
* static_cast<float>(elapsedMs) / 1000.0f;
m_viewCenterXTiles += (direction == PanDirection::Left) ? -distance : distance;
}
m_viewCenterXTiles = std::max(bounds.leftTiles,
std::min(m_viewCenterXTiles, bounds.rightTiles));
return m_viewCenterXTiles != before;
}
float WorldCamera::getViewCenterXTiles() const
{
return m_viewCenterXTiles;
}
void WorldCamera::reset()
{
m_viewCenterXTiles = 0.0f;
}
float WorldCamera::getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const
{
// Slow near the asteroid/player buffer, fast across the contest zone, with a
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
// contest zone spans from the player buffer's right edge to the enemy stations,
// the latter tracked live so the ramp follows the front line as it is pushed.
const float slow = static_cast<float>(m_scroll->panSpeedSlow_tps);
const float fast = static_cast<float>(m_scroll->panSpeedFast_tps);
const float half = static_cast<float>(m_scroll->panRampBandWidth_tiles) / 2.0f;
const float leftEdge = static_cast<float>(m_regions->playerBufferWidth_tiles);
const float rightEdge = contestZoneRightEdgeTiles;
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
// below the fast speed where the two ramps cross.
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
return std::min(leftRamp, rightRamp);
}

View File

@@ -0,0 +1,72 @@
#pragma once
#include <QtGlobal>
#include "WorldConfig.h"
// Which way the player is currently panning the view (REQ-UI-SCROLL). A plain
// direction rather than key state: the camera is deliberately agnostic about how
// the intent was expressed, so rebindable controls would change nothing here.
enum class PanDirection
{
None,
Left,
Right
};
// The horizontal limits of the view center, in world tiles (REQ-GW-SCROLL-LIMIT):
// the view can pan left until the asteroid's left edge is centered and right until
// the enemy stations are. Both move as the game progresses — the left edge with
// asteroid expansion (REQ-GW-ASTEROID-EXPAND), the right edge as stations are
// pushed back (REQ-GW-PUSH-EXPAND) — so they are supplied per frame by the caller
// that can see the simulation, rather than queried here. That keeps the camera a
// value with no simulation dependency.
struct ScrollBounds
{
float leftTiles;
float rightTiles;
};
// Horizontal view position for the game world (REQ-UI-SCROLL, REQ-UI-SCROLL-SPEED).
// Works purely in world units — tiles and tiles per second, never pixels. Turning
// the resulting position into a widget transform is WorldCoordinates' job; the two
// meet only where the view feeds getViewCenterXTiles() into that transform.
class WorldCamera
{
public:
// Both config structs are referenced rather than copied: they live inside the
// Simulation's GameConfig, which is assigned in place on restart
// (REQ-CFG-RELOAD), so a camera built once still picks up reloaded tuning.
WorldCamera(const WorldScroll& scroll, const WorldRegions& regions);
// Pans by `direction` for `elapsedMs` of wall-clock time, then clamps into
// `bounds`. Wall clock rather than ticks because panning is presentation only
// (REQ-UI-NO-ZOOM's sibling concern) and keeps working while the simulation is
// paused. Clamping happens on every call, not just when panning, so the view
// follows the bounds inward when they shrink.
//
// Returns true when the view center actually moved — including when it moved
// only because the bounds did. Callers use that to refresh anything anchored to
// the world under a stationary cursor, such as the box-select rectangle.
bool advance(PanDirection direction, qint64 elapsedMs, ScrollBounds bounds);
// World X (tiles) at the center of the viewport.
float getViewCenterXTiles() const;
// Returns the view to the start-of-run position. Deliberately does not clamp:
// a new run's bounds are not known here, and the next advance() clamps anyway.
void reset();
// Pan speed at a given view center, in tiles/s (REQ-UI-SCROLL-SPEED). Public
// because the ramp shape is the subtle part of this class and is worth testing
// directly; advance() uses it internally. `contestZoneRightEdgeTiles` is the
// live right-hand boundary — the same value as ScrollBounds::rightTiles — so
// the ramp follows the front line as it is pushed.
float getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const;
private:
const WorldScroll* m_scroll;
const WorldRegions* m_regions;
float m_viewCenterXTiles;
};

View File

@@ -13,6 +13,7 @@ add_files(
BeltDragPathTest.cpp
TunnelCompletionTest.cpp
WorldCoordinatesTest.cpp
WorldCameraTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -0,0 +1,243 @@
#include "catch.hpp"
#include "WorldCamera.h"
#include "WorldConfig.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Slow 10 tiles/s, fast 50 tiles/s, and a 4-tile ramp band — so each ramp spans
// 2 tiles either side of a contest-zone boundary and the arithmetic stays exact.
static WorldScroll makeScroll(int rampBandWidth_tiles = 4)
{
WorldScroll scroll;
scroll.panSpeedSlow_tps = 10.0;
scroll.panSpeedFast_tps = 50.0;
scroll.panRampBandWidth_tiles = rampBandWidth_tiles;
return scroll;
}
// The player buffer's right edge — the contest zone's left boundary — sits at 20.
static WorldRegions makeRegions()
{
WorldRegions regions;
regions.asteroidWidth_tiles = 10;
regions.playerBufferWidth_tiles = 20;
regions.contestZoneWidth_tiles = 60;
regions.enemyBufferWidth_tiles = 10;
return regions;
}
static ScrollBounds makeBounds(float leftTiles = -100.0f, float rightTiles = 100.0f)
{
return ScrollBounds{leftTiles, rightTiles};
}
// ---------------------------------------------------------------------------
// Pan speed ramp (REQ-UI-SCROLL-SPEED)
// ---------------------------------------------------------------------------
TEST_CASE("Pan speed is slow over the asteroid and player buffer", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Left of the rising ramp band (which starts at 20 - 2 = 18).
REQUIRE(camera.getPanSpeedTilesPerSecondAt(-50.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(18.0f, 80.0f) == Approx(10.0f));
}
TEST_CASE("Pan speed is fast across the middle of the contest zone", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Between the two ramp bands: past 20 + 2 and before 80 - 2.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(22.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(78.0f, 80.0f) == Approx(50.0f));
}
TEST_CASE("Pan speed ramps linearly across each contest-zone boundary", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Rising band spans [18, 22]; the boundary itself is the midpoint.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 80.0f) == Approx(30.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.0f, 80.0f) == Approx(20.0f));
// Falling band spans [78, 82], mirrored.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.0f, 80.0f) == Approx(30.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(81.0f, 80.0f) == Approx(20.0f));
}
TEST_CASE("Pan speed returns to slow past the enemy stations", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(82.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(200.0f, 80.0f) == Approx(10.0f));
}
TEST_CASE("The ramp follows the front line as it is pushed", "[camera]")
{
// The right boundary is passed in per call, so a push that moves the enemy
// stations moves the falling ramp with it (REQ-GW-PUSH-EXPAND).
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// X = 90 is past the old boundary (slow) but well inside the pushed-back one.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 140.0f) == Approx(50.0f));
}
TEST_CASE("Overlapping ramp bands peak below the fast speed", "[camera]")
{
// A contest zone narrower than the ramp band never reaches full speed: the two
// ramps cross before either tops out, leaving a single peak where they meet.
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 40);
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Bands are [0, 40] rising and [10, 50] falling; they cross at x = 25.
const float peak = camera.getPanSpeedTilesPerSecondAt(25.0f, 30.0f);
REQUIRE(peak > 10.0f);
REQUIRE(peak < 50.0f);
// And it really is the maximum — the neighbours on both sides are lower.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 30.0f) < peak);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(30.0f, 30.0f) < peak);
}
TEST_CASE("A zero-width ramp band steps straight from slow to fast", "[camera]")
{
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 0);
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.99f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.01f, 80.0f) == Approx(10.0f));
}
// ---------------------------------------------------------------------------
// Panning
// ---------------------------------------------------------------------------
TEST_CASE("The camera starts centered on the world origin", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("Panning moves the view center at the local pan speed", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
// Starting at 0, the local speed is the slow one: 10 tiles/s for 500 ms.
REQUIRE(camera.advance(PanDirection::Right, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(5.0f));
REQUIRE(camera.advance(PanDirection::Left, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("Not panning leaves the view center alone", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 500, makeBounds());
const float before = camera.getViewCenterXTiles();
REQUIRE_FALSE(camera.advance(PanDirection::None, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(before));
}
// ---------------------------------------------------------------------------
// Clamping (REQ-GW-SCROLL-LIMIT)
// ---------------------------------------------------------------------------
TEST_CASE("Panning stops at the scroll bounds", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
// Far more time than it takes to cross the bound.
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(12.0f));
camera.advance(PanDirection::Left, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(-5.0f));
}
TEST_CASE("Shrinking bounds pull the view in even without panning", "[camera]")
{
// Bounds move as the game progresses, so clamping cannot wait for the player
// to press a key — a view left outside them would show unreachable world.
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 100000, makeBounds(-50.0f, 40.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(40.0f));
// The right bound comes in; the camera must follow it despite no pan input,
// and must report that it moved.
REQUIRE(camera.advance(PanDirection::None, 16, makeBounds(-50.0f, 25.0f)));
REQUIRE(camera.getViewCenterXTiles() == Approx(25.0f));
}
TEST_CASE("Panning into a bound the view already sits on reports no movement",
"[camera]")
{
// The caller refreshes the box-select rectangle on a true return, so a camera
// pinned against its limit must not keep claiming to have moved.
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE_FALSE(camera.advance(PanDirection::Right, 16, makeBounds(-5.0f, 12.0f)));
}
TEST_CASE("Reset returns the view to the origin", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 2000, makeBounds());
REQUIRE(camera.getViewCenterXTiles() != Approx(0.0f));
camera.reset();
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("The camera tracks config edited after construction", "[camera]")
{
// The camera references the config rather than copying it, so a restart that
// reloads world.toml in place (REQ-CFG-RELOAD) changes the pan speed without
// the camera being rebuilt.
WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
scroll.panSpeedSlow_tps = 3.0;
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(3.0f));
}

View File

@@ -199,7 +199,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_commandManager(*sim)
, m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0)
, m_scrollXTiles(0.0f)
, m_camera(config->world.scroll, config->world.regions)
, m_ghostRotation(Rotation::East)
, m_ghostValid(false)
, m_dragging(false)
@@ -366,18 +366,19 @@ void GameWorldView::onFrame()
// Apply held scroll
{
// Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED).
const float viewCenterX = m_scrollXTiles;
const float delta = panSpeedTilesPerSecondAt(viewCenterX)
* static_cast<float>(elapsed) / 1000.0f;
const float scrollBefore = m_scrollXTiles;
if (m_scrollLeft) { m_scrollXTiles -= delta; }
if (m_scrollRight) { m_scrollXTiles += delta; }
clampScroll();
// Holding both keys cancels out, as it did when each key moved the view
// independently.
PanDirection direction = PanDirection::None;
if (m_scrollLeft != m_scrollRight)
{
direction = m_scrollLeft ? PanDirection::Left : PanDirection::Right;
}
const bool viewMoved = m_camera.advance(direction, elapsed, getScrollBounds());
// While the view scrolls, the tile under a stationary cursor changes,
// so refresh the box-select rectangle even though no mouse move fires.
if (m_boxSelecting && m_scrollXTiles != scrollBefore)
if (m_boxSelecting && viewMoved)
{
m_boxCurrentTile =
getCoordinates().widgetToTile(mapFromGlobal(QCursor::pos()));
@@ -526,7 +527,7 @@ void GameWorldView::paintGL()
WorldCoordinates GameWorldView::getCoordinates() const
{
return WorldCoordinates::scrolling(size(), m_config->world.heightTiles,
m_scrollXTiles);
m_camera.getViewCenterXTiles());
}
float GameWorldView::getAsteroidLeftEdge() const
@@ -562,47 +563,13 @@ float GameWorldView::getEnemyStationRightEdge() const
return rightX;
}
namespace
ScrollBounds GameWorldView::getScrollBounds() const
{
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
{
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
return valueAt0 + (valueAt1 - valueAt0) * t;
}
}
float GameWorldView::panSpeedTilesPerSecondAt(float viewCenterXTiles) const
{
// Slow near the asteroid/player buffer, fast across the contest zone, with a
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
// contest zone spans from the player buffer's right edge to the enemy stations,
// the latter tracked live so the ramp follows the front line as it is pushed.
const float slow = static_cast<float>(m_config->world.scroll.panSpeedSlow_tps);
const float fast = static_cast<float>(m_config->world.scroll.panSpeedFast_tps);
const float half = static_cast<float>(m_config->world.scroll.panRampBandWidth_tiles) / 2.0f;
const float leftEdge = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles);
const float rightEdge = getEnemyStationRightEdge();
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
// below the fast speed where the two ramps cross.
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
return std::min(leftRamp, rightRamp);
}
void GameWorldView::clampScroll()
{
// m_scrollXTiles is the view center, so the pan limits are the edges themselves:
// the view can pan left until the buildable/asteroid edge is centered, and right
// until the enemy stations are centered (revealing a little space beyond them).
const float leftBound = getAsteroidLeftEdge();
const float rightBound = getEnemyStationRightEdge();
m_scrollXTiles = std::max(leftBound, std::min(m_scrollXTiles, rightBound));
// The camera clamps its view center to these, so the pan limits are the edges
// themselves: the view can pan left until the buildable/asteroid edge is
// centered, and right until the enemy stations are centered (revealing a little
// space beyond them).
return ScrollBounds{getAsteroidLeftEdge(), getEnemyStationRightEdge()};
}
// ---------------------------------------------------------------------------
@@ -3102,7 +3069,7 @@ void GameWorldView::resetForNewGame()
m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
m_boxSelecting = false;
m_scrollXTiles = 0.0f;
m_camera.reset();
m_scrollLeft = false;
m_scrollRight = false;
m_gameOverShown = false;

View File

@@ -49,6 +49,7 @@
#include "TickDriver.h"
#include "TunnelCompletion.h"
#include "VisualsConfig.h"
#include "WorldCamera.h"
#include "WorldCoordinates.h"
struct Command;
@@ -184,9 +185,9 @@ private:
float getAsteroidLeftEdge() const;
float getEnemyStationRightEdge() const;
// Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED).
float panSpeedTilesPerSecondAt(float viewCenterXTiles) const;
void clampScroll();
// The camera's current pan limits, read fresh from the simulation each frame:
// both edges move with asteroid expansion and with pushes (REQ-GW-SCROLL-LIMIT).
ScrollBounds getScrollBounds() const;
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
@@ -333,8 +334,9 @@ private:
std::mt19937 m_rng;
double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed;
// World-X (tiles) at the center of the viewport (see getViewLeftTiles()).
float m_scrollXTiles;
// Horizontal view position (REQ-UI-SCROLL). Owns the scroll position itself;
// this widget only supplies the pan intent and the simulation-derived bounds.
WorldCamera m_camera;
QTimer* m_renderTimer;
@@ -390,6 +392,11 @@ private:
QPoint m_boxStartTile;
QPoint m_boxCurrentTile;
// Held-key state for the A / D pan controls (REQ-UI-SCROLL, REQ-UI-HOTKEYS),
// collapsed into a PanDirection for the camera each frame. Kept here rather
// than on the camera because it is key state, not view state: once controls
// are rebindable this becomes an input mapper publishing the direction, and
// the camera's interface does not change.
bool m_scrollLeft;
bool m_scrollRight;
bool m_gameOverShown;