[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 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 */ 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); } }