登录
ZH
games/satisfying-bounce / src / Scene / ZoomRingsScene.php · commit 8bdd6e4
<?phpnamespace App\Scene;use App\Audio\Scale;use App\Render\Gfx;use JumPHP\Physics2d\Ball;use JumPHP\Physics2d\Ring;use JumPHP\Renderer\Renderer;/** * Rings inside rings, each with an opening, turning at their own speed. The ball bounces until it finds the opening, escapes, the ring it * left bursts and the camera glides out (ZoomView, an eased zoom in log space) to the next one; new rings keep appearing outside, so it * never ends. The physics runs in world units and never knows about the camera: the ball's size, speed and gravity are kept the same ON * SCREEN by scaling them with the camera, and the world is rebased (everything multiplied by the zoom) whenever the camera rests, * so the numbers stay small however far it goes. Bounces play a pentatonic arpeggio, a passage plays a bell chord. */final class ZoomRingsScene extends VideoScene{    public const SECONDS = 28.0;    /** screen radius of the ring the ball is in, ratio between two rings, rings kept ahead, ball radius on screen */    public const R0 = 100.0;    public const RATIO = 1.34;    public const AHEAD = 5;    public const BALL = 5.5;    private const GRAVITY = 300.0;    private int $passed = 0;    private int $nextK = 0;    private int $hitsInRing = 0;    private float $lastScale = 1.0;    private float $zoomTotal = 1.0;    /** @var list<array{x: float, y: float, r: float, angle: float, gap: float, rgb: array, age: float, thick: float}> rings that burst */    private array $ghosts = [];    protected function melodyNotes(): string    {        return 'C4';    }    protected function headingText(): string    {        return 'ESCAPE. ZOOM. REPEAT.';    }    protected function hookText(): string    {        return 'HOW FAR CAN IT ESCAPE?';    }    protected function captionText(): string    {        return $this->t('RINGS {n}   ZOOM x{z}', ['n' => $this->passed, 'z' => $this->zoomLabel()]);    }    private function zoomLabel(): string    {        $z = $this->zoomTotal;        return $z < 1000 ? (string)(int)round($z) : (string)(int)round($z / 1000) . 'K';    }    public function passed(): int    {        return $this->passed;    }    protected function build(): void    {        $this->world->reset($this->seed + $this->round * 7919);        $this->world->restitution = 1.05;        $this->passed = 0;        $this->nextK = 0;        $this->hitsInRing = 0;        $this->lastScale = 1.0;        $this->zoomTotal = 1.0;        $this->ghosts = [];        $this->view->reset(1.0);        $rand = $this->world->random;        for ($i = 0; $i < self::AHEAD; $i++) {            $this->addRing(self::R0 * self::RATIO ** $i);        }        // the hook: the first opening is wide and the ball is thrown at it, so the first escape and zoom come within a second or two        $first = $this->world->rings()[0];        $a = $first->angle + $first->spin * 0.8;        $t = 0.8;        $this->world->addBall(new Ball(self::CX, self::CY, cos($a) * (self::R0 + 6) / $t, sin($a) * (self::R0 + 6) / $t - 0.5 * self::GRAVITY * $t, self::BALL, '#fff6d6', 150));        $this->world->gravity = self::GRAVITY;        $this->world->maxSpeed = 900;    }    private function addRing(float $radius): void    {        $rand = $this->world->random;        $k = $this->nextK++;        $ring = new Ring(self::CX, self::CY, $radius, gap: $k === 0 ? 1.6 : 1.45 - 0.03 * min(8, $k % 12), angle: $rand->between(0, 2 * M_PI),            spin: ($k % 2 ? -1 : 1) * (0.55 + 0.9 * $rand->between(0, 1)), openOnHit: 0.12, thickness: 3, color: self::hue($k * 0.071));        $ring->data['k'] = $k;        $this->world->addRing($ring);    }    protected function tick(float $dt): void    {        $s = $this->view->scale;        // the camera moved: the ball keeps its size and speed on screen, gravity too        foreach ($this->world->balls() as $ball) {            $ratio = $this->lastScale / $s;            $ball->vx *= $ratio;            $ball->vy *= $ratio;            $ball->radius = self::BALL / $s;        }        $this->lastScale = $s;        $this->world->gravity = self::GRAVITY / $s;        $this->world->maxSpeed = 900 / $s;        foreach ($this->ghosts as $i => &$g) {            $g['age'] += $dt * 2.0;        }        unset($g);        $this->ghosts = array_values(array_filter($this->ghosts, fn (array $g) => $g['age'] < 1));        if (!$this->view->isZooming() && abs($s - 1.0) > 1e-9) {            $this->rebase();        }    }    /** The camera rests at scale s: multiply the whole world by s and put the camera back at 1 (nothing changes on screen). */    private function rebase(): void    {        $f = $this->view->scale;        foreach ($this->world->rings() as $ring) {            $ring->radius *= $f;        }        foreach ($this->world->balls() as $ball) {            $ball->x = self::CX + ($ball->x - self::CX) * $f;            $ball->y = self::CY + ($ball->y - self::CY) * $f;            $ball->vx *= $f;            $ball->vy *= $f;            $ball->radius *= $f;            foreach ($ball->trail as $i => $p) {                $ball->trail[$i] = [self::CX + ($p[0] - self::CX) * $f, self::CY + ($p[1] - self::CY) * $f];            }        }        foreach ($this->ghosts as $i => $g) {            $this->ghosts[$i]['r'] *= $f;        }        $this->view->reset(1.0);        $this->lastScale = 1.0;    }    protected function onHit(array $hit): void    {        /** @var Ball $ball */        $ball = $hit['ball'];        /** @var Ring $ring */        $ring = $hit['body'];        $sx = $this->view->x((float)$hit['x']);        $sy = $this->view->y((float)$hit['y']);        if ($hit['type'] === 'escape') {            $this->passRing($ring);            return;        }        $this->bounces++;        $this->hitsInRing++;        $ring->data['flash'] = 1.0;        $this->squash($ball, (float)$hit['x'], (float)$hit['y']);        $this->burst($sx, $sy, $ring->color, 6, 80);        $this->play(Scale::freq(Scale::MAJOR_PENTA, 3 + ($this->passed + $this->hitsInRing) % 9), 'marimba', 0.45, 0.24, 0.07);    }    private function passRing(Ring $ring): void    {        $this->passed++;        $this->hitsInRing = 0;        $rgb = Gfx::rgb($ring->color);        $x = $this->view->x($ring->x);        $y = $this->view->y($ring->y);        $this->ghosts[] = ['x' => $ring->x, 'y' => $ring->y, 'r' => $ring->radius, 'angle' => $ring->angle, 'gap' => $ring->gap, 'rgb' => $rgb, 'age' => 0.0, 'thick' => 3.0];        $this->burstRing($x, $y, $this->view->len($ring->radius), $rgb, 30);        $this->world->removeRing($ring);        // the special note: a bell chord, a little higher each ring, then starting over        $d = 8 + ($this->passed * 2) % 8;        $this->recorder->note($this->time, Scale::freq(Scale::MAJOR_PENTA, $d), 1.3, 'bell', 0.26);        $this->recorder->note($this->time + 0.05, Scale::freq(Scale::MAJOR_PENTA, $d + 2), 1.3, 'bell', 0.18);        $this->lastNote = $this->time;        // new rings outside, so that there are always AHEAD of them        $last = max(array_map(fn (Ring $r) => $r->radius, $this->world->rings()));        while (count($this->world->rings()) < self::AHEAD) {            $last *= self::RATIO;            $this->addRing($last);        }        $next = min(array_map(fn (Ring $r) => $r->radius, $this->world->rings()));        $this->view->zoomTo(self::R0 / $next, 0.95);        $this->zoomTotal *= self::RATIO;    }    protected function drawWorld(Renderer $r): void    {        $bg = Gfx::BG_BOTTOM;        $near = null;        foreach ($this->world->rings() as $ring) {            $near = $near === null ? $ring->radius : min($near, $ring->radius);        }        foreach ($this->ghosts as $g) {            $k = $g['age'];            Gfx::ring($r, $this->view->x($g['x']), $this->view->y($g['y']), $this->view->len($g['r']) * (1 + $k * 0.5), $g['angle'], $g['gap'],                max(0.4, 3.2 * (1 - $k)), Gfx::mix($bg, $g['rgb'], 1 - $k), false);        }        foreach (array_reverse($this->world->rings()) as $ring) {            $d = $ring->radius / ($near ?: 1);            $isNear = $d < 1.01;            $dim = $isNear ? 1.0 : max(0.3, 0.75 - ($d - 1.3) * 0.18);            $rgb = Gfx::mix($bg, Gfx::rgb($ring->color), $dim);            $flash = (float)($ring->data['flash'] ?? 0);            $x = $this->view->x($ring->x);            $y = $this->view->y($ring->y);            $rad = $this->view->len($ring->radius);            if ($isNear) {                Gfx::ring($r, $x, $y, $rad, $ring->angle, $ring->gap, 8 + $flash * 4, Gfx::mix($bg, $rgb, 0.14 + 0.2 * $flash), false);            }            Gfx::ring($r, $x, $y, $rad, $ring->angle, $ring->gap, ($isNear ? 3.4 : 2.4) + $flash * 1.6, Gfx::mix($rgb, [255, 255, 255], $flash * 0.8));        }        foreach ($this->world->balls() as $ball) {            $this->drawBall($r, $ball, self::BALL);        }    }}