PL
games/satisfying-bounce / src / Scene / CatBounceScene.php · commit 8bdd6e4
<?phpnamespace App\Scene;use App\Audio\Scale;use App\Render\Gfx;use JumPHP\Physics2d\Ball;use JumPHP\Physics2d\Wall;use JumPHP\Renderer\Renderer;/** * Three round cats bounce in a box and meow at every bounce: the meows are synthesized (a glide in pitch through moving vowel formants, no * recording), each cat has its own pitch from a pentatonic scale so they sing together, and a purr rumbles underneath. The cat that * hits opens its mouth and squeezes its eyes. */final class CatBounceScene extends VideoScene{    public const SECONDS = 24.0;    public const CATS = 4;    /** seconds between two cats showing up, and how long the pop-in takes */    public const SPAWN_EVERY = 0.9;    private const POP = 0.45;    protected float $punch = 0.015;    private const BOX = [26.0, 150.0, 244.0, 410.0];    private const PITCH = [392.0, 523.25, 659.25, 783.99];    private float $nextPurr = 0.0;    private int $meows = 0;    private float $builtAt = 0.0;    private int $spawned = 0;    protected function melodyNotes(): string    {        return 'G4';    }    protected function headingText(): string    {        return 'CATS BOUNCE. CATS MEOW.';    }    protected function hookText(): string    {        return 'TURN THE SOUND ON';    }    protected function captionText(): string    {        return $this->t('MEOWS {n}', ['n' => $this->meows]);    }    public function meows(): int    {        return $this->meows;    }    protected function build(): void    {        $this->world->reset($this->seed + $this->round * 7919);        $this->world->gravity = 380;        $this->world->ballsCollide = true;        $this->world->maxSpeed = 700;        $this->meows = 0;        $this->nextPurr = 0.0;        [$x0, $y0, $x1, $y1] = self::BOX;        foreach ([[$x0, $y0, $x1, $y0], [$x1, $y0, $x1, $y1], [$x1, $y1, $x0, $y1], [$x0, $y1, $x0, $y0]] as [$a, $b, $c, $d]) {            $this->world->addWall(new Wall($a, $b, $c, $d, '#3b3f6e'));        }        $this->builtAt = $this->time;        $this->spawned = 0;        $this->spawnCat();    }    /** How many cats are in the box (they show up one by one). */    public function visibleCats(): int    {        return $this->spawned;    }    private function spawnCat(): void    {        $i = $this->spawned++;        $rand = $this->world->random;        $hues = [0.08, 0.58, 0.9, 0.3];        $radius = [20.0, 17.5, 15.0, 13.5];        $ball = new Ball(70 + ($i % 3) * 65 + ($i >= 3 ? 20 : 0), 200 + $rand->between(0, 40), $rand->between(-120, 120), $rand->between(-60, 0), $radius[$i], self::hue($hues[$i]), 40);        $ball->data['cat'] = $i;        $ball->data['born'] = $this->time;        $this->world->addBall($ball);        if ($i > 0) {                                                     // a soft hello (the first one is the film's very first frame)            if ($this->play(self::PITCH[$i], 'meow', 0.55 + 0.1 * min($i, 2), 0.2, 0.1)) {                $this->meows++;            }        }        $this->burst($ball->x, $ball->y, $ball->color, 6, 50);    }    protected function tick(float $dt): void    {        while ($this->spawned < self::CATS && $this->time >= $this->builtAt + $this->spawned * self::SPAWN_EVERY) {            $this->spawnCat();        }        if ($this->time >= $this->nextPurr) {            $this->nextPurr = $this->time + 4.6;            $this->recorder->note($this->time, 52.0, 5.2, 'purr', 0.16, false);        }        foreach ($this->world->balls() as $ball) {            $ball->data['open'] = max(0.0, (float)($ball->data['open'] ?? 0) - $dt * 3.2);        }    }    protected function onHit(array $hit): void    {        /** @var Ball $ball */        $ball = $hit['ball'];        if (($hit['type'] !== 'wall' && $hit['type'] !== 'ball') || $hit['speed'] < 70) {            return;        }        $cat = (int)($ball->data['cat'] ?? 0);        $ball->data['open'] = 1.0;        $this->squash($ball, (float)$hit['x'], (float)$hit['y']);        $this->burst((float)$hit['x'], (float)$hit['y'], $ball->color, 4, 60);        $bump = $hit['type'] === 'ball';        $freq = self::PITCH[$cat] * [1.0, 1.122, 1.26, 0.89][(int)($this->world->random->between(0, 3.99))] * ($bump ? 1.25 : 1.0);        if ($this->play($freq, 'meow', $bump ? 0.34 : 0.55 + 0.1 * $cat, $bump ? 0.2 : 0.3, 0.2)) {            $this->meows++;        }    }    protected function drawWorld(Renderer $r): void    {        [$x0, $y0, $x1, $y1] = self::BOX;        Gfx::bar($r, $x0, $y0, $x1, $y0, 3, [70, 76, 130]);        Gfx::bar($r, $x1, $y0, $x1, $y1, 3, [70, 76, 130]);        Gfx::bar($r, $x1, $y1, $x0, $y1, 3, [70, 76, 130]);        Gfx::bar($r, $x0, $y1, $x0, $y0, 3, [70, 76, 130]);        foreach ($this->world->balls() as $ball) {            $this->drawCat($r, $ball);        }    }    private function drawCat(Renderer $r, Ball $ball): void    {        $fur = Gfx::rgb($ball->color);        $x = $ball->x;        $y = $ball->y;        $age = ($this->time - (float)($ball->data['born'] ?? -9)) / self::POP;        $pop = $age >= 1.0 ? 1.0 : max(0.0, 1 + 2.7 * ($age - 1) ** 3 + 1.7 * ($age - 1) ** 2);   // ease out back: overshoots, settles        $rad = $ball->radius * $pop;        if ($rad < 0.5) {            return;        }        $open = (float)($ball->data['open'] ?? 0);        $sq = (float)($ball->data['sq'] ?? 0);        $dark = Gfx::mix($fur, [30, 20, 50], 0.45);        Gfx::glow($r, $x, $y, $rad, $fur, 0.7);        $tilt = max(-0.4, min(0.4, $ball->vx / 700));        foreach ([-1, 1] as $s) {                                         // ears            $ax = $x + $s * $rad * 0.95;            $ay = $y - $rad * 0.55;            Gfx::poly($r, [[$ax - $s * $rad * 0.55 + $tilt * 3, $ay + $rad * 0.2], [$ax + $s * $rad * 0.1 + $tilt * 6, $ay - $rad * 0.85], [$ax + $s * $rad * 0.05, $ay + $rad * 0.45]], $fur);            Gfx::poly($r, [[$ax - $s * $rad * 0.3 + $tilt * 3, $ay + $rad * 0.2], [$ax + $s * $rad * 0.02 + $tilt * 5, $ay - $rad * 0.5], [$ax, $ay + $rad * 0.35]], [255, 170, 190]);        }        Gfx::blob($r, $x, $y + $rad * 0.12 * $sq, $rad * (1 + 0.12 * $sq), $rad * (1 - 0.12 * $sq), 0.0, $fur);        foreach ([-1, 1] as $s) {                                         // eyes: round, or squeezed shut when it meows            $ex = $x + $s * $rad * 0.42;            $ey = $y - $rad * 0.12;            if ($open > 0.35) {                Gfx::bar($r, $ex - $rad * 0.2, $ey + $rad * 0.05, $ex, $ey - $rad * 0.12, max(0.8, $rad * 0.1), $dark);                Gfx::bar($r, $ex, $ey - $rad * 0.12, $ex + $rad * 0.2, $ey + $rad * 0.05, max(0.8, $rad * 0.1), $dark);            } else {                Gfx::blob($r, $ex, $ey, $rad * 0.2, $rad * 0.26, 0.0, [250, 245, 200]);                Gfx::blob($r, $ex, $ey, $rad * 0.07, $rad * 0.22, 0.0, [25, 20, 40]);            }        }        Gfx::poly($r, [[$x - $rad * 0.1, $y + $rad * 0.12], [$x + $rad * 0.1, $y + $rad * 0.12], [$x, $y + $rad * 0.24]], [255, 140, 170]);        if ($open > 0.1) {                                                // the mouth            Gfx::blob($r, $x, $y + $rad * 0.5, $rad * 0.2 * (0.6 + $open), $rad * 0.26 * $open, 0.0, [90, 25, 50]);        }        foreach ([-1, 1] as $s) {                                         // whiskers            for ($k = -1; $k <= 1; $k++) {                Gfx::bar($r, $x + $s * $rad * 0.45, $y + $rad * (0.25 + $k * 0.08), $x + $s * $rad * 1.15, $y + $rad * (0.25 + $k * 0.3), 0.5, [245, 245, 255]);            }        }    }}