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