engine->options->set('bloom', (bool)$params['bloom']); } $this->art->load($this->engine); $this->audio->load($this->sound()); $this->level = max(0, min(Levels::count() - 1, (int)($params['level'] ?? 0))); $this->demo = (bool)($params['demo'] ?? false); $this->def = Levels::get($this->level); $this->world = LevelBuilder::build($this->def); $this->view?->release(); $this->view = new WorldView($this->engine, $this->art, $this->world, $this->def['theme']); $this->auto = $this->demo || !empty($params['auto']) ? new Autoplay($this->world, $this->def['solution']) : null; $this->skill = 'dig'; foreach (World::SKILLS as $s) { if ($this->world->skills[$s] > 0) { $this->skill = $s; break; } } $this->zoom = 1.0; $this->camY = 0; $this->camX = $this->clampCam($this->world->hatch[0] - 140); $input = $this->engine->input; $controls = $input->getControls(); if (!$controls instanceof \App\View\Pinch) { $controls = new \App\View\Pinch($controls); $input->setControls($controls); } $this->pinch = $controls; if (isset($params['zoom'])) { // tests, screenshots $this->zoomAt((float)$params['zoom'], 100, 150); } $this->acc = 0; $this->fast = false; $this->banner = $this->demo || !empty($params['auto']) ? 0 : 3.2; $this->endTimer = 0; $this->ended = false; $this->nukeArmed = 0; $this->dragging = false; $this->pinchLock = false; $this->tapPending = false; $this->idleFrames = 0; $this->hover = null; $this->time = 0; $this->mouseSeen = false; $this->tip = ''; $this->tipTime = 0; $this->yaw = 0; $this->yawTarget = 0; $this->force3d = !empty($params['force3d']); $this->layer(false); if (isset($params['angle']) && $this->can3d()) { // tests, screenshots: straight into the 3D view $this->yawTarget = max(-self::YAW_STEP, min(self::YAW_STEP, (float)$params['angle'])); $this->view->world3d()->prepare($this->camera3d($this->yawTarget), 1e6); $this->yaw = $this->yawTarget; } $music = ['forest' => 'play', 'cave' => 'play2', 'forge' => 'play3', 'night' => 'play4', 'desert' => 'play5', 'ice' => 'play6', 'jungle' => 'play7', 'sky' => 'play8', 'autumn' => 'play9', 'reef' => 'play10', 'clockwork' => 'play11'][$this->def['theme']] ?? 'play'; if (!$this->demo && $this->sound()->getCurrentMusic() !== $music) { $this->sound()->playMusic($music); } $this->progress->current = $this->level; } public function exit(): void { $this->layer(false); $this->view?->release(); $this->view = null; } public function hotState(): array { return ['level' => $this->level, 'demo' => $this->demo]; } public function hotRestore(array $state): void { if ($state['level'] !== $this->level || $state['demo'] !== $this->demo) { $this->enter($state); } } public function world(): World { return $this->world; } public function view(): WorldView { return $this->view ?? throw new \LogicException('not entered'); } public function camX(): float { return $this->camX; } private function clampCam(float $x): float { $this->camY = max(0, min(WorldView::VIEW_H - WorldView::VIEW_H / $this->zoom, $this->camY)); return max(0, min($this->world->terrain->width - WorldView::VIEW_W / $this->zoom, $x)); } // --- the 3D view ------------------------------------------------------------------------------------------ /** The 3D view needs GL (never in the title's demo). */ public function can3d(): bool { return !$this->demo && ($this->force3d || $this->engine->gl()->isAvailable()); } public function yaw(): float { return $this->yaw; } public function yawTarget(): float { return $this->yawTarget; } /** True while the level is drawn in 3D (any angle but 0, transitions included). */ public function is3d(): bool { return $this->yaw != 0.0; } /** An arrow: the camera turns by 45 degrees to the left (-1) or the right (1), up to 45 degrees. */ public function turn(int $dir): void { if (!$this->can3d()) { return; } $target = max(-self::YAW_STEP, min(self::YAW_STEP, $this->yawTarget + $dir * self::YAW_STEP)); if ($target != $this->yawTarget) { $this->yawTarget = $target; $this->sound()->playSfx('select'); } } public function camera3d(?float $yaw = null): Camera3d { $shake = $this->view !== null && $this->view->shake > 0 ? round(sin($this->view->time() * 90) * 2.5 * min(1, $this->view->shake * 4)) : 0.0; return new Camera3d($this->camX, $this->camY, $this->zoom, $yaw ?? $this->yaw, $shake); } /** A view point to level coordinates: through the 3D camera when the view is turned. @return float[] [x, y] */ public function toLevel(float $vx, float $vy): array { if ($this->is3d()) { $p = $this->camera3d()->unproject($vx, $vy); if ($p !== null) { return $p; } } return [$vx / $this->zoom + $this->camX, $vy / $this->zoom + $this->camY]; } /** The GL layer under the 2D: on in 3D only, so that the 2D path stays exactly as it was. */ private function layer(bool $on): void { if ($on === $this->layerOn) { return; } $this->layerOn = $on; $gl = $this->engine->gl(); if (!$gl->isAvailable()) { return; } $on ? $gl->setLayer('below', 'view', null) : $gl->removeLayer(); } /** Eases the camera's turn; the first turn waits until the terrain in view is built in 3D. */ private function updateYaw(float $dt): void { if ($this->yawTarget == 0.0 && $this->yaw == 0.0) { return; } if (!$this->can3d()) { $this->yaw = $this->yawTarget = 0; return; } $starting = $this->yaw == 0.0; $ready = $this->view()->world3d()->prepare($this->camera3d($starting ? $this->yawTarget : $this->yaw), $starting ? 14 : 4); if ($starting && !$ready) { return; } $this->yaw += ($this->yawTarget - $this->yaw) * min(1, $dt * 6); if (abs($this->yawTarget - $this->yaw) < 0.3) { $this->yaw = $this->yawTarget; } } public function pinch(): ?\App\View\Pinch { return $this->pinch; } public function camY(): float { return $this->camY; } public function zoom(): float { return $this->zoom; } /** Zooms by $factor, keeping the level point under the view point ($vx, $vy) where it is. */ public function zoomAt(float $factor, float $vx, float $vy): void { $old = $this->zoom; $this->zoom = max(1.0, min(3.0, $old * $factor)); if (abs($this->zoom - 1.0) < 0.02) { $this->zoom = 1.0; } $this->camY += $vy / $old - $vy / $this->zoom; $this->camX = $this->clampCam($this->camX + $vx / $old - $vx / $this->zoom); } // --- the bar's buttons: [x, y, w, h] ----------------------------------------------------------------------- /** @return array */ public static function buttons(): array { $b = []; foreach (World::SKILLS as $i => $skill) { $b[$skill] = [4 + $i * 35, 235, 32, 32]; } $b['minus'] = [290, 235, 24, 15]; $b['plus'] = [316, 235, 24, 15]; $b['pause'] = [346, 235, 32, 32]; $b['fast'] = [381, 235, 32, 32]; $b['nuke'] = [416, 235, 32, 32]; return $b; } /** The two arrows that turn the camera, at the top right of the level. @return array */ public static function viewButtons(): array { return ['turn_left' => [425, 15, 24, 15], 'turn_right' => [451, 15, 24, 15]]; } /** The arrow under a view point; without GL the greyed arrows let the clicks through to the level. */ private function viewButtonAt(float $x, float $y): ?string { if (!$this->can3d()) { return null; } foreach (self::viewButtons() as $name => [$bx, $by, $bw, $bh]) { if ($x >= $bx - 1 && $x < $bx + $bw + 1 && $y >= $by - 2 && $y < $by + $bh + 2) { return $name; } } return null; } private function buttonAt(float $x, float $y): ?string { foreach (self::buttons() as $name => [$bx, $by, $bw, $bh]) { if ($x >= $bx - 1 && $x < $bx + $bw + 1 && $y >= $by - 2 && $y < $by + $bh + 2) { return $name; } } return null; } /** A click / tap on the bar. */ public function pressButton(string $name): void { if (in_array($name, World::SKILLS, true)) { $this->skill = $name; $this->sound()->playSfx('select'); $this->tip = 'SKILL ' . $name; $this->tipTime = 2.5; return; } switch ($name) { case 'minus': $this->world->changeRate(-10); $this->sound()->playSfx('select'); break; case 'plus': $this->world->changeRate(10); $this->sound()->playSfx('select'); break; case 'pause': $this->scenes()->push(PauseScene::class, ['level' => $this->level]); break; case 'fast': $this->fast = !$this->fast; $this->sound()->playSfx('select'); break; case 'nuke': if ($this->nukeArmed > 0) { $this->world->nuke(); $this->nukeArmed = 0; } else { $this->nukeArmed = 2.0; $this->sound()->playSfx('select'); $this->tip = 'NUKE CONFIRM'; $this->tipTime = 2.0; } break; } } /** A click / tap in the level (view coordinates): gives the selected skill to the Lumikin there. */ public function clickAt(float $vx, float $vy): bool { [$x, $y] = $this->toLevel($vx, $vy); $l = $this->world->pick($x, $y, $this->skill, Platform::isMobile() ? 12 : 8); if ($l === null) { return false; } if (!$this->world->assign($l, $this->skill)) { $this->sound()->playSfx('deny'); } return true; } public function update(float $dt): void { $this->time += $dt; $this->banner = max(0, $this->banner - $dt); $this->nukeArmed = max(0, $this->nukeArmed - $dt); $this->tipTime = max(0, $this->tipTime - $dt); $a = $this->actions(); if (!$this->demo) { if ($a->isPressed('pause') || !$this->engine->lifecycle()->isVisible()) { $this->scenes()->push(PauseScene::class, ['level' => $this->level]); return; } $this->pointer(); foreach (World::SKILLS as $i => $skill) { if ($a->isPressed('skill' . ($i + 1))) { $this->pressButton($skill); } } if ($a->isPressed('turn_left')) { $this->turn(-1); } if ($a->isPressed('turn_right')) { $this->turn(1); } foreach (['fast' => 'fast', 'rate_up' => 'plus', 'rate_down' => 'minus', 'nuke' => 'nuke'] as $action => $button) { if ($a->isPressed($action)) { $this->pressButton($button); } } $this->camX = $this->clampCam($this->camX + $a->axis('left', 'right') * 260 * $dt / $this->zoom); } $this->acc = min(0.4, $this->acc + $dt * ($this->fast || ($this->demo && $this->world->spawned >= $this->world->total) ? 3 : 1)); while ($this->acc >= self::TICK && !$this->ended) { $this->acc -= self::TICK; $this->world->step(); $this->auto?->step(); if ($this->world->isOver()) { $this->ended = true; } } $this->view()->consume(!$this->demo || $this->scenes()->current() === $this); $this->view()->update($dt); $this->updateYaw($dt); if ($this->demo) { // the camera follows the Lumikin in front $lead = null; foreach ($this->world->lumis as $l) { if ($l->alive && !$l->is(Lumi::STOP) && ($lead === null || $l->x > $lead->x)) { $lead = $l; } } $target = $this->clampCam(($lead->x ?? $this->world->hatch[0]) - 240); $this->camX += ($target - $this->camX) * min(1, $dt * 2.5); } if ($this->ended) { $this->endTimer += $dt; if ($this->endTimer > 1.2 && $this->scenes()->current() === $this) { $this->finish(); } } } private function finish(): void { if ($this->demo) { $this->enter(['level' => ($this->level + 1) % Levels::count(), 'demo' => true]); return; } $percent = (int)floor($this->world->saved * 100 / $this->world->total); $won = $this->world->isWon(); if ($won) { $this->progress->won($this->engine, $this->level, $percent); } $this->endTimer = -1e9; $this->scenes()->push(ResultScene::class, [ 'level' => $this->level, 'won' => $won, 'saved' => $this->world->saved, 'total' => $this->world->total, 'need' => $this->world->need, ]); } /** A touch position (0..1 of the window, what SDL gives) as a view point, through the letterbox. @return array{0: float, 1: float} */ private function touchToView(float $fx, float $fy): array { $r = $this->sdl()->getRenderer(); [$ow, $oh] = $r->outputSize(); [$lx, $ly, $lw, $lh] = $r->letterbox(); if ($lw <= 0 || $lh <= 0 || $ow <= 0) { return [$fx * $this->width(), $fy * $this->height()]; } return [($fx * $ow - $lx) * $this->width() / $lw, ($fy * $oh - $ly) * $this->height() / $lh]; } private function pointer(): void { $m = $this->engine->input->getMouse(); if ($m->x != 0 || $m->y != 0) { $this->mouseSeen = true; } $viewButton = $this->viewButtonAt($m->x, $m->y); $inLevel = $m->y < self::HUD_Y && $viewButton === null; if ($inLevel && !$this->dragging && !Platform::isMobile()) { [$hx, $hy] = $this->toLevel($m->x, $m->y); $this->hover = $this->world->pick($hx, $hy, $this->skill); } else { $this->hover = null; } if ($m->isPressed(1)) { if ($viewButton !== null) { $this->turn($viewButton === 'turn_left' ? -1 : 1); } elseif (!$inLevel) { $button = $this->buttonAt($m->x, $m->y); if ($button !== null) { $this->pressButton($button); } } elseif (Platform::isMobile()) { // a finger: the skill is given when it lifts without having moved (else it was a drag or a pinch) $this->tapPending = !$this->pinchLock; $this->dragging = true; $this->dragFrom = $m->x; $this->dragCam = $this->camX; $this->dragFromY = $m->y; $this->dragCamY = $this->camY; $this->dragLevel = $this->is3d() ? $this->toLevel($m->x, $m->y) : null; } elseif (!$this->clickAt($m->x, $m->y)) { $this->dragging = true; $this->dragFrom = $m->x; $this->dragCam = $this->camX; $this->dragFromY = $m->y; $this->dragCamY = $this->camY; $this->dragLevel = $this->is3d() ? $this->toLevel($m->x, $m->y) : null; } } if ($this->pinch?->active()) { $this->pinchLock = true; $this->tapPending = false; } if ($this->tapPending && abs($m->x - $this->dragFrom) + abs($m->y - $this->dragFromY) > 6) { $this->tapPending = false; // it moved: a drag, not a tap } if ($this->tapPending && $m->isReleased(1)) { $this->tapPending = false; $this->dragging = false; $this->clickAt($this->dragFrom, $this->dragFromY); } if ($this->dragging && ($this->pinchLock || $this->tapPending)) { if (!$m->isHeld(1)) { $this->dragging = false; } } elseif ($this->dragging) { if ($m->isHeld(1) && !$this->pinch?->active() && $this->dragLevel !== null && $this->is3d()) { // turned view: the grabbed level point is cast again under the pointer, the camera moves by the difference [$px, $py] = $this->toLevel($m->x, $m->y); $this->camY += $this->dragLevel[1] - $py; $this->camX = $this->clampCam($this->camX + $this->dragLevel[0] - $px); } elseif ($m->isHeld(1) && !$this->pinch?->active()) { $this->camY = $this->dragCamY - ($m->y - $this->dragFromY) / $this->zoom; $this->camX = $this->clampCam($this->dragCam - ($m->x - $this->dragFrom) / $this->zoom); } else { $this->dragging = false; } } elseif ($this->mouseSeen && $inLevel && !Platform::isMobile()) { if ($m->x >= WorldView::VIEW_W - 6) { $this->camX = $this->clampCam($this->camX + 4); } elseif ($m->x <= 5) { $this->camX = $this->clampCam($this->camX - 4); } } $keys = $this->engine->input->getKeyboard(); if ($m->wheelY != 0) { if ($keys->isKeyHeld(\SDLK_LCTRL) || $keys->isKeyHeld(\SDLK_RCTRL)) { $this->zoomAt($m->wheelY > 0 ? 1.25 : 0.8, $m->x, min($m->y, self::HUD_Y)); // Ctrl + wheel: zoom on the pointer } else { $this->camX = $this->clampCam($this->camX - $m->wheelY * 40 / $this->zoom); } } if ($this->pinch !== null) { [$factor, $px, $py] = $this->pinch->take(); if ($factor != 1.0 && $this->pinch->active()) { [$vx, $vy] = $this->touchToView($px, $py); $this->zoomAt(max(0.8, min(1.25, $factor)), max(0, min(WorldView::VIEW_W, $vx)), max(0, min(self::HUD_Y, $vy))); } $this->idleFrames = $m->isHeld(1) ? 0 : $this->idleFrames + 1; if ($this->idleFrames > 10) { $this->pinch->reset(); } } if (!$m->isHeld(1) && !$this->pinch?->active()) { $this->pinchLock = false; } $a = $this->actions(); if ($a->isHeld('up') || $a->isHeld('down')) { // keys: zoom on the middle of the view $this->zoomAt($a->isHeld('up') ? 1.03 : 0.97, WorldView::VIEW_W / 2, self::HUD_Y / 2); } if (!$inLevel && !Platform::isMobile() && ($b = $this->buttonAt($m->x, $m->y)) !== null && in_array($b, World::SKILLS, true) && $this->tipTime <= 0.1) { $this->tip = 'SKILL ' . $b; $this->tipTime = 0.1; } } public function render(Renderer $r): void { $w = $this->world; $hoverOk = $this->hover !== null && $w->canAssign($this->hover, $this->skill); $this->layer($this->is3d()); if ($this->is3d()) { $this->view()->render3d($r, $this->camera3d(), $this->camX, $this->camY, $this->hover, $hoverOk); } else { $this->view()->render($r, $this->camX, $this->hover, $hoverOk, $this->zoom, $this->camY); } if ($this->demo) { return; } $e = $this->engine; $f = $this->fonts; $sdl = $this->sdl(); // top line: who is out, who is home, the clock $seconds = (int)ceil($w->ticksLeft / World::TICKS_PER_SECOND); $f->text($e, $r, 'OUT {n}', 6, 3, 1, [255, 255, 255], 'left', ['n' => $w->alive()]); $homeColor = $w->isWon() ? [120, 255, 150] : [255, 210, 60]; $f->text($e, $r, 'HOME {n}/{need}', 240, 3, 1, $homeColor, 'center', ['n' => $w->saved, 'need' => $w->need]); $f->text($e, $r, sprintf('%d:%02d', intdiv($seconds, 60), $seconds % 60), 474, 3, 1, $seconds < 30 ? [255, 110, 90] : [255, 255, 255], 'right'); // the arrows that turn the camera (greyed without GL) $can = $this->can3d(); foreach (self::viewButtons() as $name => [$x, $y, $bw, $bh]) { $on = $can && (($name === 'turn_left' && $this->yawTarget < 0) || ($name === 'turn_right' && $this->yawTarget > 0)); $this->art->draw($r, 'btn-small' . ($on ? '-on' : ''), null, $x, $y); $this->art->icon($r, $e, $name, $x + $bw / 2 - 6, $y + $bh / 2 - 6, $can ? '' : '-dim', 1.0); } // the bar $this->art->draw($r, 'hud', null, 0, self::HUD_Y); foreach (self::buttons() as $name => [$x, $y, $bw, $bh]) { $isSkill = in_array($name, World::SKILLS, true); if ($isSkill) { $n = $w->skills[$name]; $on = $this->skill === $name; $this->art->draw($r, $on ? 'btn-on' : ($n > 0 ? 'btn' : 'btn-off'), null, $x, $y); $this->art->icon($r, $e, $name, $x + 7, $y + 1, $n > 0 ? '' : '-dim', 1.5); $f->print($e, $r, (string)$n, $x + 16, $y + 18, 1, $n > 0 ? [255, 255, 255] : [110, 100, 140], 'center', [20, 12, 40]); continue; } $on = ($name === 'fast' && $this->fast) || ($name === 'nuke' && $this->nukeArmed > 0 && (int)($this->time * 8) % 2 === 0) || ($name === 'nuke' && $w->nuked); $small = $bh < 20; $this->art->draw($r, ($small ? 'btn-small' : 'btn') . ($on ? '-on' : ''), null, $x, $y); $k = $small ? 1.0 : 1.5; $this->art->icon($r, $e, $name, $x + $bw / 2 - 6 * $k, $y + $bh / 2 - 6 * $k, '', $k); } $f->print($e, $r, (string)$w->rate, 315, 252, 1, [255, 210, 60], 'center', [20, 12, 40]); $r->fillRect(452, 238, 24, 26, [12, 8, 24]); $r->fillRect(452, 238 + 26 * (1 - $w->saved / max(1, $w->total)), 24, 26 * $w->saved / max(1, $w->total), [255, 190, 60]); $r->fillRect(452, 238 + 26 * (1 - $w->need / max(1, $w->total)), 24, 1, [255, 255, 255]); $r->drawRect(451, 237, 26, 28, [92, 74, 148]); if ($this->tipTime > 0 && $this->banner <= 0) { $f->text($e, $r, $this->tip, 6, self::HUD_Y - 15, 1, [255, 240, 190]); } if ($this->banner > 0) { $k = min(1, $this->banner * 2, (3.2 - $this->banner) * 4); $r->fillRect(0, 78, 480, 62, [10, 6, 24, (int)(190 * $k)]); if ($k > 0.5) { $f->text($e, $r, 'LEVEL {n}', 240, 82, 1, [255, 210, 60], 'center', ['n' => $this->level + 1]); $f->text($e, $r, $this->def['name'], 240, 93, 1.8, [255, 255, 255], 'center'); $f->text($e, $r, $this->def['hint'], 240, 121, 1, [200, 230, 255], 'center'); } } } }