TR
games/satisfying-bounce / src / Scene / VideoScene.php · commit 8bdd6e4
<?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));    }}