From f6df95abb2f2a77ad0f7024ca34130358bba2a5e Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Wed, 5 Aug 2026 19:48:06 +0200 Subject: [PATCH] extract the scroll position into WorldCamera --- docs/architecture.md | 3 +- src/lib/core/CMakeLists.txt | 2 + src/lib/core/WorldCamera.cpp | 72 +++++++++++ src/lib/core/WorldCamera.h | 72 +++++++++++ src/test/CMakeLists.txt | 1 + src/test/WorldCameraTest.cpp | 243 +++++++++++++++++++++++++++++++++++ src/ui/GameWorldView.cpp | 71 +++------- src/ui/GameWorldView.h | 17 ++- 8 files changed, 423 insertions(+), 58 deletions(-) create mode 100644 src/lib/core/WorldCamera.cpp create mode 100644 src/lib/core/WorldCamera.h create mode 100644 src/test/WorldCameraTest.cpp diff --git a/docs/architecture.md b/docs/architecture.md index 4aa0fce..6b611a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -357,7 +357,8 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly ### Coordinates and Scrolling -- `GameWorldView` holds a continuous `scrollXTiles` (float), the world X at the *center* of the viewport. A / D input pans this smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). +- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform. +- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed. - The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not. - `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it. - Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation. diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index e7fea30..a081b01 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -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 ) diff --git a/src/lib/core/WorldCamera.cpp b/src/lib/core/WorldCamera.cpp new file mode 100644 index 0000000..73969c1 --- /dev/null +++ b/src/lib/core/WorldCamera.cpp @@ -0,0 +1,72 @@ +#include "WorldCamera.h" + +#include + +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(®ions) + , 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(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(m_scroll->panSpeedSlow_tps); + const float fast = static_cast(m_scroll->panSpeedFast_tps); + const float half = static_cast(m_scroll->panRampBandWidth_tiles) / 2.0f; + const float leftEdge = static_cast(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); +} diff --git a/src/lib/core/WorldCamera.h b/src/lib/core/WorldCamera.h new file mode 100644 index 0000000..79edc3e --- /dev/null +++ b/src/lib/core/WorldCamera.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +#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; +}; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index e812447..b3771a2 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -13,6 +13,7 @@ add_files( BeltDragPathTest.cpp TunnelCompletionTest.cpp WorldCoordinatesTest.cpp + WorldCameraTest.cpp BuildingTest.cpp BuildingConfigTest.cpp ShipTest.cpp diff --git a/src/test/WorldCameraTest.cpp b/src/test/WorldCameraTest.cpp new file mode 100644 index 0000000..7b38a25 --- /dev/null +++ b/src/test/WorldCameraTest.cpp @@ -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)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index acf78b8..bac5081 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -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(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(m_config->world.scroll.panSpeedSlow_tps); - const float fast = static_cast(m_config->world.scroll.panSpeedFast_tps); - const float half = static_cast(m_config->world.scroll.panRampBandWidth_tiles) / 2.0f; - const float leftEdge = static_cast(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; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 45523f3..33d7eb7 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -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 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;