登录
ZH
games/satisfying-bounce / src / Scene / StairsScene.php · commit 8bdd6e4
<?phpnamespace App\Scene;use App\Audio\Scale;use App\Render\Gfx;use App\Render\ZoomView;use JumPHP\Renderer\Renderer;/** * The optical trick of the stairs: a shape hops down steps that are each a bit bigger than the one before, and it grows with them; the camera * zooms out in step so that, ON SCREEN, it never changes size. Only the stairs behind it shrink away, so it feels like it keeps growing * while it does not. New steps are made ahead, old ones dropped, the world is rebased now and then: endless. A different shape each step * (square, star, ball, ring, domino); each landing plays the next lower note: bigger sounds lower. */final class StairsScene extends VideoScene{    public const SECONDS = 24.0;    public const GROW = 1.28;    public const HOP = 0.72;    public const BASE = 26.0;    public const AHEAD = 14;    private const SHAPES = ['square', 'star', 'ball', 'ring', 'domino'];    /** @var array<int, array{x: float, y: float, w: float, h: float}> */    private array $steps = [];    private int $k = 0;    private float $phase = 0.0;    /** the world is multiplied by this since the start (it is rebased): the true growth is GROW ** hops */    private int $hops = 0;    private float $objSize = 0.0;    private float $objX = 0.0;    private float $objY = 0.0;    private float $objAngle = 0.0;    private float $squashT = 0.0;    private float $screenSize = self::BASE;    protected function melodyNotes(): string    {        return 'C4';    }    protected function headingText(): string    {        return 'IT GROWS WITH EVERY STEP';    }    protected function hookText(): string    {        return 'WATCH IT GROW';    }    protected function captionText(): string    {        return $this->t('STEPS {n}   SIZE x{z}', ['n' => $this->hops, 'z' => $this->growthLabel()]);    }    private function growthLabel(): string    {        $z = self::GROW ** $this->hops;        return $z < 1000 ? (string)(int)round($z) : (string)(int)round($z / 1000) . 'K';    }    protected function defaultCaptions(): array    {        $c = parent::defaultCaptions();        $c[] = ['text' => $this->t('BUT ITS SIZE ON SCREEN NEVER CHANGES'), 'from' => $this->seconds * 0.38, 'to' => $this->seconds * 0.38 + 3.4, 'at' => 396, 'scale' => 2, 'color' => '#9fe8ff', 'style' => 'pop'];        return $c;    }    /** Size of the shape now on screen (always about BASE) */    public function screenSize(): float    {        return $this->screenSize;    }    public function stepCount(): int    {        return count($this->steps);    }    public function hops(): int    {        return $this->hops;    }    /** the largest coordinate of the world (it stays small: the world is rebased) */    public function extent(): float    {        $m = 0.0;        foreach ($this->steps as $s) {            $m = max($m, abs($s['x']), abs($s['y']), $s['w']);        }        return $m;    }    protected function build(): void    {        $this->steps = [];        $this->k = 0;        $this->hops = 0;        $this->phase = 0.0;        $this->squashT = 0.0;        $this->world->reset($this->seed);        $x = 0.0;        $y = 0.0;        $w = 40.0;        for ($i = 0; $i < self::AHEAD; $i++) {            $this->steps[$i] = ['x' => $x, 'y' => $y, 'w' => $w, 'h' => $w * 0.55];            $x += $w;            $y += $w * 0.55;            $w *= self::GROW;        }        $this->objSize = 40.0 * 0.46;        $this->place();    }    /** where the shape is, and the camera, for the current hop phase */    private function place(): void    {        $k = $this->k;        $u = min(1.0, $this->phase / self::HOP);        $e = ZoomView::ease($u);        $a = $this->steps[$k];        $b = $this->steps[$k + 1];        $size0 = $a['w'] * 0.46;        $size1 = $b['w'] * 0.46;        $this->objSize = $size0 * ($size1 / $size0) ** $e;        $ax = $a['x'] + $a['w'] / 2;        $bx = $b['x'] + $b['w'] / 2;        $ay = $a['y'] - $size0 / 2;        $by = $b['y'] - $size1 / 2;        $this->objX = $ax + ($bx - $ax) * $e;        $arc = $a['h'] * 1.6 + $size0 * 0.6;        $this->objY = $ay + ($by - $ay) * $e - $arc * sin(M_PI * $u);        $this->objAngle = $u * 2 * M_PI;        $this->view->scale = self::BASE / $this->objSize;        $this->view->cx = $ax + ($bx - $ax) * $e;        $this->view->cy = $ay + ($by - $ay) * $e + $this->objSize * 0.2;        $this->view->sx = 96;        $this->view->sy = 200;        $this->screenSize = $this->view->len($this->objSize);    }    protected function tick(float $dt): void    {        $this->phase += $dt;        $this->squashT = max(0.0, $this->squashT - $dt * 6);        while ($this->phase >= self::HOP) {            $this->phase -= self::HOP;            $this->land();        }        $this->place();    }    private function land(): void    {        $this->k++;        $this->hops++;        $this->bounces++;        $this->squashT = 1.0;        $step = $this->steps[$this->k];        $rgb = Gfx::hsv($this->hops * 0.067, 0.55);        $this->burst($this->view->x($step['x'] + $step['w'] / 2), $this->view->y($step['y']), $rgb, 8, 70);        $this->play(Scale::freq(Scale::LYDIAN, 14 - $this->hops % 14, 60), 'marimba', 0.7, 0.3, 0.0);        // new steps ahead        $last = max(array_keys($this->steps));        while ($last < $this->k + self::AHEAD) {            $p = $this->steps[$last];            $this->steps[++$last] = ['x' => $p['x'] + $p['w'], 'y' => $p['y'] + $p['h'], 'w' => $p['w'] * self::GROW, 'h' => $p['w'] * self::GROW * 0.55];        }        foreach (array_keys($this->steps) as $i) {            if ($i < $this->k - 9) {                unset($this->steps[$i]);            }        }        // the world is rebased: every length divided, so the numbers never grow        $f = $this->steps[$this->k]['w'] > 400 ? 40.0 / $this->steps[$this->k]['w'] : 1.0;        if ($f !== 1.0) {            foreach ($this->steps as $i => $s) {                $this->steps[$i] = ['x' => $s['x'] * $f, 'y' => $s['y'] * $f, 'w' => $s['w'] * $f, 'h' => $s['h'] * $f];            }        }    }    protected function drawWorld(Renderer $r): void    {        $w = $r->getWidth();        $h = $r->getHeight();        foreach ($this->steps as $i => $s) {            $x0 = $this->view->x($s['x']);            $x1 = $this->view->x($s['x'] + $s['w']);            $y0 = $this->view->y($s['y']);            if ($x1 < -2 || $x0 > $w || $y0 > $h + 2) {                continue;            }            $hue = ($i + $this->hopsBase()) * 0.067;            $face = Gfx::hsv($hue, 0.5, 0.42);            $top = Gfx::hsv($hue, 0.45, 0.95);            $r->fillRect($x0, $y0, $x1 - $x0, $h - $y0 + 4, Gfx::color($face));            $r->fillRect($x0, $y0, $x1 - $x0, max(1.2, $this->view->len($s['h']) * 0.16), Gfx::color($top));        }        $this->drawShape($r);    }    /** hues are tied to the absolute step number: the ones dropped behind and the rebase never change a colour */    private function hopsBase(): int    {        return $this->hops - $this->k;    }    private function drawShape(Renderer $r): void    {        $x = $this->view->x($this->objX);        $y = $this->view->y($this->objY);        $half = self::BASE / 2;        $shape = self::SHAPES[$this->hops % count(self::SHAPES)];        $rgb = Gfx::hsv(($this->hops + $this->phase / self::HOP) * 0.067 + 0.5, 0.45, 1.0);        Gfx::glow($r, $x, $y, $half, $rgb, 0.8);        $sq = $this->squashT;        $u = min(1.0, $this->phase / self::HOP);        $ang = $u < 1.0 ? $this->objAngle : 0.0;        switch ($shape) {            case 'square':                Gfx::box($r, $x, $y + $half * 0.25 * $sq, self::BASE * (1 + 0.18 * $sq), self::BASE * (1 - 0.2 * $sq), $ang, $rgb);                break;            case 'star':                Gfx::star($r, $x, $y, $half * 1.25, $half * 0.55, $ang, $rgb);                break;            case 'ball':                Gfx::blob($r, $x, $y + $half * 0.2 * $sq, $half * (1 + 0.2 * $sq), $half * (1 - 0.2 * $sq), 0.0, $rgb);                break;            case 'ring':                Gfx::arc($r, $x, $y, $half * 0.8, 0, 2 * M_PI, self::BASE * 0.24, $rgb);                break;            default:                Gfx::box($r, $x, $y, self::BASE * 0.5, self::BASE * 1.1, $ang * 0.5, $rgb);        }        Gfx::disc($r, $x - $half * 0.3, $y - $half * 0.35, $half * 0.2, [255, 255, 255], 170);    }}