登录
ZH
games/doom-jumphp / src / Scene / PlayScene.php · commit 8e1cb9f
<?phpnamespace App\Scene;use App\Engine\Art;use App\Engine\Map\Line;use App\Engine\Screen;use App\Engine\SoftRenderer;use App\Game\Bot;use App\Game\Campaign;use App\Game\Graphics;use App\Game\HdView;use App\Game\Hud;use App\Game\SoundBank;use App\Game\Weapons;use App\Game\World;use JumPHP\Kernel\Platform;use JumPHP\Loop\FixedStep;use JumPHP\Renderer\Renderer;use JumPHP\Scene\AbstractScene;use JumPHP\Scene\HotReloadable;/** * A level being played: the world ticks at 35 Hz (FixedStep), the software renderer draws the view at every * frame (interpolated between tics), the status bar and the weapon on top. * * Params: level ('m1'...), skill (1..3), carry (what the player brings from the previous level), * demo (true: a bot plays: title screen and smoke test), detail ('high' | 'low'). */final class PlayScene extends AbstractScene implements HotReloadable{    public const VIEW_H = 168;    private ?Art $art = null;    private ?SoftRenderer $view = null;    private ?Screen $screen = null;    private ?Hud $hud = null;    private World $world;    private FixedStep $step;    private ?Bot $bot = null;    private string $levelId = 'm1';    private int $skill = World::SKILL_NORMAL;    private ?array $carry = null;    private bool $demo = false;    private bool $automap = false;    private int $turnHeld = 0;    private int $deadTics = 0;    /** one-shot inputs, kept until the next tic */    private ?int $wantWeapon = null;    private bool $wantUse = false;    private float $renderMs = 0.0;    private bool $done = false;    /** screenshots of the web site: no texts over the view */    private bool $quiet = false;    /** measures: the render time on stderr every second */    private bool $profile = false;    public function enter(array $params = []): void    {        if (isset($params['graphics'])) {            $this->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]);    }}