PL
games/satisfying-bounce / tests / ScenesTest.php · commit 8bdd6e4
<?phpnamespace Tests;use App\Scene\CatBounceScene;use App\Scene\DominoScene;use App\Scene\DotRippleScene;use App\Scene\FilmScene;use App\Scene\GapCircleScene;use App\Scene\PendulumScene;use App\Scene\PlinkoScene;use App\Scene\RainPondScene;use App\Scene\SpiralScene;use App\Scene\StairsScene;use App\Scene\TitleScene;use App\Scene\TunnelScene;use App\Scene\WaveGridScene;use JumPHP\Shorts\Mixer;use JumPHP\Shorts\Recorder;use JumPHP\Shorts\Voice;use JumPHP\Testing\GameTestCase;/** The other scenes: illusions, dominoes, cats, rain, pendulums, pegs; the sound; the French texts. */final class ScenesTest extends GameTestCase{    public function testTheGrowingShapeKeepsTheSameSizeOnScreenWhileItsWorldGrows(): void    {        $this->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');    }}