extract the scroll position into WorldCamera
This commit is contained in:
@@ -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<X>` 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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
72
src/lib/core/WorldCamera.cpp
Normal file
72
src/lib/core/WorldCamera.cpp
Normal 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(®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<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);
|
||||
}
|
||||
72
src/lib/core/WorldCamera.h
Normal file
72
src/lib/core/WorldCamera.h
Normal 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;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ add_files(
|
||||
BeltDragPathTest.cpp
|
||||
TunnelCompletionTest.cpp
|
||||
WorldCoordinatesTest.cpp
|
||||
WorldCameraTest.cpp
|
||||
BuildingTest.cpp
|
||||
BuildingConfigTest.cpp
|
||||
ShipTest.cpp
|
||||
|
||||
243
src/test/WorldCameraTest.cpp
Normal file
243
src/test/WorldCameraTest.cpp
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user