x - $b->x); $dy = abs($a->y - $b->y); return $dx + $dy - min($dx, $dy) / 2; } // ----------------------------------------------------------------------------------- monsters private function lookForPlayer(Mobj $actor, bool $allAround): bool { $p = $this->player; if ($p->dead || $p->mo->health <= 0) { return false; } if (!$this->checkSight($actor, $p->mo)) { return false; } if (!$allAround) { $an = fmod(atan2($p->mo->y - $actor->y, $p->mo->x - $actor->x) - $actor->angle + 4 * M_PI, 2 * M_PI); if ($an > M_PI / 2 && $an < 3 * M_PI / 2 && $this->dist($p->mo, $actor) > self::MELEERANGE) { return false; // behind its back } } $actor->target = $p->mo; return true; } private function aLook(Mobj $actor): void { $actor->threshold = 0; $targ = $actor->sector?->soundTarget; $see = false; if ($targ !== null && $targ->is(Info::SHOOTABLE)) { $actor->target = $targ; $see = !$actor->is(Info::AMBUSH) || $this->checkSight($actor, $targ); } if (!$see && !$this->lookForPlayer($actor, false)) { return; } if ($actor->info['seeSound'] !== null) { $this->sound($actor->info['seeSound'], $actor->is(Info::BOSS) ? null : $actor); } $this->setState($actor, $actor->info['see']); } private function aChase(Mobj $actor): void { if ($actor->reactiontime > 0) { $actor->reactiontime--; } if ($actor->threshold > 0) { if ($actor->target === null || $actor->target->health <= 0) { $actor->threshold = 0; } else { $actor->threshold--; } } // turn towards the movement direction if ($actor->movedir < 8) { $want = $actor->movedir * M_PI / 4; $actor->angle = round($actor->angle / (M_PI / 4)) * (M_PI / 4); $delta = fmod($actor->angle - $want + 5 * M_PI, 2 * M_PI) - M_PI; if ($delta > 0.01) { $actor->angle -= M_PI / 4; } elseif ($delta < -0.01) { $actor->angle += M_PI / 4; } $actor->angle = fmod($actor->angle + 2 * M_PI, 2 * M_PI); } $t = $actor->target; if ($t === null || !$t->is(Info::SHOOTABLE) || $t->removed) { if ($this->lookForPlayer($actor, true)) { return; } $this->setState($actor, $actor->info['spawn']); return; } if ($actor->is(Info::JUSTATTACKED)) { $actor->flags &= ~Info::JUSTATTACKED; if ($this->skill !== self::SKILL_HARD) { $this->newChaseDir($actor); } return; } if ($actor->info['melee'] !== null && $this->checkMeleeRange($actor)) { if ($actor->info['attackSound'] !== null) { $this->sound($actor->info['attackSound'], $actor); } $this->setState($actor, $actor->info['melee']); return; } if ($actor->info['missile'] !== null && !($this->skill !== self::SKILL_HARD && $actor->movecount > 0) && $this->checkMissileRange($actor)) { $this->setState($actor, $actor->info['missile']); $actor->flags |= Info::JUSTATTACKED; return; } if (--$actor->movecount < 0 || !$this->moveActor($actor)) { $this->newChaseDir($actor); } if ($actor->info['activeSound'] !== null && $this->random() < 3) { $this->sound($actor->info['activeSound'], $actor); } } private function checkMeleeRange(Mobj $actor): bool { $t = $actor->target; if ($t === null) { return false; } if ($this->dist($actor, $t) >= self::MELEERANGE - 20 + $t->radius) { return false; } return $this->checkSight($actor, $t); } private function checkMissileRange(Mobj $actor): bool { if (!$this->checkSight($actor, $actor->target)) { return false; } if ($actor->is(Info::JUSTHIT)) { $actor->flags &= ~Info::JUSTHIT; return true; } if ($actor->reactiontime > 0) { return false; } $d = $this->dist($actor, $actor->target) - 64; if ($actor->info['melee'] === null) { $d -= 128; } if ($actor->is(Info::BOSS)) { $d /= 2; } $d = min(200, $d); return $this->random() >= $d; } /** P_Move: a step along movedir; opens doors in the way. */ private function moveActor(Mobj $actor): bool { if ($actor->movedir === self::NODIR) { return false; } $speed = $actor->info['speed']; $tx = $actor->x + $speed * self::DIR_X[$actor->movedir]; $ty = $actor->y + $speed * self::DIR_Y[$actor->movedir]; if (!$this->tryMove($actor, $tx, $ty)) { if (!$this->specHit) { return false; } $actor->movedir = self::NODIR; $good = false; foreach ($this->specHit as $l) { if ($this->useSpecialLine($actor, $l)) { $good = true; } } return $good; } $actor->z = $actor->floorz; return true; } private function tryWalk(Mobj $actor): bool { if (!$this->moveActor($actor)) { return false; } $actor->movecount = $this->random() & 15; return true; } /** P_NewChaseDir */ private function newChaseDir(Mobj $actor): void { $t = $actor->target; $old = $actor->movedir; $turnaround = self::OPPOSITE[$old]; $dx = $t->x - $actor->x; $dy = $t->y - $actor->y; $d1 = $dx > 10 ? 0 : ($dx < -10 ? 4 : self::NODIR); $d2 = $dy < -10 ? 6 : ($dy > 10 ? 2 : self::NODIR); if ($d1 !== self::NODIR && $d2 !== self::NODIR) { $actor->movedir = self::DIAGS[(($dy < 0) ? 2 : 0) + ($dx > 0 ? 1 : 0)]; if ($actor->movedir !== $turnaround && $this->tryWalk($actor)) { return; } } if ($this->random() > 200 || abs($dy) > abs($dx)) { [$d1, $d2] = [$d2, $d1]; } if ($d1 === $turnaround) { $d1 = self::NODIR; } if ($d2 === $turnaround) { $d2 = self::NODIR; } foreach ([$d1, $d2] as $d) { if ($d !== self::NODIR) { $actor->movedir = $d; if ($this->tryWalk($actor)) { return; } } } if ($old !== self::NODIR) { $actor->movedir = $old; if ($this->tryWalk($actor)) { return; } } $dirs = ($this->random() & 1) ? range(0, 7) : range(7, 0); foreach ($dirs as $d) { if ($d !== $turnaround) { $actor->movedir = $d; if ($this->tryWalk($actor)) { return; } } } if ($turnaround < self::NODIR) { $actor->movedir = $turnaround; // @phpstan-ignore-next-line if ($this->tryWalk($actor)) { return; } } $actor->movedir = self::NODIR; } private function aFaceTarget(Mobj $actor): void { if ($actor->target === null) { return; } $actor->flags &= ~Info::AMBUSH; $actor->angle = atan2($actor->target->y - $actor->y, $actor->target->x - $actor->x); } private function aHuskShoot(Mobj $actor): void { if ($actor->target === null) { return; } $this->aFaceTarget($actor); [$slope] = $this->aimLineAttack($actor, $actor->angle, self::MISSILERANGE); $this->sound('pistol', $actor); $angle = $actor->angle + ($this->random() - $this->random()) / 255 * 0.39; $this->lineAttack($actor, $angle, self::MISSILERANGE, $slope, (($this->random() % 5) + 1) * 3); } private function aBite(Mobj $actor): void { if ($actor->target === null) { return; } $this->aFaceTarget($actor); if ($this->checkMeleeRange($actor)) { $this->damage($actor->target, $actor, $actor, (($this->random() % 10) + 1) * 4); } } private function aSpitAttack(Mobj $actor): void { if ($actor->target === null) { return; } $this->aFaceTarget($actor); if ($this->checkMeleeRange($actor)) { $this->sound('claw', $actor); $this->damage($actor->target, $actor, $actor, (($this->random() % 8) + 1) * 3); return; } $this->spawnMissile($actor, $actor->target, 'acidball'); } private function aWardenFire(Mobj $actor): void { if ($actor->target === null) { return; } $this->aFaceTarget($actor); $mo = $this->spawnMissile($actor, $actor->target, 'plasma', 60); if ($mo !== null) { // aims a little to the left of the cannon arm $mo->x += cos($actor->angle - M_PI / 2) * 10; $mo->y += sin($actor->angle - M_PI / 2) * 10; } } private function aPain(Mobj $actor): void { if ($actor->info['painSound'] !== null) { $this->sound($actor->info['painSound'], $actor); } } private function aScream(Mobj $actor): void { if ($actor->info['deathSound'] !== null) { $this->sound($actor->info['deathSound'], $actor->is(Info::BOSS) ? null : $actor); } } private function aFall(Mobj $actor): void { $actor->flags &= ~Info::SOLID; } private function aExplode(Mobj $actor): void { $this->radiusAttack($actor, $actor->target, 128); } /** The warden's death opens the way out: every "boss" sector of the level opens. */ private function aBossDeath(Mobj $actor): void { foreach ($this->mobjs as $mo) { if ($mo !== $actor && $mo->is(Info::BOSS) && $mo->health > 0) { return; } } foreach ($this->level->sectors as $s) { if (!empty($s->def['boss'])) { $this->openGate($s); } } $this->message('THE WARDEN IS DOWN. THE CORE IS OPEN!'); } /** P_SpawnMissile */ private function spawnMissile(Mobj $source, Mobj $dest, string $type, float $z = 32): ?Mobj { $mo = $this->spawn($type, $source->x, $source->y, $source->z + $z); if ($mo->info['seeSound'] !== null) { $this->sound($mo->info['seeSound'], $mo); } $mo->target = $source; $mo->angle = atan2($dest->y - $source->y, $dest->x - $source->x); $speed = $mo->info['speed']; $mo->momx = $speed * cos($mo->angle); $mo->momy = $speed * sin($mo->angle); $dist = max(1.0, $this->dist($dest, $source) / $speed); $mo->momz = ($dest->z + $dest->height / 2 - $mo->z) / $dist; $this->checkMissileSpawn($mo); return $mo->removed ? null : $mo; } private function checkMissileSpawn(Mobj $mo): void { $mo->tics = max(1, $mo->tics - ($this->random() & 3)); $mo->x += $mo->momx / 2; $mo->y += $mo->momy / 2; $mo->z += $mo->momz / 2; if (!$this->tryMove($mo, $mo->x, $mo->y)) { $this->explodeMissile($mo); } } private function explodeMissile(Mobj $mo): void { $mo->momx = $mo->momy = $mo->momz = 0.0; $mo->flags &= ~Info::MISSILE; if ($mo->info['deathSound'] !== null) { $this->sound($mo->info['deathSound'], $mo); } if ($this->setState($mo, $mo->info['death'])) { $mo->tics = max(1, $mo->tics - ($this->random() & 3)); } } private function spawnPuff(float $x, float $y, float $z): void { $mo = $this->spawn('puff', $x, $y, $z + (($this->random() - $this->random()) / 64)); $mo->momz = 1.0; $mo->tics = max(1, $mo->tics - ($this->random() & 3)); } private function spawnBlood(float $x, float $y, float $z, int $damage): void { $mo = $this->spawn('blood', $x, $y, $z + (($this->random() - $this->random()) / 64)); $mo->momz = 2.0; $mo->tics = max(1, $mo->tics - ($this->random() & 3)); if ($damage <= 12 && $damage >= 9) { $this->setState($mo, 'BLUD2'); } elseif ($damage < 9) { $this->setState($mo, 'BLUD3'); } } // ------------------------------------------------------------------------------- damage, death /** P_DamageMobj */ public function damage(Mobj $target, ?Mobj $inflictor, ?Mobj $source, int $damage): void { if (!$target->is(Info::SHOOTABLE) || $target->health <= 0) { return; } $p = $target->player; if ($p !== null && $this->skill === self::SKILL_EASY) { $damage >>= 1; } if ($inflictor !== null && !$target->is(Info::NOCLIP)) { $ang = atan2($target->y - $inflictor->y, $target->x - $inflictor->x); $thrust = $damage / 8 * 100 / $target->info['mass']; $target->momx += $thrust * cos($ang); $target->momy += $thrust * sin($ang); } if ($p !== null) { if ($p->god) { return; } if ($p->armorType > 0) { $saved = $p->armorType === 1 ? intdiv($damage, 3) : intdiv($damage, 2); if ($p->armor <= $saved) { $saved = $p->armor; $p->armorType = 0; } $p->armor -= $saved; $damage -= $saved; } $p->health = max(0, $p->health - $damage); $p->attacker = $source; $p->damagecount = min(100, $p->damagecount + $damage); } $target->health -= $damage; if ($target->health <= 0) { $this->kill($source, $target); return; } if ($this->random() < $target->info['painChance'] && $target->info['pain'] !== null) { $target->flags |= Info::JUSTHIT; $this->setState($target, $target->info['pain']); } elseif ($p !== null && $this->random() < 160) { $this->sound('plpain', $target); } $target->reactiontime = 0; if ($target->threshold <= 0 && $source !== null && $source !== $target && $p === null) { $target->target = $source; $target->threshold = 100; if ($target->state === $target->info['spawn'] || str_starts_with($target->state, substr($target->info['spawn'], 0, -1))) { if ($target->info['see'] !== null) { $this->setState($target, $target->info['see']); } } } } /** P_KillMobj */ private function kill(?Mobj $source, Mobj $target): void { $target->flags &= ~(Info::SHOOTABLE | Info::NOGRAVITY); $target->flags |= Info::CORPSE | Info::DROPOFF; $target->height /= 4; if ($source !== null && $source->player !== null && $target->is(Info::COUNTKILL)) { $source->player->kills++; } elseif ($target->is(Info::COUNTKILL) && $source !== null && $source->player === null) { // killed by another monster or a barrel: still counts for the player $this->player->kills++; } if ($target->player !== null) { $target->flags &= ~Info::SOLID; $target->player->dead = true; $target->player->health = 0; $this->sound('pldeath', $target); $this->setWeaponState('DOWN'); return; } if ($target->info['death'] !== null) { $this->setState($target, $target->info['death']); $target->tics = max(1, $target->tics - ($this->random() & 3)); } else { $this->remove($target); } if ($target->info['drop'] !== null) { $mo = $this->spawn($target->info['drop'], $target->x, $target->y, null); $mo->flags |= Info::DROPPED; } } // --------------------------------------------------------------------------------------- items /** P_TouchSpecialThing */ private function touchSpecial(Mobj $special, Mobj $toucher): void { $p = $toucher->player; if ($p === null || $toucher->health <= 0 || $p->dead) { return; } $delta = $special->z - $toucher->z; if ($delta > $toucher->height || $delta < -8) { return; } $sound = 'item'; $msg = ''; $dropped = $special->is(Info::DROPPED); switch ($special->type) { case 'medkit': if ($p->health >= 100) { return; } $msg = $p->health < 25 ? 'PICKED UP A MEDKIT THAT YOU REALLY NEED!' : 'PICKED UP A MEDKIT.'; $this->giveHealth($p, 25, 100); break; case 'stim': if ($p->health >= 100) { return; } $this->giveHealth($p, 10, 100); $msg = 'PICKED UP A STIMPACK.'; break; case 'vial': $this->giveHealth($p, 1, 200); $msg = 'PICKED UP A HEALTH VIAL.'; break; case 'orb': $this->giveHealth($p, 100, 200); $msg = 'OVERCHARGE!'; $sound = 'powerup'; break; case 'armor1': if (!$this->giveArmor($p, 1)) { return; } $msg = 'PICKED UP THE COMBAT VEST.'; break; case 'armor2': if (!$this->giveArmor($p, 2)) { return; } $msg = 'PICKED UP THE HEAVY PLATE!'; break; case 'shard': $p->armor = min(200, $p->armor + 1); if ($p->armorType === 0) { $p->armorType = 1; } $msg = 'PICKED UP AN ARMOR SHARD.'; break; case 'clip': if (!$this->giveAmmo($p, 'bullets', $dropped ? 5 : 10)) { return; } $msg = 'PICKED UP A CLIP.'; break; case 'ammobox': if (!$this->giveAmmo($p, 'bullets', 50)) { return; } $msg = 'PICKED UP A BOX OF BULLETS.'; break; case 'shells': if (!$this->giveAmmo($p, 'shells', 4)) { return; } $msg = 'PICKED UP 4 SHELLS.'; break; case 'shellbox': if (!$this->giveAmmo($p, 'shells', 20)) { return; } $msg = 'PICKED UP A BOX OF SHELLS.'; break; case 'cells': if (!$this->giveAmmo($p, 'cells', 40)) { return; } $msg = 'PICKED UP AN ENERGY CELL.'; break; case 'redkey': case 'bluekey': case 'yellowkey': $k = substr($special->type, 0, -3); $p->keys[$k] = true; $msg = 'PICKED UP THE ' . strtoupper($k) . ' KEYCARD.'; break; case 'scattergun': $msg = 'YOU GOT THE SCATTERGUN!'; $this->giveWeapon($p, 3, 'shells', 8); $sound = 'weapon'; break; case 'riveter': $msg = 'YOU GOT THE RIVETER!'; $this->giveWeapon($p, 4, 'bullets', 20); $sound = 'weapon'; break; case 'iongun': $msg = 'YOU GOT THE ION LAUNCHER!'; $this->giveWeapon($p, 5, 'cells', 40); $sound = 'weapon'; break; default: return; } if ($special->is(Info::COUNTITEM)) { $p->items++; } $this->remove($special); $p->bonuscount += 6; $this->message($msg); $this->sound($sound); } private function giveHealth(Player $p, int $n, int $max): void { $p->health = min($max, $p->health + $n); $p->mo->health = $p->health; } private function giveArmor(Player $p, int $type): bool { $points = $type * 100; if ($p->armor >= $points) { return false; } $p->armorType = $type; $p->armor = $points; return true; } private function giveAmmo(Player $p, string $ammo, int $n): bool { if ($p->ammo[$ammo] >= Player::AMMO_MAX[$ammo]) { return false; } if ($this->skill === self::SKILL_EASY) { $n *= 2; } $had = $p->ammo[$ammo]; $p->ammo[$ammo] = min(Player::AMMO_MAX[$ammo], $had + $n); // the original switches to a better weapon when the ammunition of an empty one comes back if ($had === 0 && $p->weapon === 1 || ($had === 0 && Weapons::LIST[$p->weapon]['ammo'] === $ammo)) { foreach ([5, 4, 3, 2] as $w) { if (!empty($p->weapons[$w]) && Weapons::LIST[$w]['ammo'] === $ammo) { $p->pending = $w; break; } } } return true; } private function giveWeapon(Player $p, int $w, string $ammo, int $n): void { $new = empty($p->weapons[$w]); $p->weapons[$w] = true; $this->giveAmmo($p, $ammo, $n); if ($new) { $p->pending = $w; } } // ------------------------------------------------------------------------ the weapon in the hands public function setWeaponState(string $state): void { $p = $this->player; $guard = 0; do { $st = Weapons::STATES[$state]; $p->wState = $state; $p->wTics = $st[1]; if ($st[2] !== null) { $before = $p->wState; $this->{'w' . ucfirst($st[2])}(); if ($p->wState !== $before) { return; // the action changed the state itself } } $state = $st[3]; } while ($p->wTics === 0 && ++$guard < 8); } private function movePsprite(): void { $p = $this->player; if ($p->wTics > 0 && --$p->wTics === 0) { $this->setWeaponState(Weapons::STATES[$p->wState][3]); } } /** The frame of the weapon to draw: [sprite, frame letter]. */ public function weaponFrame(): array { $p = $this->player; return [Weapons::LIST[$p->weapon]['sprite'], Weapons::STATES[$p->wState][0]]; } private function hasAmmo(): bool { $p = $this->player; $w = Weapons::LIST[$p->weapon]; return $w['ammo'] === null || $p->ammo[$w['ammo']] >= $w['per']; } /** P_CheckAmmo: out of ammunition, the best weapon left comes up. */ private function checkAmmo(): bool { if ($this->hasAmmo()) { return true; } $p = $this->player; foreach ([5, 4, 3, 2, 1] as $w) { $def = Weapons::LIST[$w]; if (!empty($p->weapons[$w]) && ($def['ammo'] === null || $p->ammo[$def['ammo']] >= $def['per'])) { $p->pending = $w; break; } } $this->setWeaponState('DOWN'); return false; } private function fireWeapon(): void { if (!$this->checkAmmo()) { return; } $this->setWeaponState(Weapons::LIST[$this->player->weapon]['fire']); $this->noiseAlert($this->player->mo); } private function wWeaponReady(): void { $p = $this->player; if ($p->pending !== null || $p->dead) { $this->setWeaponState('DOWN'); return; } if ($p->attackDown) { $this->fireWeapon(); return; } $p->refire = 0; $a = 2 * M_PI * $this->tic / 64; $p->wx = $p->bob * cos($a); $p->wy = $p->bob * abs(sin($a)); } private function wLower(): void { $p = $this->player; $p->wy += Weapons::SPEED; if ($p->wy < Weapons::LOWERED) { return; } if ($p->dead) { $p->wy = Weapons::LOWERED + 40; return; } if ($p->pending !== null) { $p->weapon = $p->pending; $p->pending = null; } $this->setWeaponState('UP'); } private function wRaise(): void { $p = $this->player; $p->wx = 0; $p->wy -= Weapons::SPEED; if ($p->wy > 0) { return; } $p->wy = 0; $this->setWeaponState('READY'); } private function wReFire(): void { $p = $this->player; if ($p->attackDown && $p->pending === null && !$p->dead) { $p->refire++; $this->fireWeapon(); } else { $p->refire = 0; $this->checkAmmo(); } } private function useAmmo(): void { $p = $this->player; $w = Weapons::LIST[$p->weapon]; if ($w['ammo'] !== null) { $p->ammo[$w['ammo']] = max(0, $p->ammo[$w['ammo']] - $w['per']); } } /** P_BulletSlope: vertical autoaim, looking a little left and right too. */ private function bulletSlope(): float { $mo = $this->player->mo; foreach ([0.0, 0.098, -0.098] as $d) { [$slope, $target] = $this->aimLineAttack($mo, $mo->angle + $d, 1024); if ($target !== null) { return $slope; } } return 0.0; } private function gunShot(bool $accurate, float $slope): void { $mo = $this->player->mo; $angle = $mo->angle; if (!$accurate) { $angle += ($this->random() - $this->random()) / 255 * 0.098; } $this->lineAttack($mo, $angle, self::MISSILERANGE, $slope, 5 * (($this->random() % 3) + 1)); } private function wPunch(): void { $mo = $this->player->mo; $damage = (($this->random() % 10) + 1) * 2; $angle = $mo->angle + ($this->random() - $this->random()) / 255 * 0.05; [$slope, $target] = $this->aimLineAttack($mo, $angle, self::MELEERANGE); $this->sound('swing', $mo); if ($target !== null) { $this->lineAttack($mo, $angle, self::MELEERANGE, $slope, $damage); $this->sound('punch', $mo); $mo->angle = atan2($target->y - $mo->y, $target->x - $mo->x); } } private function wFireSidearm(): void { $this->sound('pistol', $this->player->mo); $this->useAmmo(); $this->player->flashTics = 4; $this->gunShot($this->player->refire === 0, $this->bulletSlope()); } private function wFireScatter(): void { $this->sound('shotgun', $this->player->mo); $this->useAmmo(); $this->player->flashTics = 5; $slope = $this->bulletSlope(); for ($i = 0; $i < 7; $i++) { $this->gunShot(false, $slope); } } private function wPump(): void { $this->sound('pump', $this->player->mo); } private function wFireRivet(): void { if (!$this->hasAmmo()) { return; } $this->sound('rivet', $this->player->mo); $this->useAmmo(); $this->player->flashTics = 3; $this->gunShot($this->player->refire === 0, $this->bulletSlope()); } private function wFireIon(): void { $this->useAmmo(); $this->player->flashTics = 3; $mo = $this->player->mo; $this->sound('ion', $mo); $slope = 0.0; foreach ([0.0, 0.098, -0.098] as $d) { [$s, $target] = $this->aimLineAttack($mo, $mo->angle + $d, 1024); if ($target !== null) { $slope = $s; break; } } $ball = $this->spawn('ionball', $mo->x, $mo->y, $mo->z + 32); $ball->target = $mo; $ball->angle = $mo->angle; $speed = $ball->info['speed']; $ball->momx = $speed * cos($mo->angle); $ball->momy = $speed * sin($mo->angle); $ball->momz = $speed * $slope; $this->checkMissileSpawn($ball); } }