game->start(StairsScene::class, ['seed' => 3]); $this->game->run(30); for ($i = 0; $i < 40; $i++) { $this->advance(15); self::assertEqualsWithDelta(StairsScene::BASE, $this->game->peek('screenSize'), 0.01); } self::assertGreaterThanOrEqual(8, $this->game->peek('hops')); self::assertGreaterThanOrEqual(StairsScene::AHEAD, $this->game->scene()->stepCount(), 'new steps are made ahead'); $this->game->shot('stairs'); } public function testTheStairsGoOnForeverWithoutTheNumbersGrowing(): void { $this->game->start(StairsScene::class, ['seed' => 3]); $this->advance(60 * 40); self::assertGreaterThan(50, $this->game->peek('hops')); self::assertLessThan(1.0e5, $this->game->scene()->extent()); } public function testEachLandingRingsALowerNote(): void { $this->game->start(StairsScene::class, ['seed' => 3]); $this->advance(60 * 4); $notes = $this->recorder()->notes(); self::assertGreaterThan(4, count($notes)); self::assertGreaterThan($notes[3]['freq'], $notes[0]['freq']); } public function testTheTunnelNotesRepeatEveryPeriodSoItLoops(): void { $this->game->start(TunnelScene::class, ['seed' => 3]); $this->game->run(20); $this->advance((int)(60 * TunnelScene::PERIOD * 2)); $freqs = array_column($this->recorder()->notes(), 'freq'); $marimba = array_values(array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'marimba')); self::assertGreaterThanOrEqual(20, count($marimba)); for ($i = 0; $i < TunnelScene::RINGS; $i++) { self::assertSame($marimba[$i]['freq'], $marimba[$i + TunnelScene::RINGS]['freq']); } self::assertNotEmpty($freqs); $this->game->shot('tunnel'); } public function testTheSpiralPlaysEightNotesAHalfTurn(): void { $this->game->start(SpiralScene::class, ['seed' => 3]); $this->advance((int)(60 * SpiralScene::PERIOD) - 2); $bells = array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'bell'); self::assertCount(SpiralScene::ARMS * 2, $bells); } public function testTheGridAndTheDotsBeatOnTime(): void { $this->game->start(WaveGridScene::class, ['seed' => 3]); $this->advance((int)(60 * WaveGridScene::PERIOD) - 2); $marimba = array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'marimba'); self::assertCount(WaveGridScene::BEATS, $marimba); $this->game->start(DotRippleScene::class, ['seed' => 3]); $this->advance((int)(60 * DotRippleScene::PERIOD) - 2); $bells = array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'bell'); self::assertCount(4, $bells); } public function testTheDominoesFallOneAfterTheOtherThenStandUpAgain(): void { $this->game->start(DominoScene::class, ['seed' => 3]); $this->game->run(20); $this->advance(60 * 3); $half = $this->game->scene()->fallenCount(); self::assertGreaterThan(10, $half); self::assertLessThan(DominoScene::COUNT, $half); $this->advance(60 * 3 - 25); self::assertSame(DominoScene::COUNT, $this->game->scene()->fallenCount(), 'all down at the middle of the cycle'); $falls = array_values(array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'marimba')); self::assertGreaterThan($falls[0]['freq'], $falls[count($falls) - 1]['freq'], 'each note is higher than the one before'); $this->advance(60 * 6 - 5); self::assertSame(0, $this->game->scene()->fallenCount(), 'and standing again at the end'); $this->game->shot('dominos'); } public function testTheCatsMeowAtEveryBounce(): void { $this->game->start(CatBounceScene::class, ['seed' => 3]); $this->game->run(20); $this->advance(60 * 6); self::assertGreaterThan(4, $this->game->scene()->meows()); $meows = array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'meow'); self::assertCount($this->game->scene()->meows(), $meows); $times = array_column(array_values($meows), 'time'); for ($i = 1; $i < count($times); $i++) { self::assertGreaterThanOrEqual(0.19, $times[$i] - $times[$i - 1], 'two meows never run into each other'); } self::assertSame(CatBounceScene::CATS, count($this->game->peek('world')->balls())); $this->game->shot('cats'); } public function testTheCatsShowUpOneByOne(): void { $this->game->start(CatBounceScene::class, ['seed' => 3]); $seen = [$this->game->scene()->visibleCats()]; foreach ([1.0, 1.0, 1.0, 1.0] as $s) { $this->advance((int)(60 * $s)); $seen[] = $this->game->scene()->visibleCats(); } self::assertSame(1, $seen[0], 'one cat at the very first frame'); self::assertSame([1, 2, 3, 4, 4], $seen, 'then one more about every 0.9 s, and no more than CATS'); $hello = array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'meow' && $n['time'] < 3.0); self::assertGreaterThanOrEqual(3, count($hello), 'each newcomer says hello'); $this->game->scene()->update(1 / 60); self::assertCount(4, $this->game->peek('world')->balls()); } public function testAMeowIsAGlideInPitchNotAFlatTone(): void { $samples = Voice::render('meow', 440.0, 0.5, 0.3); self::assertCount((int)round(0.5 * Voice::RATE), $samples); $peak = max(array_map('abs', $samples)); self::assertGreaterThan(0.05, $peak); self::assertLessThan(1.0, $peak); $zero = function (array $s): int { $n = 0; for ($i = 1; $i < count($s); $i++) { $n += ($s[$i - 1] < 0) !== ($s[$i] < 0) ? 1 : 0; } return $n; }; $early = $zero(array_slice($samples, 1000, 4000)); $late = $zero(array_slice($samples, 14000, 4000)); self::assertNotSame($early, $late, 'the pitch moves'); self::assertCount((int)round(0.4 * Voice::RATE), Voice::render('purr', 50.0, 0.4, 0.2)); } public function testTheRainFallsSlowlyAndEachDropIsRoundAndSoft(): void { $this->game->start(RainPondScene::class, ['seed' => 3]); $this->game->run(20); $this->advance(60 * 6); $drops = array_filter($this->recorder()->notes(), fn (array $n) => $n['wave'] === 'drop'); self::assertGreaterThan(4, count($drops)); self::assertLessThan(14, count($drops)); self::assertSame(count($drops), $this->game->scene()->dropCount()); $this->game->shot('rain'); } public function testThePendulumsLineUpAgainAfterOneCycle(): void { for ($i = 0; $i < PendulumScene::COUNT; $i++) { self::assertEqualsWithDelta(PendulumScene::angle($i, 0.0), PendulumScene::angle($i, PendulumScene::PERIOD), 1e-9); self::assertEqualsWithDelta(0.27, PendulumScene::angle($i, 0.0), 1e-9); } $this->game->start(PendulumScene::class, ['seed' => 3]); $this->game->run(5); $notes = $this->recorder()->notes(); self::assertCount(PendulumScene::COUNT, $notes, 'they start as one chord'); self::assertCount(PendulumScene::COUNT, array_unique(array_column($notes, 'freq')), 'each pendulum has its own note'); $this->advance(60 * 4); self::assertGreaterThan(PendulumScene::COUNT * 2, count($this->recorder()->notes())); $this->game->shot('pendulum'); } public function testBallsFallThroughThePegsAndEachPegPlaysANote(): void { $this->game->start(PlinkoScene::class, ['seed' => 3]); $this->game->run(20); $this->advance(60 * 7); self::assertGreaterThan(20, count($this->recorder()->notes())); self::assertGreaterThan(0, $this->game->scene()->landed()); self::assertLessThanOrEqual(PlinkoScene::MAX_ALIVE, count($this->game->peek('world')->balls())); $this->game->shot('plinko'); } public function testEachBallScoresTheValueOfItsBinAndTheScoreIsShown(): void { $this->game->start(PlinkoScene::class, ['seed' => 3]); $this->advance(60 * 9); $scene = $this->game->scene(); $bins = $this->game->peek('bins'); self::assertNotEmpty($bins); self::assertSame(array_sum(array_map(fn (int $b): int => PlinkoScene::VALUES[$b], $bins)), $scene->score()); self::assertGreaterThan(0, $scene->score()); $this->game->run(1); self::assertTrue($scene->watermarkDrawn); $this->game->shot('plinko-score'); } public function testTheScreenStaysOnWhileADemoPlays(): void { $this->game->start(GapCircleScene::class, ['seed' => 3]); self::assertTrue($this->game->engine->device()->isKeptAwake()); } public function testTheWatermarkIsDrawnInEveryScene(): void { foreach (['Zoom\ZoomRings', 'GapCircle', 'Concentric', 'Multiply', 'Marble', 'Plinko', 'Domino', 'Pendulum', 'Stairs', 'Tunnel', 'Spiral', 'WaveGrid', 'DotRipple', 'CatBounce', 'RainPond'] as $n) { $class = 'App\\Scene\\' . str_replace('Zoom\\', '', $n) . 'Scene'; $this->game->start($class, ['seed' => 3]); $this->game->run(2); self::assertTrue($this->game->scene()->watermarkDrawn, $class); } } public function testTheTextsAreInFrenchWhenAskedAndEverySceneHasAFrenchHeading(): void { $this->game->start(GapCircleScene::class, ['seed' => 3, 'lang' => 'fr']); self::assertSame('PEUT-ELLE TROUVER LA SORTIE ?', $this->game->peek('heading')); self::assertStringStartsWith('REBONDS', $this->game->peek('caption')); foreach (FilmScene::TEMPLATES as $class) { $this->game->start($class, ['seed' => 3, 'lang' => 'fr']); self::assertNotSame(strtoupper($this->game->peek('heading')), '', $class); $heading = $this->game->peek('heading'); $en = (new \ReflectionMethod($class, 'headingText')); self::assertNotSame($en->invoke($this->game->scene()), $heading, "$class has a French heading"); } $this->game->start(GapCircleScene::class, ['seed' => 3, 'lang' => 'en']); self::assertSame('CAN IT FIND THE EXIT?', $this->game->peek('heading')); } public function testTheSoundIsSoftNormalisedAndFadesOut(): void { $wav = Mixer::mix([['time' => 0.2, 'freq' => 440.0, 'wave' => 'bell', 'length' => 1.0, 'volume' => 0.5], ['time' => 0.4, 'freq' => 330.0, 'wave' => 'meow', 'length' => 0.5, 'volume' => 0.3]], 3.0, 0.7); $samples = array_values(unpack('v*', substr($wav, 44))); $signed = array_map(fn (int $s) => $s > 32767 ? $s - 65536 : $s, $samples); $peak = max(array_map('abs', $signed)) / 32767; self::assertGreaterThan(0.6, $peak); self::assertLessThan(0.95, $peak, 'the same level for every video, no clipping'); $tail = array_slice($signed, -200); self::assertLessThan(200, max(array_map('abs', $tail)), 'it fades out to silence'); } /** Plays $frames frames of the scene without drawing them (a long simulation: the film of the tests stays short). */ public function testEscapeAndTheMenuButtonBringBackTheMenu(): void { $this->game->start(CatBounceScene::class, ['seed' => 3]); $this->game->run(30); $this->game->press('back'); $this->game->run(40); self::assertInstanceOf(TitleScene::class, $this->game->scene(), 'Escape'); foreach ([CatBounceScene::class, GapCircleScene::class, RainPondScene::class] as $class) { $this->game->start($class, ['seed' => 3]); $this->game->run(10); $scene = $this->game->scene(); $mouse = $this->game->peek('engine', $scene)->input->getMouse(); $mouse->move(30.0, 15.0); $mouse->down(1); $scene->update(1 / 60); $mouse->up(1); $this->game->run(40); self::assertInstanceOf(TitleScene::class, $this->game->scene(), "$class: the MENU button"); } } public function testATapOutsideTheButtonStillRestartsTheScene(): void { $this->game->start(GapCircleScene::class, ['seed' => 3]); $this->game->run(10); $mouse = $this->game->peek('engine', $this->game->scene())->input->getMouse(); $mouse->move(135.0, 300.0); $mouse->down(1); $this->game->scene()->update(1 / 60); $mouse->up(1); self::assertInstanceOf(GapCircleScene::class, $this->game->scene()); } private function advance(int $frames): void { $scene = $this->game->scene(); for ($i = 0; $i < $frames; $i++) { $scene->update(1 / 60); } } private function recorder(): Recorder { return $this->game->peek('recorder'); } }