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