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

@@ -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));
}