*/ public array $mobjs = []; /** @var array moving floors / ceilings, light effects */ public array $thinkers = []; public int $tic = 0; public int $skill; public int $totalKills = 0; public int $totalItems = 0; public int $totalSecrets = 0; /** sounds of this tic: [name, x, y] (x null = everywhere) */ public array $sounds = []; public string $message = ''; public int $messageTics = 0; /** '' while playing; 'exit' / 'secret' when the level is done */ public string $exit = ''; public int $exitDelay = 0; /** @var array> thing block map */ public array $blockThings = []; private int $nextId = 1; private int $rnd; public int $validCount = 0; private ?Sector $lastSector = null; public function __construct(Level $level, int $skill = self::SKILL_NORMAL, ?array $carry = null, int $seed = 1) { $this->level = $level; $this->skill = $skill; $this->rnd = abs($seed) % 65537; $this->player = new Player(); if ($carry !== null) { $this->player->restore($carry); } foreach ($level->things as $t) { if ($t['type'] === 'player') { $mo = $this->spawn('player', $t['x'], $t['y'], null); $mo->angle = $t['angle']; $mo->player = $this->player; $this->player->mo = $mo; $mo->health = $this->player->health; continue; } if (!empty($t['def']['skill']) && !in_array($skill, (array)$t['def']['skill'], true)) { continue; } $mo = $this->spawn($t['type'], $t['x'], $t['y'], null); $mo->angle = $t['angle']; if (!empty($t['def']['ambush'])) { $mo->flags |= Info::AMBUSH; } if ($mo->is(Info::COUNTKILL)) { $this->totalKills++; if ($skill === self::SKILL_EASY && $mo->type === 'warden') { $mo->health = (int)($mo->health * 0.7); } } if ($mo->is(Info::COUNTITEM)) { $this->totalItems++; } // the original's random start: monsters do not move in step if ($mo->tics > 0) { $mo->tics = 1 + $this->random() % $mo->tics; } } if (!isset($this->player->mo)) { throw new \RuntimeException('The level has no player start'); } foreach ($level->sectors as $s) { if ($s->secret) { $this->totalSecrets++; } } $this->spawnSpecials(); $this->player->wState = 'READY'; $this->player->viewz = $this->player->pviewz = $this->player->mo->z + Player::VIEWHEIGHT; $this->player->pangle = $this->player->mo->angle; $this->setWeaponState('UP'); $this->player->wy = Weapons::LOWERED; } /** P_Random: 0..255, deterministic (demos, tests). */ public function random(): int { // a small LCG (75 x + 74 mod 65537): exact with 32-bit integers (the browser) $this->rnd = ($this->rnd * 75 + 74) % 65537; return $this->rnd & 255; } public function sound(string $name, ?Mobj $origin = null): void { $this->sounds[] = [$name, $origin?->x, $origin?->y]; } public function message(string $text): void { $this->message = $text; $this->messageTics = 4 * self::TICRATE; } // --------------------------------------------------------------------------------------- things /** P_SpawnMobj. $z null = on the floor. */ public function spawn(string $type, float $x, float $y, ?float $z): Mobj { $mo = new Mobj($this->nextId++, $type, $x, $y, 0.0); $sector = $this->level->sectorAt($x, $y); $mo->floorz = $sector->floor; $mo->ceilingz = $sector->ceil; $mo->z = $mo->pz = $z ?? $sector->floor; if ($this->skill === self::SKILL_HARD && $mo->is(Info::COUNTKILL)) { $mo->reactiontime = (int)($mo->reactiontime / 2); } $this->mobjs[$mo->id] = $mo; $this->link($mo); // P_SpawnMobj: the first state without running its action $st = Info::STATES[$mo->info['spawn']]; $mo->state = $mo->info['spawn']; $mo->tics = $st[2]; $mo->sprite = $st[0]; $mo->frame = $st[1][0]; $mo->bright = strlen($st[1]) > 1; return $mo; } /** Puts a thing in its sector's list and the thing block map. */ public function link(Mobj $mo): void { $sector = $this->level->sectorAt($mo->x, $mo->y); if ($mo->sector !== $sector) { if ($mo->sector !== null) { unset($mo->sector->things[$mo->id]); } $mo->sector = $sector; $sector->things[$mo->id] = $mo; } $lv = $this->level; $bx = (int)floor(($mo->x - $lv->bmx) / Level::BLOCK); $by = (int)floor(($mo->y - $lv->bmy) / Level::BLOCK); $block = ($bx < 0 || $by < 0 || $bx >= $lv->bmw || $by >= $lv->bmh) ? -1 : $by * $lv->bmw + $bx; if ($block !== $mo->block) { if ($mo->block >= 0) { unset($this->blockThings[$mo->block][$mo->id]); } $mo->block = $block; if ($block >= 0) { $this->blockThings[$block][$mo->id] = $mo; } } } public function remove(Mobj $mo): void { if ($mo->removed) { return; } $mo->removed = true; unset($this->mobjs[$mo->id]); if ($mo->sector !== null) { unset($mo->sector->things[$mo->id]); } if ($mo->block >= 0) { unset($this->blockThings[$mo->block][$mo->id]); } } /** P_SetMobjState: enters a state, runs its action; zero-tic states chain. */ public function setState(Mobj $mo, ?string $state): bool { do { if ($state === null || $state === 'NULL' && $mo->type !== 'player') { $mo->state = 'NULL'; $this->remove($mo); return false; } $st = Info::STATES[$state]; $mo->state = $state; $mo->tics = $st[2]; $mo->sprite = $st[0]; $mo->frame = $st[1][0]; $mo->bright = strlen($st[1]) > 1; if ($st[3] !== null) { $this->{'a' . ucfirst($st[3])}($mo); if ($mo->removed) { return false; } } $state = $st[4]; // @phpstan-ignore-next-line (no state of the table has 0 tics today) } while ($mo->tics === 0 && $mo->state !== $state); return true; } // ----------------------------------------------------------------------------------------- tic /** * One tic (1/35 s) of the world. $cmd: forward (-1..1, 2 = run), side, turn (radians), fire, use, weapon (1..5|null). */ public function tic(array $cmd): void { $this->sounds = []; $this->tic++; foreach ($this->mobjs as $mo) { $mo->px = $mo->x; $mo->py = $mo->y; $mo->pz = $mo->z; } $p = $this->player; $p->pviewz = $p->viewz; $p->pangle = $p->mo->angle; $this->playerThink($cmd); foreach ($this->thinkers as $k => $t) { if ($t->done) { unset($this->thinkers[$k]); continue; } $this->runThinker($t); } foreach ($this->mobjs as $mo) { if (!$mo->removed) { $this->mobjThink($mo); } } $this->sectorSpecials(); if ($this->messageTics > 0) { $this->messageTics--; } if ($this->exitDelay > 0 && --$this->exitDelay === 0) { $this->exit = $this->exit === '' ? 'exit' : $this->exit; } } /** P_MobjThinker */ private function mobjThink(Mobj $mo): void { if ($mo->momx != 0 || $mo->momy != 0) { $this->xyMovement($mo); if ($mo->removed) { return; } } if ($mo->z != $mo->floorz || $mo->momz != 0) { $this->zMovement($mo); if ($mo->removed) { return; } } if ($mo->tics !== -1) { $mo->tics--; if ($mo->tics <= 0) { $this->setState($mo, Info::STATES[$mo->state][4]); } } } // -------------------------------------------------------------------------------------- player private function playerThink(array $cmd): void { $p = $this->player; $mo = $p->mo; if ($p->dead) { $this->deathThink($cmd); return; } // turning $mo->angle = fmod($mo->angle + ($cmd['turn'] ?? 0.0) + 4 * M_PI, 2 * M_PI); $onGround = $mo->z <= $mo->floorz; $forward = (float)($cmd['forward'] ?? 0); $side = (float)($cmd['side'] ?? 0); if ($onGround) { if ($forward != 0) { $this->thrust($mo, $mo->angle, $forward * 25 / 32); } if ($side != 0) { $this->thrust($mo, $mo->angle - M_PI / 2, $side * 24 / 32); } } $this->calcHeight($onGround); // the sector under the player $sector = $mo->sector; $entered = $sector !== $this->lastSector; $this->lastSector = $sector; if ($sector !== null && $mo->z <= $sector->floor) { $this->playerInSpecialSector($sector, $entered); } // weapon change $want = $cmd['weapon'] ?? null; if ($want !== null && $want !== $p->weapon && !empty($p->weapons[$want]) && $p->pending === null) { $p->pending = $want; } // use if (!empty($cmd['use'])) { if (!$p->useDown) { $this->useLines(); $p->useDown = true; } } else { $p->useDown = false; } $p->attackDown = !empty($cmd['fire']); $this->movePsprite(); if ($p->damagecount > 0) { $p->damagecount--; } if ($p->bonuscount > 0) { $p->bonuscount--; } if ($p->flashTics > 0) { $p->flashTics--; } } public function thrust(Mobj $mo, float $angle, float $move): void { $mo->momx += $move * cos($angle); $mo->momy += $move * sin($angle); } /** P_CalcHeight: view height, bobbing, smooth steps. */ private function calcHeight(bool $onGround): void { $p = $this->player; $mo = $p->mo; $p->bob = min(16.0, ($mo->momx ** 2 + $mo->momy ** 2) / 4); if (!$onGround) { $p->viewz = $mo->z + Player::VIEWHEIGHT; if ($p->viewz > $mo->ceilingz - 4) { $p->viewz = $mo->ceilingz - 4; } return; } $bob = $p->bob / 2 * sin(2 * M_PI * $this->tic / 20); $p->viewheight += $p->deltaviewheight; if ($p->viewheight > Player::VIEWHEIGHT) { $p->viewheight = Player::VIEWHEIGHT; $p->deltaviewheight = 0; } if ($p->viewheight < Player::VIEWHEIGHT / 2) { $p->viewheight = Player::VIEWHEIGHT / 2; if ($p->deltaviewheight <= 0) { $p->deltaviewheight = 1 / 4; } } if ($p->deltaviewheight != 0) { $p->deltaviewheight += 1 / 4; if ($p->deltaviewheight == 0) { $p->deltaviewheight = 1 / 1024; } } $p->viewz = $mo->z + $p->viewheight + $bob; if ($p->viewz > $mo->ceilingz - 4) { $p->viewz = $mo->ceilingz - 4; } } private function deathThink(array $cmd): void { $p = $this->player; $mo = $p->mo; $this->movePsprite(); if ($p->viewheight > 6) { $p->viewheight -= 1; } $p->viewz = $mo->z + $p->viewheight; $p->bob = 0; if ($p->attacker !== null && $p->attacker !== $mo && !$p->attacker->removed) { $want = atan2($p->attacker->y - $mo->y, $p->attacker->x - $mo->x); $delta = fmod($want - $mo->angle + 3 * M_PI, 2 * M_PI) - M_PI; if (abs($delta) < 0.1) { $mo->angle = $want; } else { $mo->angle += $delta > 0 ? 0.087 : -0.087; } } if ($p->damagecount > 0) { $p->damagecount--; } } /** Fraction of the level done, for the intermission. */ public function stats(): array { $p = $this->player; return [ 'kills' => $this->totalKills ? (int)round($p->kills * 100 / $this->totalKills) : 100, 'items' => $this->totalItems ? (int)round($p->items * 100 / $this->totalItems) : 100, 'secrets' => $this->totalSecrets ? (int)round($p->secrets * 100 / $this->totalSecrets) : 100, 'time' => intdiv($this->tic, self::TICRATE), 'par' => $this->level->par, ]; } /** The sector things of a sector's bounding area (for moving floors / ceilings). @return Mobj[] */ public function thingsTouching(Sector $s): array { $out = []; foreach ($this->mobjs as $mo) { if ($mo->sector === $s) { $out[] = $mo; continue; } // a thing standing over the edge foreach ($s->lines as $l) { if ($mo->x + $mo->radius > $l->bbox[2] && $mo->x - $mo->radius < $l->bbox[3] && $mo->y + $mo->radius > $l->bbox[1] && $mo->y - $mo->radius < $l->bbox[0] && $this->boxOnLineSide($mo->y + $mo->radius, $mo->y - $mo->radius, $mo->x - $mo->radius, $mo->x + $mo->radius, $l) === -1) { $out[] = $mo; break; } } } return $out; } }