PL
games/open-lemmings / src / View / WorldView.php · commit 04e2e2a
<?phpnamespace App\View;use App\Service\Art;use App\Sim\Lumi;use App\Sim\World;use JumPHP\Kernel\Engine;use JumPHP\Renderer\BitmapFont;use JumPHP\Renderer\Renderer;/** * Draws a World: sky, two parallax layers, floating motes, the hatch and the exit, the terrain, the pools, the * Lumikins with their halo, the particles. Also turns the world's events into particles, flashes and sounds. */final class WorldView{    public const VIEW_W = 480;    public const VIEW_H = 232;    /** sprite sheet rows (16 px cells): state => [row, frames, ticks per frame] */    public const ANIM = [        Lumi::WALK => [0, 8, 2], Lumi::FALL => [1, 4, 3], Lumi::GLIDE => [2, 4, 6], Lumi::CLIMB => [3, 4, 4], Lumi::STOP => [4, 4, 8],        Lumi::BRIDGE => [5, 4, 2], Lumi::BASH => [6, 4, 3], Lumi::MINE => [7, 4, 3], Lumi::DIG => [8, 4, 3], Lumi::LEAVE => [9, 4, 5],    ];    public Particles $particles;    public TerrainView $terrain;    public float $shake = 0;    /** @var array<int, array{0:float,1:float,2:float,3:string}> flashes: x, y, life, glow */    private array $flashes = [];    private array $theme;    private BitmapFont $digits;    private float $time = 0;    private ?World3d $world3d = null;    public function __construct(private Engine $engine, private Art $art, public World $world, private string $themeName)    {        $this->theme = $art->themes[$themeName];        $this->particles = new Particles();        $this->digits = new BitmapFont($engine->sdl);        $this->terrain = new TerrainView($engine->sdl, $world->terrain, $engine->config->path($art->tile("$themeName-earth")),            $engine->config->path($art->tile('steel')), $this->theme, $art->scale);    }    public function release(): void    {        $this->terrain->release();        $this->world3d?->release();        $this->world3d = null;    }    public function theme(): array    {        return $this->theme;    }    public function themeName(): string    {        return $this->themeName;    }    public function time(): float    {        return $this->time;    }    /** @return array<int, array{0:float,1:float,2:float,3:string}> */    public function flashes(): array    {        return $this->flashes;    }    /** The 3D view of this world, made the first time it is asked for (nothing is built before). */    public function world3d(): World3d    {        return $this->world3d ??= new World3d($this->engine, $this->art, $this);    }    /**     * The level in 3D (the GL layer under the 2D, which is cleared transparent here), then what stays flat over it:     * the fuse counters, the vignette.     */    public function render3d(Renderer $r, Camera3d $cam, float $camX, float $camY, ?Lumi $hover = null, bool $hoverOk = false): void    {        $r->clear([0, 0, 0, 0], true);        $this->world3d()->render($cam, $hover, $hoverOk, $camX, $camY);        if ($this->art->scale > 1) {   // HD: the haze rising from the bottom of the level, as in 2D (flat over the 3D)            $fog = $this->theme['fog'];            for ($i = 0; $i < 6; $i++) {                $r->fillRect(0, self::VIEW_H - 60 + $i * 10, self::VIEW_W, 60 - $i * 10, [$fog[0], $fog[1], $fog[2], 12]);            }        }        foreach ($this->world->lumis as $l) {            if ($l->alive && $l->fuse >= 0 && ($p = $cam->project($l->x, $l->y - 27)) !== null && $p[1] < self::VIEW_H) {                $this->digits->draw($r, (string)(int)ceil($l->fuse / World::TICKS_PER_SECOND), $p[0] + 1, $p[1], 1, [255, 240, 120], 'center', [60, 0, 0]);            }        }        $this->art->draw($r, 'vignette', [0, 0, 480, self::VIEW_H], 0, 0);    }    /** After the world's ticks of this frame: its events become effects, then they are forgotten. */    public function consume(bool $sounds = true): void    {        $sound = $this->engine->sound;        $earth = ['#a36a3c', '#7a4b2c', '#4a2a1c', $this->theme['grass'][1]];        foreach ($this->world->events as [$type, $x, $y, $extra]) {            switch ($type) {                case 'spawn':                    $this->particles->burst($x, $y - 4, 6, ['#ffd23c', '#fff8d8'], 40, 0.4, 60);                    break;                case 'assign':                    $this->particles->burst($x, $y - 8, 10, ['#ffffff', '#ffd23c', '#5cf2e0'], 55, 0.45, 0, 1, 0, M_PI);                    $this->flashes[] = [$x, $y - 8, 0.18, 'glow-white'];                    break;                case 'saved':                    $this->particles->burst($x, $y - 12, 14, ['#ffd23c', '#fff8d8', '#ff8a1c'], 70, 0.7, -40);                    $this->flashes[] = [$x, $y - 14, 0.25, 'glow-amber'];                    break;                case 'boom':                    $this->particles->burst($x, $y - 6, 46, array_merge($earth, ['#ffd23c', '#ff8a1c', '#ffffff']), 170, 0.9, 320, 1, 1.2);                    $this->flashes[] = [$x, $y - 6, 0.3, 'flash'];                    $this->shake = 0.35;                    break;                case 'splat':                    $this->particles->burst($x, $y - 2, 16, ['#2ee0c4', '#ffe9c4', '#ffd23c'], 80, 0.6, 300, 1, 1, 1.3);                    break;                case 'drown':                    $this->particles->burst($x, $y - 6, 14, [$this->theme['liquid'][0], '#ffffff'], 90, 0.6, 320, 1, 1.5, 0.6);                    break;                case 'dust':                    $this->particles->burst($x, $y, 3, $earth, 45, 0.45, 260);                    break;                case 'clink':                    $this->particles->burst($x, $y - 5, 8, ['#ffffff', '#ffd23c'], 80, 0.3, 200);                    break;                case 'brick':                case 'lastbrick':                    $this->particles->burst($x, $y, 2, ['#f4c882'], 25, 0.3, 200);                    break;            }            if ($sounds) {                $name = match ($type) {                    'assign' => 'assign', 'spawn' => 'spawn', 'saved' => 'saved', 'boom' => 'boom', 'splat' => 'splat', 'drown' => 'drown',                    'brick' => 'brick', 'lastbrick' => 'lastbrick', 'clink' => 'clink', 'nuke' => 'fuse', default => null,                };                if ($name !== null) {                    $sound->playSfx($name);                }            }        }        $this->world->events = [];    }    public function update(float $dt): void    {        $this->time += $dt;        $this->shake = max(0, $this->shake - $dt);        $this->particles->update($dt);        foreach ($this->flashes as $i => &$f) {            if (($f[2] -= $dt) <= 0) {                unset($this->flashes[$i]);            }        }        unset($f);        $this->terrain->refresh(3);        if ($this->world3d === null) {            $this->terrain->touched = [];   // no 3D view yet: it builds every chunk when it is made        }    }    public function render(Renderer $r, float $camX, ?Lumi $hover = null, bool $hoverOk = false, float $zoom = 1.0, float $viewY = 0.0): void    {        $sdl = $this->engine->sdl;        $t = $this->themeName;        $fine = $zoom > 1.0 || $r->hd() > 1;        $camX = $fine ? $camX : round($camX);        $camY = $viewY + ($this->shake > 0 ? round(sin($this->time * 90) * 2.5 * min(1, $this->shake * 4)) : 0);        $bgX = $camX;        $bgY = $camY - $viewY;        $this->art->draw($r, "bg-$t-sky", null, 0, 0);        foreach (['far' => 0.15, 'near' => 0.4] as $layer => $speed) {            $tex = "bg-$t-$layer";            $off = -fmod($bgX * $speed, 512);            $this->art->draw($r, $tex, null, round($off), -$bgY * $speed);            $this->art->draw($r, $tex, null, round($off) + 512, -$bgY * $speed);        }        // motes drifting in the air (fireflies, embers, snow): positions from the clock, no state        $mote = $this->theme['mote'];        $glow = 'glow-' . $this->theme['glow'];        for ($i = 0; $i < 26; $i++) {            $sx = fmod($i * 97.3 + $this->time * (4 + $i % 5) - $bgX * (0.3 + ($i % 4) * 0.12) + 4800, 500) - 10;            $sy = fmod($i * 53.7 + ($t === 'night' ? 1 : -1) * $this->time * (3 + $i % 4) + 2320, 232) + 4 * sin($this->time * 1.3 + $i);            $pulse = 0.5 + 0.5 * sin($this->time * (1.5 + ($i % 3) * 0.6) + $i * 2.1);            if ($pulse > 0.25) {                if ($i % 3 === 0) {                    $this->art->draw($r, $glow, null, $sx - 6, $sy - 6, 13, 13);                }                $r->fillRect($sx, $sy, 1, 1, $mote);            }        }        if ($zoom > 1.0) {   // from here on, level pixels are $zoom times bigger (SDL scales every drawing)            \SDL_RenderSetLogicalSize($r->getRenderer(), (int)round($r->getWidth() / $zoom), (int)round($r->getHeight() / $zoom));        }        // the hatch and the exit        [$hx, $hy] = $this->world->hatch;        $this->art->draw($r, 'glow-violet', null, $hx - 30 - $camX, $hy - 48 - $camY, 60, 60);        $this->art->draw($r, 'hatch', null, $hx - 24 - $camX, $hy - 34 - $camY);        [$ex, $ey] = $this->world->exit;        $pulse = 62 + 8 * sin($this->time * 3);        $this->art->draw($r, 'glow-amber', null, $ex - $pulse - $camX, $ey - 22 - $pulse - $camY, 2 * $pulse, 2 * $pulse);        $this->art->draw($r, 'exit', [((int)($this->time * 9) % 6) * 40, 0, 40, 44], $ex - 20 - $camX, $ey - 43 - $camY);        $this->terrain->render($r, $camX, $camY);        // pools: a waving surface, darker with depth, a glow above        $liquid = $this->theme['liquid'];        foreach ($this->world->liquids as $pool) {            [$x0, $x1, $top] = $pool;            $bottom = $pool[3] ?? self::VIEW_H + 4;            if ($x1 < $camX - 40 || $x0 > $camX + self::VIEW_W + 40) {                continue;            }            $this->art->draw($r, $t === 'forge' ? 'glow-amber' : 'glow-cyan', [0, 0, 64, 32], $x0 - 12 - $camX, $top - 22 - $camY,                $x1 - $x0 + 25, 26);            $step = $this->art->scale > 1 ? 1 : 2;            for ($x = $x0; $x <= $x1; $x += $step) {                $wave = sin($x * 0.21 + $this->time * 3.1) + 0.6 * sin($x * 0.47 - $this->time * 2.3);                $y = $top + 1 + $wave * 1.4;                $w = min($step, $x1 - $x + 1);                $r->fillRect($x - $camX, $y - $camY, $w, 2, $wave > 0.9 ? '#ffffff' : $liquid[0]);                $r->fillRect($x - $camX, $y + 2 - $camY, $w, 7, $liquid[1]);                $r->fillRect($x - $camX, $y + 9 - $camY, $w, max(0, $bottom - $y - 9), $liquid[2]);                if ($step === 1) {                    $r->fillRect($x - $camX, $y + 1.4, $w, 1.2, [255, 255, 255, 70]);                    $r->fillRect($x - $camX, $y + 7 + $wave, $w, 3, [0, 0, 0, 40]);                }            }            for ($i = 0; $i < 4; $i++) {   // bubbles                $bx = $x0 + 3 + fmod($i * 37.7 + floor($this->time * 0.7 + $i * 0.3) * 23.1, max(1, $x1 - $x0 - 6));                $by = $top + 14 - fmod($this->time * 0.7 + $i * 0.3, 1) * 12;                $r->fillRect($bx - $camX, $by - $camY, 1, 1, $liquid[0]);            }        }        $hd = $this->art->scale > 1;        if ($hd) {   // haze rising from the bottom of the level            $fog = $this->theme['fog'];            for ($i = 0; $i < 6; $i++) {                $r->fillRect(0, self::VIEW_H - 60 + $i * 10, self::VIEW_W, 60 - $i * 10, [$fog[0], $fog[1], $fog[2], 12]);            }        }        // the Lumikins        $sheet = 'lumi';        $halo = 'glow-amber';        $red = 'glow-red';        foreach ($this->world->lumis as $l) {            $sx = $l->x - $camX;            if (!$l->alive || $sx < -20 || $sx > self::VIEW_W + 20) {                continue;            }            $sy = $l->y - $camY;            if ($hd && !$l->is(Lumi::FALL, Lumi::GLIDE, Lumi::LEAVE)) {                $this->art->draw($r, 'shadow', null, $sx - 7, $sy - 2, 15, 5);            }            $warn = $l->fuse >= 0 || $l->state === Lumi::STOP;            $this->art->draw($r, $warn && ($l->frame >> 2) % 2 ? $red : $halo, null, $sx - 15, $sy - 27, 31, 31);            [$row, $frames, $ticks] = self::ANIM[$l->state];            $frame = $l->state === Lumi::LEAVE ? min(3, intdiv($l->frame, $ticks)) : intdiv($l->frame, $ticks) % $frames;            $this->art->draw($r, $sheet, [$frame * 16, $row * 16, 16, 16], $sx - ($l->dir > 0 ? 8 : 7), $sy - 16, 16, 16, $l->dir < 0);            if ($l->fuse >= 0) {                $this->digits->draw($r, (string)(int)ceil($l->fuse / World::TICKS_PER_SECOND), $sx + 1, $sy - 27, 1, [255, 240, 120], 'center', [60, 0, 0]);            } elseif ($l->is(Lumi::WALK, Lumi::FALL)) {                if ($l->climber) {                    $r->fillRect($sx - 4, $sy - 19, 2, 2, '#5cf2e0');                }                if ($l->glider) {                    $r->fillRect($sx + 3, $sy - 19, 2, 2, '#5ce06c');                }            }        }        if ($hover !== null && $hover->alive) {            $this->art->draw($r, $hoverOk ? 'cursor-on' : 'cursor', null, $hover->x - $camX - 7, $hover->y - $camY - 16);        }        $this->particles->render($r, $camX, $camY);        foreach ($this->flashes as [$fx, $fy, $life, $tex]) {            $s = $tex === 'flash' ? 150 * min(1, $life * 5) : 40 * min(1, $life * 8);            $this->art->draw($r, $tex, null, $fx - $camX - $s / 2, $fy - $camY - $s / 2, $s, $s);        }        if ($zoom > 1.0) {            \SDL_RenderSetLogicalSize($r->getRenderer(), $r->getWidth(), $r->getHeight());        }        $this->art->draw($r, 'vignette', [0, 0, 480, self::VIEW_H], 0, 0);    }}