engine->options->set('graphics', $params['graphics']); } $this->art ??= Art::get($this->engine->config->path('assets/data/art.pak')); $this->screen ??= new Screen($this->engine); $this->hud ??= new Hud($this->art); $low = ($params['detail'] ?? $this->engine->options->get('detail', Platform::isMobile() ? 'low' : 'high')) === 'low'; $w = $low ? 160 : 320; if ($this->view === null || $this->view->w !== $w) { $this->view = new SoftRenderer($this->art, $w, self::VIEW_H); } $this->levelId = $params['level'] ?? 'm1'; $this->skill = (int)($params['skill'] ?? World::SKILL_NORMAL); $this->carry = $params['carry'] ?? null; $this->demo = !empty($params['demo']); $this->bot = $this->demo ? new Bot() : null; $this->quiet = !empty($params['quiet']); $this->profile = !empty($params['profile']); $this->startLevel(); SoundBank::load($this->sound(), $this->engine->config); if (!empty($params['god'])) { $this->world->player->god = true; } // tests, screenshots: start at a cell of the level text [column, row, degrees] if (isset($params['at'])) { [$col, $row, $deg] = $params['at']; $mo = $this->world->player->mo; $rows = count($this->world->level->data['map']); $mo->x = $mo->px = $col * 64 + 32; $mo->y = $mo->py = ($rows - $row) * 64 - 32; $this->world->link($mo); $mo->z = $mo->pz = $mo->floorz = $mo->sector->floor; $mo->ceilingz = $mo->sector->ceil; $mo->angle = $this->world->player->pangle = deg2rad($deg); $this->world->player->viewz = $this->world->player->pviewz = $mo->z + 41; } if (!empty($params['weapons'])) { $p = $this->world->player; $p->weapons = [1 => true, 2 => true, 3 => true, 4 => true, 5 => true]; $p->ammo = ['bullets' => 200, 'shells' => 50, 'cells' => 300]; } } private function startLevel(): void { $level = Campaign::level($this->engine->config->path("assets/maps/{$this->levelId}.json"), $this->levelId); $this->world = new World($level, $this->skill, $this->carry, $this->demo ? 7 : (int)fmod(microtime(true) * 1000, 100000)); if ($this->demo) { $this->world->player->god = true; $this->world->player->weapons = [1 => true, 2 => true, 3 => true, 4 => true, 5 => false]; $this->world->player->ammo = ['bullets' => 200, 'shells' => 50, 'cells' => 0]; } $this->step = new FixedStep(World::TICRATE, 4); $this->automap = false; $this->deadTics = 0; $this->done = false; $this->wantWeapon = null; $this->wantUse = false; $this->world->message($level->name); } public function exit(): void { Graphics::retro($this->engine); } public function world(): World { return $this->world; } public function hotState(): array { $mo = $this->world->player->mo; return ['level' => $this->levelId, 'skill' => $this->skill, 'x' => $mo->x, 'y' => $mo->y, 'angle' => $mo->angle, 'carry' => $this->world->player->carry()]; } public function hotRestore(array $state): void { $this->levelId = $state['level']; $this->skill = $state['skill']; $this->carry = $state['carry']; $this->startLevel(); $mo = $this->world->player->mo; if ($this->world->tryMove($mo, $state['x'], $state['y'])) { $mo->px = $mo->x; $mo->py = $mo->y; } $mo->angle = $state['angle']; } // ---------------------------------------------------------------------------------------- update public function update(float $dt): void { $a = $this->actions(); if (!$this->demo && $a->isPressed('pause')) { $this->scenes()->push(PauseScene::class); return; } if ($this->demo && ($a->isPressed('confirm') || $a->isPressed('back')) && $this->scenes()->current() === $this) { $this->scenes()->switchTo(TitleScene::class); return; } for ($n = 1; $n <= 5; $n++) { if ($a->isPressed("weapon$n")) { $this->wantWeapon = $n; } } if ($a->isPressed('next_weapon')) { $p = $this->world->player; for ($i = 1; $i <= 5; $i++) { $n = ($p->weapon + $i - 1) % 5 + 1; if (!empty($p->weapons[$n])) { $this->wantWeapon = $n; break; } } } if ($a->isPressed('use')) { $this->wantUse = true; } if ($a->isPressed('map')) { $this->automap = !$this->automap; } $this->step->run($dt, fn () => $this->tic()); } private function tic(): void { if ($this->done) { return; } $w = $this->world; $cmd = $this->bot !== null ? $this->bot->cmd($w) : $this->buildCmd(); $w->tic($cmd); $this->art->animate($w->tic); $this->playSounds(); if ($w->player->dead) { $this->deadTics++; if ($this->deadTics > 35 && ($cmd['use'] || $cmd['fire'])) { $this->startLevel(); } if ($this->demo && $this->deadTics > 70) { $this->startLevel(); } return; } if ($w->exit !== '') { $this->done = true; if ($this->demo) { $this->startLevel(); return; } $this->sound()->playSfx('switch'); $this->scenes()->switchTo(IntermissionScene::class, [ 'level' => $this->levelId, 'name' => $w->level->name, 'stats' => $w->stats(), 'skill' => $this->skill, 'carry' => $w->player->carry(), 'next' => Campaign::next($this->levelId), ]); } } private function buildCmd(): array { $a = $this->actions(); $run = $a->isHeld('run'); $speed = $run ? 2.0 : 1.0; $strafe = $a->isHeld('strafe'); $forward = ($a->isHeld('up') ? 1 : 0) - ($a->isHeld('down') ? 1 : 0); $side = ($a->isHeld('strafe_right') ? 1 : 0) - ($a->isHeld('strafe_left') ? 1 : 0); $turnDir = ($a->isHeld('left') ? 1 : 0) - ($a->isHeld('right') ? 1 : 0); $turn = 0.0; if ($strafe) { $side -= $turnDir; $turnDir = 0; } if ($turnDir !== 0) { $this->turnHeld++; // the original: slow for the first tics (fine aiming), then fast $turn = $turnDir * ($this->turnHeld < 6 ? 0.0307 : ($run ? 0.1227 : 0.0614)); } else { $this->turnHeld = 0; } // analog stick, when there is one $pad = $this->engine->input->getGamepad(); if ($pad->count() > 0) { $rx = $pad->axis(0, 'rx'); if (abs($rx) > 0.1) { $turn -= $rx * 0.1; } } $cmd = [ 'forward' => max(-1, min(1, $forward)) * $speed, 'side' => max(-1, min(1, $side)) * ($run ? 1.67 : 1.0), 'turn' => $turn, 'fire' => $a->isHeld('fire') || (!Platform::isMobile() && $this->engine->input->getMouse()->isHeld(1)), 'use' => $this->wantUse || $a->isHeld('use'), 'weapon' => $this->wantWeapon, ]; $this->wantUse = false; $this->wantWeapon = null; return $cmd; } private function playSounds(): void { $mo = $this->world->player->mo; foreach ($this->world->sounds as [$name, $x, $y]) { if ($x !== null && hypot($x - $mo->x, $y - $mo->y) > 1500) { continue; } $this->sound()->playSfx($name); } } // ---------------------------------------------------------------------------------------- render public function render(Renderer $r): void { $t0 = microtime(true); $w = $this->world; $p = $w->player; $mo = $p->mo; $alpha = $this->step->alpha(); $x = $mo->px + ($mo->x - $mo->px) * $alpha; $y = $mo->py + ($mo->y - $mo->py) * $alpha; $z = $p->pviewz + ($p->viewz - $p->pviewz) * $alpha; $da = fmod($mo->angle - $p->pangle + 5 * M_PI, 2 * M_PI) - M_PI; $angle = $p->pangle + $da * $alpha; // damage and pickup flashes (the original swapped palettes) $tint = null; if ($p->damagecount > 0) { $tint = [255, 0, 0, min(0.75, (intdiv($p->damagecount + 7, 8)) * 0.09)]; } elseif ($p->bonuscount > 0) { $tint = [255, 220, 80, 0.12 + min(0.15, $p->bonuscount * 0.02)]; } elseif ($p->dead) { $tint = [255, 0, 0, 0.25]; } $weapon = null; if (!$p->dead || $p->wy < Weapons::LOWERED) { [$sprite, $frame] = $w->weaponFrame(); $pic = $this->art->weapons[$sprite][$frame] ?? $this->art->weapons[$sprite]['A']; $weapon = [$pic, $p->wx, $p->wy + ($p->dead ? 20 : 0), $p->flashTics > 0 && $frame !== 'A', "$sprite$frame"]; } $hd = Graphics::hd($this->engine); if ($hd !== null && !$this->automap) { $r->clear([255, 0, 255]); $hd->setTint($tint); $things = HdView::things($w, $alpha, $x, $y, $angle); $lights = HdView::lights($w, $x, $y, $z, $angle); $hdWeapon = $weapon === null ? null : [$weapon[0], $weapon[1], $weapon[2], ($mo->sector->light / 255) * 1.15, $weapon[3], $weapon[4]]; $hd->render($w->level, $x, $y, $z, $angle, $things, $lights, $hdWeapon); $this->drawHdHud($r); } else { Graphics::retro($this->engine); $view = $this->view; $view->alpha = $alpha; $view->extraLight = $p->flashTics > 0 ? 1 : 0; $view->render($w->level, $x, $y, $z, $angle, $mo); if ($weapon !== null) { $level = $weapon[3] ? 0 : max(0, (15 - min(15, ($mo->sector->light >> 4) + 1)) * 4 - 23); $view->drawWeapon($weapon[0], $weapon[1], $weapon[2], $level); } $this->screen->setTint($tint); $this->screen->present($r, $view->fb, $view->w, self::VIEW_H, $this->hud->bar($w), Hud::HEIGHT); if ($this->automap) { $this->drawAutomap($r); } } $font = $this->font(); if ($w->messageTics > 0 && $w->message !== '' && !$this->quiet) { $font->draw($r, $w->message, 2, 2, 1, [255, 230, 150], 'left', [0, 0, 0]); } if ($p->dead && $this->deadTics > 35 && !$this->demo) { $font->draw($r, 'PRESS USE TO TRY AGAIN', $this->width() / 2, 70, 1, [255, 255, 255], 'center', [0, 0, 0]); } if ($this->demo && !$this->quiet && $this->scenes()->current() === $this) { $font->draw($r, 'DEMO - PRESS ENTER', $this->width() / 2, 158, 1, [255, 200, 120], 'center', [0, 0, 0]); } $this->renderMs = $this->renderMs * 0.95 + (microtime(true) - $t0) * 1000 * 0.05; if ($this->profile && $w->tic % 35 === 0) { error_log(sprintf('render %.2f ms', $this->renderMs)); } if ($this->engine->isDebug()) { $st = $this->view->stats; $font->draw($r, sprintf('%s %.1f MS %d SEGS %d PL %d SPR', $hd !== null ? 'HD' : 'RETRO', $this->renderMs, $st['segs'] ?? 0, $st['planes'] ?? 0, $st['sprites'] ?? 0), 2, 60, 1, [120, 255, 120]); } } /** The HUD of the HD mode: translucent panels over the 3D, a crosshair. */ private function drawHdHud(Renderer $r): void { if ($this->quiet) { return; } $p = $this->world->player; $f = $this->font(); $h = $this->height(); $dark = [0, 0, 0, 150]; // crosshair $r->fillRect(159, 99, 2, 2, [235, 235, 235]); // health and armour $r->fillRect(6, $h - 30, 110, 24, $dark); $r->fillRect(12, $h - 22, 8, 2, [230, 40, 30]); $r->fillRect(15, $h - 25, 2, 8, [230, 40, 30]); $f->draw($r, (string)$p->health, 24, $h - 26, 2, $p->health > 25 ? [255, 255, 255] : [255, 80, 60], 'left', [0, 0, 0]); $r->fillRect(68, $h - 25, 7, 8, $p->armorType === 2 ? [70, 110, 255] : [80, 200, 80]); $f->draw($r, (string)$p->armor, 78, $h - 26, 2, [210, 220, 255], 'left', [0, 0, 0]); $f->draw($r, 'HEALTH', 12, $h - 12, 1, [170, 170, 170]); $f->draw($r, 'ARMOR', 68, $h - 12, 1, [170, 170, 170]); // ammunition of the weapon in the hands $wdef = Weapons::LIST[$p->weapon]; $r->fillRect(218, $h - 30, 96, 24, $dark); $ammo = $wdef['ammo'] === null ? '--' : (string)$p->ammo[$wdef['ammo']]; $f->draw($r, $ammo, 308, $h - 26, 2, [255, 210, 90], 'right', [0, 0, 0]); $f->draw($r, $wdef['name'], 308, $h - 12, 1, [170, 170, 170], 'right'); // keys $kx = 290; foreach (['red' => [230, 40, 30], 'blue' => [60, 110, 255], 'yellow' => [255, 220, 40]] as $k => $c) { if (!empty($p->keys[$k])) { $r->fillRect($kx, $h - 42, 7, 9, $c); $kx -= 10; } } } private function drawAutomap(Renderer $r): void { $w = $this->world; $mo = $w->player->mo; $r->fillRect(0, 0, 320, self::VIEW_H, [0, 0, 0]); $scale = 0.18; $cx = 160; $cy = self::VIEW_H / 2; $tx = fn (float $x) => $cx + ($x - $mo->x) * $scale; $ty = fn (float $y) => $cy - ($y - $mo->y) * $scale; foreach ($w->level->lines as $l) { if (!$l->mapped) { continue; } if ($l->backSector === null || $l->flags & Line::SECRET) { $c = [220, 40, 40]; } elseif ($l->frontSector->floor != $l->backSector->floor) { $c = [190, 130, 70]; } elseif ($l->frontSector->ceil != $l->backSector->ceil) { $c = [230, 220, 60]; } else { continue; } $r->drawLine($tx($l->x1), $ty($l->y1), $tx($l->x2), $ty($l->y2), $c); } // the player: an arrow $a = $mo->angle; $pts = [[10, 0], [-6, 5], [-3, 0], [-6, -5]]; $prev = null; foreach ([...$pts, $pts[0]] as [$px, $py]) { $sx = $cx + ($px * cos($a) - $py * sin($a)) * 0.8; $sy = $cy - ($px * sin($a) + $py * cos($a)) * 0.8; if ($prev !== null) { $r->drawLine($prev[0], $prev[1], $sx, $sy, [255, 255, 255]); } $prev = [$sx, $sy]; } $this->font()->draw($r, $w->level->name, 4, self::VIEW_H - 10, 1, [200, 200, 200]); } }