<?phpnamespace App\Scene;use App\Audio\Warm;use App\Render\Fx;use App\Render\Gfx;use App\Render\ZoomView;use JumPHP\Physics2d\Ball;use JumPHP\Physics2d\Ring;use JumPHP\Physics2d\World;use JumPHP\Renderer\Captions;use JumPHP\Renderer\Renderer;use JumPHP\Renderer\TtfFont;use JumPHP\Scene\AbstractScene;use JumPHP\Shorts\Melody;use JumPHP\Shorts\Quality;use JumPHP\Shorts\Recorder;use JumPHP\Util\Lang;/** * The common part of the "satisfying" vertical videos (270 x 480, drawn in HD, x4 = 1080 x 1920 for bin/jumphp video:make): a hook * caption in the first seconds, the physics or the illusion in the middle, a counter, a closing caption, a fade in and out (or none when the * scene loops by itself), screen-space effects (Fx), a note (Recorder) at each bounce. A scene fills in build() (the bodies), onHit() * (what a bounce does), tick() and drawWorld() (what it looks like). * * Params: seed (same seed, same video), seconds (the length of the film: the scene times its payoff and its fade by it), lang (fr | en: * every text goes through Lang, config/lang.yaml), title, melody ("C4 E4 G4"), captions (timed texts: the scene has its own otherwise). */abstract class VideoScene extends AbstractScene{ protected const CX = 135; protected const CY = 250; /** the film's length when no `seconds` param says otherwise */ public const SECONDS = 24.0; /** the last picture equals the first one (a whole number of periods): no fade */ protected const LOOPS = false; protected const FADE_IN = 0.5; protected const FADE_OUT = 0.7; protected string $heading = ''; protected string $caption = ''; protected string $banner = ''; protected float $time = 0; protected float $seconds = self::SECONDS; protected float $lastNote = -1; protected float $roundEnd = -1; /** 1 at a bounce, fades to 0: the background lights up a little */ protected float $pulse = 0; protected array $tint = [140, 140, 255]; /** the picture swells by this much on a beat (0: no punch) */ protected float $punch = 0.0; protected int $bounces = 0; protected int $seed = 1; protected int $round = 0; protected string $lang = ''; protected Melody $melody; protected Captions $captions; protected Fx $fx; protected ZoomView $view; private static ?TtfFont $ttf = null; public function __construct(protected World $world, protected Recorder $recorder) { $this->melody = Melody::fromString($this->melodyNotes()); $this->captions = Captions::fromList([]); $this->fx = new Fx(); $this->view = new ZoomView(self::CX, self::CY, self::CX, self::CY); } /** The tune the bounces play, one note each (a public-domain melody or a scale). */ abstract protected function melodyNotes(): string; /** Texts: Lang keys (English), translated by config/lang.yaml. */ abstract protected function headingText(): string; abstract protected function captionText(): string; /** The hook, shown in the first seconds. */ abstract protected function hookText(): string; /** Empties the world and puts the bodies of round $this->round in it. */ abstract protected function build(): void; /** @param array<string, mixed> $hit a World::hits() entry */ protected function onHit(array $hit): void { } abstract protected function drawWorld(Renderer $r): void; /** The default captions of the film (the hook, then a call to action at the end); the `captions` param replaces them. */ protected function defaultCaptions(): array { $end = $this->seconds; return [ ['text' => $this->t($this->hookText()), 'from' => 0.4, 'to' => 3.6, 'at' => 396, 'scale' => 2, 'color' => '#ffe27a', 'style' => 'box'], ['text' => $this->t('FOLLOW FOR MORE'), 'from' => max(4.0, $end - 3.4), 'to' => $end - (static::LOOPS ? 0.9 : 1.6), 'at' => 396, 'scale' => 2, 'color' => '#ffffff', 'style' => 'box'], ] + (static::LOOPS ? [] : [ // the end card: only on films that fade out (a loop keeps its last picture equal to its first) 2 => ['text' => $this->t('PLAY IT: JUMPHP.NET'), 'from' => $end - 1.5, 'to' => $end - 0.1, 'at' => 396, 'scale' => 2, 'color' => '#ffe27a', 'style' => 'box'], ]); } protected function t(string $key, array $params = []): string { return Lang::t($key, $params); } protected function font(): \JumPHP\Renderer\TextDrawer { return self::$ttf ??= new TtfFont($this->sdl(), 'assets/fonts/Quicksand-Bold.ttf', 8, true, 0, 0.0, 0.55); } public function enter(array $params = []): void { $this->engine->device()->keepAwake(); // the screen stays on while a demo plays (Wake Lock / no screen saver) $this->seed = (int)($params['seed'] ?? 1); $this->seconds = (float)($params['seconds'] ?? static::SECONDS); $this->lang = (string)($params['lang'] ?? ''); if ($this->lang !== '') { $this->engine->lang->setLang($this->lang); } $this->time = 0; $this->lastNote = -1; $this->roundEnd = -1; $this->pulse = 0; $this->bounces = 0; $this->round = 0; $this->banner = ''; $this->melody = Melody::fromString((string)($params['melody'] ?? $this->melodyNotes())); $this->heading = (string)($params['title'] ?? $this->t($this->headingText())); $this->recorder->reset(static::LOOPS ? 0.0 : self::FADE_OUT); Quality::begin($this->sdl()->getRenderer(), $this->recorder->isRecording()); foreach (Warm::notes(static::class) as [$wave, $freq, $length, $volume]) { $this->recorder->warm($wave, $freq, $length, $volume); } $this->fx->reset($this->seed); $this->view->reset(); $this->view->cx = $this->view->sx = self::CX; $this->view->cy = $this->view->sy = self::CY; $this->world->gravity = 600; $this->world->restitution = 1.0; $this->world->ballsCollide = false; $this->world->maxSpeed = 1800; $this->build(); $this->captions = Captions::fromList(isset($params['captions']) && is_array($params['captions']) ? $params['captions'] : $this->defaultCaptions()); $this->caption = $this->captionText(); } public function update(float $dt): void { $t0 = Quality::$probing ? microtime(true) : 0.0; if ($this->actions()->isPressed('back') || $this->menuTapped()) { $this->scenes()->switchTo(TitleScene::class); return; } if ($this->actions()->isPressed('restart')) { $this->round++; $this->build(); } if (Quality::$live) { // a slow frame slows the picture down instead of making the physics catch up with many steps (which makes the next frame slower) Quality::frame($this->sdl()->getRenderer()); $dt = min($dt, 0.04); } $this->time += $dt; $this->world->update($dt); foreach ($this->world->hits() as $hit) { $this->onHit($hit); } $this->view->update($dt); if ($this->punch > 0 && !$this->view->isZooming()) { $this->view->scale = 1 + $this->punch * $this->pulse ** 2; } $this->tick($dt); if ($this->roundEnd >= 0 && $this->time >= $this->roundEnd) { $this->roundEnd = -1; $this->banner = ''; $this->round++; $this->build(); } foreach ($this->world->rings() as $ring) { $ring->data['flash'] = max(0.0, (float)($ring->data['flash'] ?? 0) - $dt * 3.5); } foreach ($this->world->balls() as $ball) { $ball->data['sq'] = max(0.0, (float)($ball->data['sq'] ?? 0) - $dt * 7); } $this->fx->update($dt); $this->pulse = max(0.0, $this->pulse - $dt * 4.5); $this->caption = $this->captionText(); if (Quality::$probing) { $t1 = Quality::lap('update', $t0); $this->recorder->pump(); Quality::lap('audio', $t1); return; } $this->recorder->pump(); } public function exit(): void { $this->recorder->stopAll(); } /** The "<- MENU" button (top left, live only): view rectangle [x, y, w, h]. */ public const MENU_BUTTON = [4.0, 4.0, 62.0, 22.0]; /** A click or a tap on the menu button this frame (a tap elsewhere restarts the scene, so this is looked at first). */ protected function menuTapped(): bool { if ($this->recorder->isRecording()) { return false; } $in = fn (float $x, float $y): bool => $x >= self::MENU_BUTTON[0] && $y >= self::MENU_BUTTON[1] && $x <= self::MENU_BUTTON[0] + self::MENU_BUTTON[2] && $y <= self::MENU_BUTTON[1] + self::MENU_BUTTON[3]; $input = $this->engine->input; $mouse = $input->getMouse(); if ($mouse->isPressed(1) && $in($mouse->x, $mouse->y)) { return true; } $touch = $input->getTouch(); return $touch->isPressed('touch:tap') && $in($touch->x * $this->width(), $touch->y * $this->height()); } protected function drawMenuButton(Renderer $r): void { [$x, $y, $w, $h] = self::MENU_BUTTON; $r->fillRect($x, $y, $w, $h, [8, 8, 24, 120]); $c = [215, 220, 255]; $my = $y + $h / 2; Gfx::bar($r, $x + 8, $my, $x + 17, $my, 1.4, $c); Gfx::bar($r, $x + 8, $my, $x + 12.5, $my - 4, 1.4, $c); Gfx::bar($r, $x + 8, $my, $x + 12.5, $my + 4, 1.4, $c); $font = $this->font(); $font->draw($r, $this->t('MENU'), $x + 22, $y + $h / 2 - $font->height(1.4) / 2, 1.4, $c, 'left', [8, 8, 24]); } /** Called every frame after the physics, before the round change. */ protected function tick(float $dt): void { } /** The next note of the melody (a run of tiny bounces makes one note, not a buzz). */ protected function note(float $octave = 1.0, float $length = 0.45, float $volume = 0.3, float $gap = 0.07): void { $this->play($this->melody->next() * $octave, 'pluck', $length, $volume, $gap); } /** A note at $freq now, unless another one sounded less than $gap seconds ago. */ protected function play(float $freq, string $wave = 'pluck', float $length = 0.45, float $volume = 0.3, float $gap = 0.07): bool { if ($this->time - $this->lastNote < $gap) { return false; } $this->lastNote = $this->time; $this->recorder->note($this->time, $freq, $length, $wave, $volume); return true; } /** A soft chord under the music (heard in the film, not live). */ protected function pad(array $freqs, float $length = 4.0, float $volume = 0.05): void { foreach ($freqs as $f) { $this->recorder->note($this->time, (float)$f, $length, 'pad', $volume, false); } } protected function burst(float $x, float $y, array|string $color, int $count, float $speed = 90.0): void { $rgb = Gfx::rgb($color); $this->fx->burst($x, $y, $rgb, $count, $speed); $this->fx->wave($x, $y, $rgb, 14 + $count * 0.8); $this->pulse = 1.0; $this->tint = $rgb; } /** Sparks all along a ring (it breaks). */ protected function burstRing(float $x, float $y, float $radius, array $rgb, int $points = 26): void { for ($i = 0; $i < $points; $i++) { $a = 2 * M_PI * $i / $points; $this->fx->burst($x + cos($a) * $radius, $y + sin($a) * $radius, $rgb, 2, 70); } $this->fx->wave($x, $y, $rgb, $radius + 12, 2.2); $this->pulse = 1.0; $this->tint = $rgb; } /** The squash of a ball that just hit something whose normal is ($nx, $ny). */ protected function squash(Ball $ball, float $x, float $y): void { $ball->data['sq'] = 1.0; $ball->data['sqa'] = atan2($y - $ball->y, $x - $ball->x); } // --- drawing ----------------------------------------------------------------------------------- public function render(Renderer $r): void { $t0 = Quality::$probing ? microtime(true) : 0.0; Gfx::background($r, $this->pulse, $this->tint); $this->drawWorld($r); $this->fx->render($r); $this->drawHud($r); $alpha = $this->fadeAlpha(); if ($alpha > 0.01) { $r->fillRect(0, 0, $this->width(), $this->height(), [0, 0, 0, (int)round(255 * min(1.0, $alpha))]); } $this->drawWatermark($r); if (!$this->recorder->isRecording()) { $this->drawMenuButton($r); } if (Quality::$probing) { Quality::lap('render', $t0); Quality::$probe['frames'] = (Quality::$probe['frames'] ?? 0) + 1; } } /** The site's name, small and translucent, bottom left (above the platforms' bottom bar, away from their right-hand buttons); constant, so loops stay loops. */ public const WATERMARK = 'jumphp.net'; /** set when the watermark was drawn (tests) */ public bool $watermarkDrawn = false; protected function drawWatermark(Renderer $r): void { // the font keeps the rendered text in a texture: one copy per frame, no text rendering $this->font()->draw($r, self::WATERMARK, 14, 438, 1.7, [255, 255, 255, 165], 'left', [8, 8, 24, 140]); $this->watermarkDrawn = true; } /** 1 = black, 0 = clear: in at the start, out at the end (films that do not loop by themselves). */ protected function fadeAlpha(): float { if (static::LOOPS) { return 0.0; } $in = 1 - $this->time / self::FADE_IN; $out = ($this->time - ($this->seconds - self::FADE_OUT)) / self::FADE_OUT; return max($in, $out, 0.0); } protected function drawHud(Renderer $r): void { $font = $this->font(); $w = $this->width(); // a soft dark veil behind the heading, so it reads over any picture foreach ([[0, 112, 70], [0, 96, 60], [0, 80, 60]] as [$y0, $y1, $a]) { $r->fillRect(0, $y0, $w, $y1 - $y0, [6, 6, 20, (int)($a * 0.55)]); } $lines = Captions::wrap($this->heading, $w - 28, fn (string $t): int => $font->width($t, 2.5)); $y = 46; foreach ($lines as $line) { $font->draw($r, $line, $w / 2, $y, 2.5, [255, 255, 255], 'center', [8, 8, 24]); $y += $font->lineHeight(2.5) - 2; } $font->draw($r, $this->caption, $w / 2, $y + 4, 1.7, [190, 198, 240], 'center', [8, 8, 24]); if ($this->banner !== '') { $font->draw($r, $this->banner, $w / 2, 226, 3, [255, 230, 110], 'center', [8, 8, 24]); } $this->captions->render($r, $font, $this->time, $w, $this->height()); } /** A ring, whiter and thicker for a moment after it was hit, its colour running round it. */ protected function drawRing(Renderer $r, Ring $ring, bool $glow = true): void { $flash = (float)($ring->data['flash'] ?? 0); $base = Gfx::rgb($ring->color); $x = $this->view->x($ring->x); $y = $this->view->y($ring->y); $rad = $this->view->len($ring->radius); $bg = Gfx::BG_BOTTOM; if ($glow && Quality::halos()) { Gfx::ring($r, $x, $y, $rad, $ring->angle, $ring->gap, $ring->thickness + 5 + $flash * 4, Gfx::mix($bg, $base, 0.13 + $flash * 0.2), false); } $time = $this->time; Gfx::waveRing($r, $x, $y, $rad, $ring->angle, $ring->gap, $ring->thickness + $flash * 1.6, fn (float $a): array => Gfx::mix(Gfx::mix($base, Gfx::hsv(0.62 + sin($a * 1 + $time * 0.6) * 0.12 + $a * 0.04), 0.35), [255, 255, 255], $flash * 0.85)); } /** A ball with its trail, a soft halo, a squash when it hits and a stretch when it flies. */ protected function drawBall(Renderer $r, Ball $ball, ?float $screenRadius = null): void { $rgb = Gfx::rgb($ball->color); $x = $this->view->x($ball->x); $y = $this->view->y($ball->y); $rad = $screenRadius ?? $this->view->len($ball->radius); $pts = []; foreach ($ball->trail as $i => $p) { if ($i % (Quality::$live ? 4 : 2) === 0) { $pts[] = [$this->view->x($p[0]), $this->view->y($p[1])]; } } Gfx::trail($r, $pts, $rad * 0.9, $rgb, Gfx::mix(Gfx::BG_BOTTOM, $rgb, 0.25)); Gfx::glow($r, $x, $y, $rad, $rgb); $sq = (float)($ball->data['sq'] ?? 0); $speed = $ball->speed() * $this->view->scale; $stretch = min(0.4, $speed / 1500); if ($sq > $stretch) { Gfx::blob($r, $x, $y, $rad * (1 - 0.32 * $sq), $rad * (1 + 0.28 * $sq), (float)($ball->data['sqa'] ?? 0), $rgb); } else { Gfx::blob($r, $x, $y, $rad * (1 + $stretch), $rad / (1 + $stretch * 0.7), atan2($ball->vy, $ball->vx), $rgb); } Gfx::disc($r, $x - $rad * 0.3, $y - $rad * 0.32, max(0.5, $rad * 0.28), Gfx::mix($rgb, [255, 255, 255], 0.75), 200); } /** '#rrggbb' of a pastel-bright colour around the colour wheel ($h: 0..1, wraps). */ public static function hue(float $h): string { return Gfx::hex(Gfx::hsv($h, 0.55, 1.0)); } /** $hex moved $k (0..1) of the way to white. */ public static function mix(string $hex, float $k): string { return Gfx::hex(Gfx::mix(Gfx::rgb($hex), [255, 255, 255], $k)); }}