<?phpnamespace App\Game;/** Bounded, deterministic PHP simulation. No renderer or host dependency. */final class Run{ public const PLAYER_RADIUS = 0.28; public const PLAYER_HALF_HEIGHT = 0.70; public const MOVE_SPEED = 3.8; public const TURN_SPEED = 2.35; public const JUMP_SPEED = 5.2; public const GRAVITY = 13.5; public const MAX_ENEMIES = 48; public const MAX_PICKUPS = 80; public const MAX_VISUAL_TRACES = 40; public const MAX_VISUAL_CORPSES = 16; public float $x; public float $y; public float $z; public float $yaw; public float $vy = 0.0; public bool $grounded = false; public int $health = 100; public int $maxHealth = 100; public int $shots = 0; public int $hits = 0; public int $kills = 0; public int $score = 0; public int $combo = 0; public float $comboTime = 0.0; public float $multiplier = 1.0; public float $muzzle = 0.0; public float $hurt = 0.0; public float $pulse = 0.0; public float $specialFlash = 0.0; public float $time = 0.0; public string $state = 'play'; public array $enemies = []; /** @var array<int,array<string,mixed>> Short-lived visual death animations; excluded from live enemy pressure. */ public array $corpses = []; public array $pickups = []; public array $traces = []; public int $losTests = 0; public int $collisionTests = 0; public int $neighborChecks = 0; public int $wave = 1; public float $waveTime = 0.0; public float $intermission = 0.0; public int $remaining = 12; public int $level = 1; public int $xp = 0; public int $nextXp = 6; public bool $pulseUnlocked = false; public int $primaryStage = 0; // Compatibility mirrors for the current HUD renderer. primaryStage is authoritative. public int $autoTier = 0; public string $autoWeapon = 'COLT'; public int $autoGuns = 1; public int $flameRank = 0; public int $rocketRank = 0; public float $flameActive = 0.0; public array $choices = []; public string $message = 'COLT .45 - RHYTHM FIRE'; public float $messageTime = 4.0; private float $pulseClock = 1.0; /** @var list<array{delay:float,guns:int,split:bool,stage:int,lateral:float}> */ private array $primaryBurstQueue = []; /** @var list<int> */ private array $primaryAudioEvents = []; private int $primaryGunPhase = 0; private float $flameCooldownClock = 4.5; private float $flameTickClock = 0.0; private float $rocketClock = 3.0; private float $spawnClock = 0.4; private float $invincible = 0.0; private int $nextId = 1; private array $recentSpawnPoints = []; private int $rng; private array $lastGroundedPosition; private int $damageRank = 0; private int $hasteRank = 0; private int $magnetRank = 0; private int $pulseRank = 0; public function __construct(public readonly BrushMap $map, int $seed = 1337) { // Quake deathmatch maps often use info_player_start as an intermission // camera. DM1's is above empty space, so use a real deathmatch spawn. [$this->x, $this->y, $this->z] = $map->spawnPoints[0] ?? $map->playerStart; $this->yaw = $map->playerYaw; $this->lastGroundedPosition = [$this->x, $this->y, $this->z]; $this->rng = max(1, $seed % 65521); $this->syncPrimaryStage(); } public function step(float $dt, float $forward, float $strafe, float $turn, bool $fire = false, bool $jump = false, bool $special = false, array $rhythmSteps = []): void { if ($this->state !== 'play') return; $dt = max(0.0, min($dt, 0.05)); $this->losTests = $this->collisionTests = $this->neighborChecks = 0; $this->time += $dt; $this->waveTime += $dt; foreach (['muzzle', 'hurt', 'pulse', 'specialFlash', 'messageTime', 'invincible'] as $field) { $this->$field = max(0.0, $this->$field - $dt); } if ($this->comboTime > 0) { $this->comboTime = max(0.0, $this->comboTime - $dt); if ($this->comboTime === 0.0) $this->resetCombo(); } foreach ($this->traces as $i => &$trace) { $trace['life'] -= $dt; if ($trace['life'] <= 0) unset($this->traces[$i]); } unset($trace); foreach ($this->corpses as $i => &$corpse) { $corpse['age'] += $dt; if ($corpse['age'] >= 0.64) unset($this->corpses[$i]); } unset($corpse); $this->yaw -= max(-1.0, min(1.0, $turn)) * self::TURN_SPEED * $dt; $length = max(1.0, hypot($forward, $strafe)); $dx = (sin($this->yaw) * $forward - cos($this->yaw) * $strafe) / $length * self::MOVE_SPEED * $dt; $dz = (cos($this->yaw) * $forward + sin($this->yaw) * $strafe) / $length * self::MOVE_SPEED * $dt; if ($jump && $this->grounded) { $this->vy = self::JUMP_SPEED; $this->grounded = false; } $this->movePlayer($dx, $dz); $this->vy -= self::GRAVITY * $dt; $this->vertical($this->vy * $dt); if ($this->grounded) $this->lastGroundedPosition = [$this->x, $this->y, $this->z]; if ($this->y < $this->lastGroundedPosition[1] - 3.0) { [$this->x, $this->y, $this->z] = $this->lastGroundedPosition; $this->vy = 0.0; $this->grounded = true; } $this->stepWaves($dt); $this->stepEnemies($dt); if ($this->health <= 0) { $this->health = 0; $this->state = 'dead'; return; } $this->pulseClock -= $dt; $this->stepPrimaryBursts($dt); foreach ($rhythmSteps as $rhythmStep) $this->triggerPrimaryRhythmStep((int)$rhythmStep); if ($this->pulseUnlocked && $this->pulseClock <= 0) $this->firePulse(); if ($this->flameRank > 0) $this->stepFlamethrower($dt); if ($this->rocketRank > 0) { $this->rocketClock -= $dt; if ($this->rocketClock <= 0) $this->fireMiniRockets(); } $this->stepPickups($dt); } public function chooseUpgrade(int $slot): bool { if ($this->state !== 'upgrade' || !isset($this->choices[$slot])) return false; $choice = $this->choices[$slot]; match ($choice) { 'POWER' => $this->damageRank++, 'HASTE' => $this->hasteRank++, 'MAGNET' => $this->magnetRank++, 'PULSE' => $this->upgradePulse(), 'FLAMETHROWER' => $this->upgradeFlamethrower(), 'MINI_ROCKETS' => $this->upgradeMiniRockets(), default => $this->upgradeVitality(), }; $this->choices = []; $this->state = 'play'; $this->message = $choice . ' / ' . $this->arsenalLabel(); $this->messageTime = 2.4; $this->checkLevel(); return true; } private function upgradeVitality(): void { $this->maxHealth += 20; $this->health = min($this->maxHealth, $this->health + 40); } private function upgradePulse(): void { $this->pulseUnlocked = true; $this->pulseRank++; $this->pulseClock = min($this->pulseClock, 0.35); } private function upgradeFlamethrower(): void { $this->flameRank = min(3, $this->flameRank + 1); $this->flameCooldownClock = min($this->flameCooldownClock, 0.45); } private function upgradeMiniRockets(): void { $this->rocketRank = min(4, $this->rocketRank + 1); $this->rocketClock = min($this->rocketClock, 0.45); } private function syncPrimaryStage(): void { $next = self::primaryStageForLevel($this->level); $changed = $next !== $this->primaryStage; $this->primaryStage = $next; [$tier, $weapon, $guns] = match ($next) { 0 => [0, 'COLT', 1], 1 => [0, 'COLT', 2], 2 => [1, 'MP44', 1], 3 => [1, 'MP44', 2], 4 => [3, 'BAR', 1], 5 => [4, 'GATLING', 1], default => [4, 'GATLING', 2], }; $this->autoTier = $tier; $this->autoWeapon = $weapon; $this->autoGuns = $guns; if ($changed) { $this->primaryBurstQueue = []; $this->message = $this->primaryWeaponLabel() . ' / RHYTHM LOCK'; $this->messageTime = 2.6; } } public static function primaryStageForLevel(int $level): int { return match (true) { $level >= 24 => 6, $level >= 20 => 5, $level >= 16 => 4, $level >= 12 => 3, $level >= 8 => 2, $level >= 4 => 1, default => 0, }; } public function primaryWeaponLabel(): string { return match ($this->primaryStage) { 0 => 'COLT .45', 1 => 'DUAL COLT', 2 => 'MP44', 3 => 'DUAL MP44', 4 => 'BAR', 5 => 'GATLING', default => 'DUAL GATLING', }; } /** @return list<int> primary stages that actually emitted a shot event */ public function drainPrimaryAudioEvents(): array { $events = $this->primaryAudioEvents; $this->primaryAudioEvents = []; return $events; } public function upgradeDescription(string $choice): string { return match ($choice) { 'POWER' => '+30% PRIMARY DAMAGE / STRONGER SECONDARIES', 'HASTE' => 'FASTER SECONDARY CYCLES', 'MAGNET' => 'WIDER PICKUP RANGE', 'PULSE' => $this->pulseUnlocked ? 'STRONGER WIDER PULSE' : 'UNLOCK RADIAL PULSE', 'FLAMETHROWER' => $this->flameRank === 0 ? 'TRACKING FIRE CONE FOR 2.4s' : 'LONGER / WIDER / HOTTER SWEEP', 'MINI_ROCKETS' => $this->rocketRank === 0 ? 'AUTO-TARGET MINI-ROCKET SALVO' : 'MORE ROCKETS / MORE DAMAGE', default => '+20 MAX HP / HEAL 40', }; } public function arsenalLabel(): string { $parts = [$this->primaryWeaponLabel()]; if ($this->flameRank > 0) $parts[] = 'FLAME L' . $this->flameRank; if ($this->rocketRank > 0) $parts[] = 'ROCKET L' . $this->rocketRank; if ($this->pulseUnlocked) $parts[] = 'PULSE L' . max(1, $this->pulseRank); return implode(' + ', $parts); } /** Demo controls, also used by the integration smoke test. */ public function autopilot(): array { $target = null; $best = INF; $visibleTarget = null; $visibleScore = INF; foreach ($this->enemies as $enemy) { $d = hypot($enemy['x'] - $this->x, $enemy['z'] - $this->z); if ($d < $best) { $target = $enemy; $best = $d; } if ($d <= 19.0 && $this->lineClear( [$this->x, $this->y + 0.35, $this->z], [$enemy['x'], $enemy['y'] + 0.15, $enemy['z']], )) { $angle = abs(self::angleDelta(atan2($enemy['x'] - $this->x, $enemy['z'] - $this->z), $this->yaw)); $score = $d + $angle * 5.0; if ($score < $visibleScore) { $visibleTarget = $enemy; $visibleScore = $score; } } } if ($target === null) return [0.25, 0.4, 0.3, false]; if ($visibleTarget === null) { // Use the map's deathmatch points as safe exploration waypoints while // every live enemy is hidden behind geometry. $goalX = $target['x']; $goalZ = $target['z']; $waypointDistance = INF; foreach ($this->map->spawnPoints as $point) { $distance = hypot($point[0] - $this->x, $point[2] - $this->z); if ($distance < 6.0 || $distance >= $waypointDistance) continue; $goalX = $point[0]; $goalZ = $point[2]; $waypointDistance = $distance; } $goalAngle = atan2($goalX - $this->x, $goalZ - $this->z); $bestHeading = null; $bestPathScore = INF; for ($i = 0; $i < 24; $i++) { $heading = $goalAngle + $i * (M_PI / 12.0); $probeX = $this->x + sin($heading); $probeZ = $this->z + cos($heading); if ($this->collides($probeX, $this->y, $probeZ, self::PLAYER_RADIUS, self::PLAYER_HALF_HEIGHT)) continue; if (!$this->collides($probeX, $this->y - 0.12, $probeZ, self::PLAYER_RADIUS, self::PLAYER_HALF_HEIGHT)) continue; $remaining = hypot($goalX - $probeX, $goalZ - $probeZ); $score = $remaining + abs(self::angleDelta($heading, $goalAngle)) * 0.16; if ($score < $bestPathScore) { $bestPathScore = $score; $bestHeading = $heading; } } if ($bestHeading === null) return [0.18, sin($this->time * 0.8) * 0.55, 0.68, false]; $error = self::angleDelta($bestHeading, $this->yaw); return [0.72, 0.0, max(-1.0, min(1.0, -$error * 1.5)), false]; } // Prefer an enemy the weapons can actually hit. The nearest enemy may be // on the other side of a DM1 wall, where it would otherwise hold aim forever. $aimTarget = $visibleTarget; $error = self::angleDelta(atan2($aimTarget['x'] - $this->x, $aimTarget['z'] - $this->z), $this->yaw); $forward = hypot($visibleTarget['x'] - $this->x, $visibleTarget['z'] - $this->z) < 3.2 ? -0.35 : 0.25; return [$forward, 0.0, max(-1.0, min(1.0, -$error * 2)), abs($error) < 0.12]; } private function stepWaves(float $dt): void { if ($this->intermission > 0) { $this->intermission = max(0.0, $this->intermission - $dt); if ($this->intermission <= 0) { $this->wave++; $this->waveTime = 0.0; $this->remaining = min(70, 8 + $this->wave * 4); $this->spawnClock = 0.0; $this->message = 'WAVE ' . $this->wave . ($this->wave % 4 === 0 ? ' - BRUTE SURGE' : ''); $this->messageTime = 2.5; } return; } if (($this->remaining === 0 && $this->enemies === []) || $this->waveTime >= 26.0) { $waveBonus = (int)round(($this->wave * 150 + $this->combo * 50) * $this->multiplier); $this->score += $waveBonus; $this->intermission = 4.0; $this->health = min($this->maxHealth, $this->health + 10); $this->message = 'WAVE BONUS +' . $waveBonus . ' / HEAL +10'; $this->messageTime = 3.0; return; } $this->spawnClock -= $dt; if ($this->remaining <= 0 || $this->spawnClock > 0 || count($this->enemies) >= self::MAX_ENEMIES) return; $batch = min($this->remaining, 1 + intdiv($this->wave, 3)); for ($i = 0; $i < $batch && count($this->enemies) < self::MAX_ENEMIES; $i++) { if ($this->spawnEnemy()) $this->remaining--; } $this->spawnClock = max(0.22, 1.2 - $this->wave * 0.065); } private function spawnEnemy(): bool { $type = $this->chooseEnemyType(); $radius = $this->enemyRadius($type); if ($this->map->spawnPoints !== []) { $candidates = []; foreach ($this->map->spawnPoints as $index => $point) { [$x, $mapY, $z] = $point; $y = $mapY - 0.20; $distance = hypot($x - $this->x, $z - $this->z); if ($distance < 4.5 || $distance > 34.0) continue; // DM1 has stacked floors. A spawn directly above the player // reads as an enemy walking on the ceiling from below. if (abs($y - $this->y) > 1.5) continue; if ($this->collides($x, $y, $z, 0.42, 0.55)) continue; [$occupied, $nearby] = $this->spawnCrowding($x, $y, $z, $radius, $type); if ($occupied) continue; $angle = abs(self::angleDelta(atan2($x - $this->x, $z - $this->z), $this->yaw)); $visible = $this->lineClear([$this->x, $this->y + 0.35, $this->z], [$x, $y + 0.25, $z]); $distanceScore = abs($distance - 12.0) * 0.28; $frontPenalty = $angle < 1.05 ? (1.05 - $angle) * 5.5 : 0.0; $visibilityPenalty = $visible ? 2.4 : -1.3; $recentPenalty = 0.0; foreach ($this->recentSpawnPoints as $age => $used) { if ($used === $index) $recentPenalty += 3.0 - $age * 0.8; } $candidates[] = [ 'score' => $distanceScore + $frontPenalty + $visibilityPenalty + $nearby * 1.5 + $recentPenalty + $this->random() * 0.55, 'x' => $x, 'y' => $y, 'z' => $z, 'index' => $index, ]; } if ($candidates !== []) { usort($candidates, static fn (array $a, array $b): int => $a['score'] <=> $b['score']); $spawn = $candidates[0]; $this->rememberSpawnPoint($spawn['index']); return $this->spawnEnemyAt($spawn['x'], $spawn['y'], $spawn['z'], $type); } } $fallbackCandidates = []; for ($attempt = 0; $attempt < 20; $attempt++) { $a = $this->random() * M_PI * 2; $anchorIndex = null; if ($this->map->spawnPoints !== []) { $anchorIndex = min(count($this->map->spawnPoints) - 1, (int)floor($this->random() * count($this->map->spawnPoints))); [$baseX, $baseY, $baseZ] = $this->map->spawnPoints[$anchorIndex]; $r = 2.5 + $this->random() * 3.5; } else { [$baseX, $baseY, $baseZ] = [$this->x, $this->y, $this->z]; $r = 6.0 + $this->random() * 4.0; } $x = $baseX + sin($a) * $r; $z = $baseZ + cos($a) * $r; $distance = hypot($x - $this->x, $z - $this->z); if ($distance < 5.0 || $distance > 34.0) continue; $ground = $this->supportHeight($x, $z, 0.42, $baseY + 2.0); if ($ground === null) continue; $y = $ground + 0.55; if (abs($y - $this->y) > 1.5) continue; if ($this->collides($x, $y, $z, 0.42, 0.55)) continue; [$occupied, $nearby] = $this->spawnCrowding($x, $y, $z, $radius, $type); if ($occupied) continue; $angle = abs(self::angleDelta(atan2($x - $this->x, $z - $this->z), $this->yaw)); $visible = $this->lineClear([$this->x, $this->y + 0.35, $this->z], [$x, $y + 0.2, $z]); $recentPenalty = 0.0; foreach ($this->recentSpawnPoints as $age => $used) { if ($used === $anchorIndex) $recentPenalty += 3.0 - $age * 0.8; } $fallbackCandidates[] = ['x' => $x, 'y' => $y, 'z' => $z, 'index' => $anchorIndex, 'score' => abs($distance - 12.0) * 0.28 + ($angle < 1.05 ? (1.05 - $angle) * 5.5 : 0.0) + ($visible ? 2.4 : -1.3) + $nearby * 1.5 + $recentPenalty + $this->random() * 0.55]; } if ($fallbackCandidates !== []) { usort($fallbackCandidates, static fn (array $a, array $b): int => $a['score'] <=> $b['score']); $point = $fallbackCandidates[0]; if ($point['index'] !== null) $this->rememberSpawnPoint($point['index']); return $this->spawnEnemyAt($point['x'], $point['y'], $point['z'], $type); } return false; } private function chooseEnemyType(): string { $weights = self::enemyWeightsForWave($this->wave); $roll = $this->random(); $cursor = 0.0; foreach ($weights as $type => $weight) { $cursor += $weight; if ($roll <= $cursor) return $type; } return 'brute'; } /** * Four durability classes, progressively weighted toward tougher enemies. * Weapon DPS still grows faster than enemy health through DifficultyDirector; * this changes composition, not the underlying elasticity rule. * * @return array{runner:float,guardian:float,enforcer:float,brute:float} */ public static function enemyWeightsForWave(int $wave): array { return match (true) { $wave <= 1 => ['runner' => 0.68, 'guardian' => 0.32, 'enforcer' => 0.00, 'brute' => 0.00], $wave <= 3 => ['runner' => 0.54, 'guardian' => 0.34, 'enforcer' => 0.12, 'brute' => 0.00], $wave <= 6 => ['runner' => 0.42, 'guardian' => 0.34, 'enforcer' => 0.18, 'brute' => 0.06], $wave <= 10 => ['runner' => 0.32, 'guardian' => 0.33, 'enforcer' => 0.23, 'brute' => 0.12], default => ['runner' => 0.22, 'guardian' => 0.30, 'enforcer' => 0.28, 'brute' => 0.20], }; } private function enemyRadius(string $type): float { return match ($type) { 'runner' => 0.23, 'brute' => 0.40, 'enforcer' => 0.27, default => 0.30 }; } private function spawnCrowding(float $x, float $y, float $z, float $radius, string $type): array { $nearby = 0; foreach ($this->enemies as $enemy) { if (abs($y - $enemy['y']) > 1.4) continue; $distance = hypot($x - $enemy['x'], $z - $enemy['z']); $spacing = ($type === 'brute' || $enemy['type'] === 'brute' ? 1.95 : 1.60) + $radius + $enemy['radius']; if ($distance < $spacing) return [true, $nearby]; if ($distance < 5.0) $nearby++; } return [false, $nearby]; } private function rememberSpawnPoint(int $index): void { array_unshift($this->recentSpawnPoints, $index); $this->recentSpawnPoints = array_slice($this->recentSpawnPoints, 0, 4); } private function spawnEnemyAt(float $x, float $y, float $z, string $type): bool { [$hp, $speed, $damage, $radius] = match ($type) { 'runner' => [24, 2.08, 7, 0.23], 'brute' => [180, 0.84, 20, 0.40], 'enforcer' => [74, 1.02, 8, 0.27], default => [52, 1.25, 10, 0.30], }; $hp = (int)round($hp * (1 + min(20, $this->wave - 1) * 0.07)); $id = $this->nextId++; $yaw = atan2($this->x - $x, $this->z - $z); $this->enemies[$id] = ['id' => $id, 'type' => $type, 'x' => $x, 'y' => $y, 'z' => $z, 'health' => $hp, 'maxHealth' => $hp, 'speed' => $speed, 'damage' => $damage, 'radius' => $radius, 'flash' => 0.0, 'cooldown' => 0.6, 'age' => 0.0, 'yaw' => $yaw, 'attackTime' => 0.0, 'moving' => false, 'animState' => 'idle']; return true; } private function stepEnemies(float $dt): void { $grid = []; foreach ($this->enemies as $id => $entry) { $cell = (int)floor($entry['x'] / 1.5) . ':' . (int)floor($entry['z'] / 1.5); $grid[$cell][$id] = true; } foreach ($this->enemies as $id => &$enemy) { $enemy['flash'] = max(0.0, $enemy['flash'] - $dt); $enemy['cooldown'] -= $dt; $enemy['attackTime'] = max(0.0, $enemy['attackTime'] - $dt); $enemy['age'] += $dt; $enemy['moving'] = false; $dx = $this->x - $enemy['x']; $dz = $this->z - $enemy['z']; $d = max(0.001, hypot($dx, $dz)); $angle = atan2($dx, $dz); $enemy['yaw'] = $angle; $ranged = $enemy['type'] === 'enforcer'; $attackRange = $ranged ? ($d > 2.5 && $d < 12.0) : ($d < $enemy['radius'] + 0.55 && abs($this->y - $enemy['y']) < 1.1); $attackVisible = $attackRange && $enemy['cooldown'] <= 0 && $this->lineClear( [$enemy['x'], $enemy['y'] + ($ranged ? 0.28 : 0.0), $enemy['z']], [$this->x, $this->y + ($ranged ? 0.35 : 0.0), $this->z], ); if ($attackRange && $enemy['cooldown'] <= 0 && $attackVisible) { if ($this->invincible <= 0) { $this->health -= $enemy['damage']; $this->hurt = 0.25; $this->invincible = 0.40; $this->resetCombo(); } $enemy['cooldown'] = $ranged ? 1.45 : 0.85; $enemy['attackTime'] = 0.32; } if ($d >= 0.55) { $separateX = $separateZ = 0.0; $cx = (int)floor($enemy['x'] / 1.5); $cz = (int)floor($enemy['z'] / 1.5); for ($ix = $cx - 1; $ix <= $cx + 1; $ix++) for ($iz = $cz - 1; $iz <= $cz + 1; $iz++) { foreach ($grid[$ix . ':' . $iz] ?? [] as $otherId => $_) { if ($otherId === $id) continue; $this->neighborChecks++; $other = $this->enemies[$otherId]; if (abs($enemy['y'] - $other['y']) > 1.1) continue; $awayX = $enemy['x'] - $other['x']; $awayZ = $enemy['z'] - $other['z']; $gap = hypot($awayX, $awayZ); if ($gap < 0.001 || $gap >= 1.6) continue; $weight = (1.6 - $gap) / (1.6 * $gap); $separateX += $awayX * $weight; $separateZ += $awayZ * $weight; } } $seekAngle = atan2($dx / $d + $separateX * 0.28, $dz / $d + $separateZ * 0.28); $side = $id % 2 === 0 ? 1 : -1; foreach ([0.0, $side * 0.65, -$side * 0.65, $side * 1.3, -$side * 1.3] as $offset) { $speed = $enemy['speed'] * $dt; $nx = $enemy['x'] + sin($seekAngle + $offset) * $speed; $nz = $enemy['z'] + cos($seekAngle + $offset) * $speed; $ground = $this->supportHeight($nx, $nz, $enemy['radius'], $enemy['y'] - 0.55 + 0.23); if ($ground === null) continue; $ny = $ground + 0.55; if ($this->collides($nx, $ny, $nz, $enemy['radius'], 0.55)) continue; $blocked = false; $gx = (int)floor($nx / 1.5); $gz = (int)floor($nz / 1.5); for ($ix = $gx - 1; $ix <= $gx + 1 && !$blocked; $ix++) for ($iz = $gz - 1; $iz <= $gz + 1 && !$blocked; $iz++) { foreach ($grid[$ix . ':' . $iz] ?? [] as $otherId => $_) { if ($otherId === $id) continue; $this->neighborChecks++; $other = $this->enemies[$otherId]; $spacing = $enemy['radius'] + $other['radius'] + ($enemy['type'] === 'brute' || $other['type'] === 'brute' ? 0.55 : 0.42); if (abs($ny - $other['y']) < 1.1) { $nextDistance = hypot($nx - $other['x'], $nz - $other['z']); $oldDistance = hypot($enemy['x'] - $other['x'], $enemy['z'] - $other['z']); if ($nextDistance < $spacing && $nextDistance <= $oldDistance) { $blocked = true; break; } } } } if (!$blocked) { $oldCell = (int)floor($enemy['x'] / 1.5) . ':' . (int)floor($enemy['z'] / 1.5); $newCell = $gx . ':' . $gz; if ($oldCell !== $newCell) { unset($grid[$oldCell][$id]); $grid[$newCell][$id] = true; } $enemy['x'] = $nx; $enemy['y'] = $ny; $enemy['z'] = $nz; $enemy['moving'] = true; break; } } } $enemy['animState'] = $enemy['flash'] > 0 ? 'pain' : ($enemy['attackTime'] > 0 ? 'attack' : ($enemy['moving'] ? 'walk' : 'idle')); } unset($enemy); } private function triggerPrimaryRhythmStep(int $step): void { $step = (($step % 16) + 16) % 16; $beat = 60.0 / (RhythmClock::BPM_BASE + max(0, $this->level - 1) * RhythmClock::BPM_PER_LEVEL); switch ($this->primaryStage) { case 0: if (($step % 4) === 0) $this->firePrimaryEvent(1, false, 0.0, 0); break; case 1: if (($step % 2) === 0) { $lateral = $this->primaryGunPhase === 0 ? -0.16 : 0.16; $this->firePrimaryEvent(1, false, $lateral, 1); $this->primaryGunPhase = 1 - $this->primaryGunPhase; } break; case 2: case 3: if (($step % 4) !== 0) break; $guns = $this->primaryStage === 3 ? 2 : 1; $stage = $this->primaryStage; $this->firePrimaryEvent($guns, false, 0.0, $stage); // Long-short spacing gives the MP44 burst a deliberate swing. $this->queuePrimaryBurst($beat * 0.42, $guns, false, $stage); $this->queuePrimaryBurst($beat * 0.66, $guns, false, $stage); break; case 4: if (($step % 4) !== 0) break; $this->firePrimaryEvent(1, false, 0.0, 4); foreach ([0.11, 0.22, 0.33, 0.44, 0.55] as $fraction) { $this->queuePrimaryBurst($beat * $fraction, 1, false, 4); } break; case 5: $this->firePrimaryEvent(1, false, 0.0, 5); break; default: // Ultimate stage: one rhythmic event, two guns, two targets when available. $this->firePrimaryEvent(2, true, 0.0, 6); break; } } private function queuePrimaryBurst(float $delay, int $guns, bool $split, int $stage, float $lateral = 0.0): void { if (count($this->primaryBurstQueue) >= 24) array_shift($this->primaryBurstQueue); $this->primaryBurstQueue[] = compact('delay', 'guns', 'split', 'stage', 'lateral'); } private function stepPrimaryBursts(float $dt): void { if ($this->primaryBurstQueue === []) return; $ready = []; foreach ($this->primaryBurstQueue as $index => &$burst) { $burst['delay'] -= $dt; if ($burst['delay'] <= 0.0) { $ready[] = $burst; unset($this->primaryBurstQueue[$index]); } } unset($burst); $this->primaryBurstQueue = array_values($this->primaryBurstQueue); foreach ($ready as $burst) { $this->firePrimaryEvent($burst['guns'], $burst['split'], $burst['lateral'], $burst['stage']); } } private function firePrimaryEvent(int $guns, bool $splitTargets = false, float $singleLateral = 0.0, ?int $stage = null): void { $stage ??= $this->primaryStage; $targetCount = $splitTargets ? max(1, $guns) : 1; $cone = 1.05; // normal weapons deliberately keep strong auto-aim $targets = $this->findTargets(19.0, $cone, $targetCount); if ($targets === []) return; $baseDamage = match ($stage) { 0, 1 => 38, 2, 3 => 14, 4 => 10, default => 13, }; $damage = (int)round($baseDamage * (1 + $this->damageRank * 0.30)); $fired = 0; for ($gun = 0; $gun < max(1, $guns); $gun++) { $target = $splitTargets ? ($targets[$gun] ?? $targets[0]) : $targets[0]; if (!isset($this->enemies[$target])) continue; $enemy = $this->enemies[$target]; $lateral = $guns > 1 ? ($gun - ($guns - 1) / 2) * 0.22 : $singleLateral; $this->addVisualTrace($enemy, 0.14, $stage <= 1 ? 'pistol' : 'auto', $lateral); $this->damage($target, $damage); $this->shots++; $this->hits++; $fired++; } if ($fired === 0) return; $this->muzzle = max($this->muzzle, 0.11); if (count($this->primaryAudioEvents) >= 32) array_shift($this->primaryAudioEvents); $this->primaryAudioEvents[] = $stage; } private function stepFlamethrower(float $dt): void { if ($this->flameActive > 0.0) { $this->flameActive = max(0.0, $this->flameActive - $dt); $this->flameTickClock -= $dt; if ($this->flameTickClock <= 0.0) { $this->fireFlameTick(); $this->flameTickClock += 0.12; } return; } $this->flameCooldownClock -= $dt; if ($this->flameCooldownClock > 0.0) return; $this->flameActive = 2.4 + ($this->flameRank - 1) * 0.45; $this->flameCooldownClock = max(5.4, 8.0 - ($this->flameRank - 1) * 0.7 - $this->hasteRank * 0.15); $this->flameTickClock = 0.0; $this->specialFlash = max($this->specialFlash, 0.18); } private function fireFlameTick(): void { $range = 5.2 + ($this->flameRank - 1) * 0.7; $cone = 0.48 + ($this->flameRank - 1) * 0.10; $limit = 6 + ($this->flameRank - 1) * 2; $targets = $this->findTargets($range, $cone, $limit); $baseDamage = 6 + ($this->flameRank - 1) * 2; foreach ($targets as $index => $id) { if (!isset($this->enemies[$id])) continue; $enemy = $this->enemies[$id]; if ($index < 3) $this->addVisualTrace($enemy, 0.10, 'flame', ($index - 1) * 0.08); $this->damage($id, (int)round($baseDamage * (1 + $this->damageRank * 0.18))); $this->hits++; } if ($targets !== []) { $this->shots++; $this->muzzle = max($this->muzzle, 0.035); } } private function fireMiniRockets(): void { $count = 2 + ($this->rocketRank - 1) * 2; $targets = $this->findTargets(22.0, 1.15, $count); if ($targets === []) { $this->rocketClock = 0.40; return; } $damage = (int)round((34 + ($this->rocketRank - 1) * 12) * (1 + $this->damageRank * 0.25)); foreach ($targets as $index => $id) { if (!isset($this->enemies[$id])) continue; $enemy = $this->enemies[$id]; $this->addVisualTrace($enemy, 0.18, 'rocket', ($index - (count($targets) - 1) / 2) * 0.04); $this->damage($id, $damage); $this->shots++; $this->hits++; } $this->specialFlash = max($this->specialFlash, 0.12); $this->rocketClock = max(2.0, 4.5 - ($this->rocketRank - 1) * 0.55 - $this->hasteRank * 0.12); } private function addVisualTrace(array $enemy, float $height, string $type, float $lateral = 0.0): void { if (count($this->traces) >= self::MAX_VISUAL_TRACES) array_shift($this->traces); $life = in_array($type, ['flame', 'rocket'], true) ? 0.075 : 0.06; $this->traces[] = [ 'startX' => $this->x + sin($this->yaw) * 0.5 - cos($this->yaw) * (0.14 + $lateral), 'startY' => $this->y + 0.18, 'startZ' => $this->z + cos($this->yaw) * 0.5 + sin($this->yaw) * (0.14 + $lateral), 'endX' => $enemy['x'], 'endY' => $enemy['y'] + $height, 'endZ' => $enemy['z'], 'x' => $enemy['x'], 'y' => $enemy['y'] + $height, 'z' => $enemy['z'], 'life' => $life, 'totalLife' => $life, 'type' => $type, ]; } private function findTarget(float $range, float $cone): ?int { return $this->findTargets($range, $cone, 1)[0] ?? null; } /** @return list<int> */ private function findTargets(float $range, float $cone, int $limit): array { $candidates = []; foreach ($this->enemies as $id => $enemy) { $d = hypot($enemy['x'] - $this->x, $enemy['z'] - $this->z); if ($d > $range) continue; $angle = abs(self::angleDelta(atan2($enemy['x'] - $this->x, $enemy['z'] - $this->z), $this->yaw)); if ($angle > $cone) continue; $score = $angle * 7 + $d * 0.14; if ($this->lineClear([$this->x, $this->y + 0.35, $this->z], [$enemy['x'], $enemy['y'] + 0.15, $enemy['z']])) { $candidates[$id] = $score; } } asort($candidates); return array_slice(array_map('intval', array_keys($candidates)), 0, max(1, $limit)); } private function firePulse(): void { $targets = []; foreach ($this->enemies as $id => $enemy) { if (hypot($enemy['x'] - $this->x, $enemy['z'] - $this->z) <= 3.6 + $this->pulseRank * 0.35 && $this->lineClear([$this->x, $this->y, $this->z], [$enemy['x'], $enemy['y'], $enemy['z']])) $targets[] = $id; } if ($targets === []) { $this->pulseClock = 0.20; return; } foreach ($targets as $id) $this->damage($id, 30 + $this->pulseRank * 14); $this->pulse = 0.25; $this->pulseClock = max(1.0, 2.4 - $this->pulseRank * 0.15 - $this->hasteRank * 0.04); } private function damage(int $id, int $amount): void { if (!isset($this->enemies[$id])) return; $this->enemies[$id]['health'] -= $amount; $this->enemies[$id]['flash'] = 0.12; $this->enemies[$id]['animState'] = 'pain'; if ($this->enemies[$id]['health'] > 0) return; $enemy = $this->enemies[$id]; unset($this->enemies[$id]); if (count($this->corpses) >= self::MAX_VISUAL_CORPSES) array_shift($this->corpses); $this->corpses[] = array_merge($enemy, ['age' => 0.0, 'animState' => 'death']); $this->kills++; $this->combo++; $this->comboTime = 3.5; $this->multiplier = match (true) { $this->combo >= 24 => 10.0, $this->combo >= 16 => 5.0, $this->combo >= 11 => 3.0, $this->combo >= 7 => 2.0, $this->combo >= 4 => 1.5, $this->combo >= 2 => 1.2, default => 1.0, }; $baseScore = match ($enemy['type']) { 'runner' => 100, 'brute' => 800, 'enforcer' => 400, default => 250, }; $this->score += (int)round(($baseScore + $this->wave * 25) * $this->multiplier); $kind = $this->kills % 9 === 0 ? 'health' : 'xp'; $value = $kind === 'health' ? 18 : ($enemy['type'] === 'brute' ? 5 : 2); if (count($this->pickups) >= self::MAX_PICKUPS) { $key = array_key_first($this->pickups); if ($kind === 'xp' && $this->pickups[$key]['kind'] === 'xp') $this->pickups[$key]['value'] += $value; else array_shift($this->pickups); } if (count($this->pickups) < self::MAX_PICKUPS) { $this->pickups[] = ['x' => $enemy['x'], 'y' => $enemy['y'] - 0.3, 'z' => $enemy['z'], 'kind' => $kind, 'value' => $value, 'age' => 0.0]; } } private function stepPickups(float $dt): void { foreach ($this->pickups as $id => &$pickup) { $pickup['age'] += $dt; $dx = $this->x - $pickup['x']; $dz = $this->z - $pickup['z']; $d = hypot($dx, $dz); if ($d < 1.9 + $this->magnetRank * 1.0 && $this->lineClear([$pickup['x'], $pickup['y'], $pickup['z']], [$this->x, $this->y, $this->z])) { if ($d < 0.6) { if ($pickup['kind'] === 'xp') $this->xp += $pickup['value']; else $this->health = min($this->maxHealth, $this->health + $pickup['value']); unset($this->pickups[$id]); } else { $s = min($d, $dt * 6); $pickup['x'] += $dx / $d * $s; $pickup['z'] += $dz / $d * $s; } } } unset($pickup); $this->checkLevel(); } private function checkLevel(): void { if ($this->xp < $this->nextXp || $this->state !== 'play') return; $this->xp -= $this->nextXp; $this->level++; $this->nextXp = 6 + ($this->level - 1) * 4; $this->syncPrimaryStage(); $weaponPool = $this->weaponUpgradePool(); $this->choices = []; if ($weaponPool !== []) { $index = (int)floor($this->random() * count($weaponPool)); $this->choices[] = $weaponPool[$index]; array_splice($weaponPool, $index, 1); } $pool = ['POWER', 'HASTE', 'MAGNET', 'VITALITY']; if (!$this->pulseUnlocked || $this->pulseRank < 3) $pool[] = 'PULSE'; $pool = array_merge($pool, $weaponPool); while (count($this->choices) < 3 && $pool !== []) { $index = (int)floor($this->random() * count($pool)); $this->choices[] = $pool[$index]; array_splice($pool, $index, 1); } $this->state = 'upgrade'; } /** @return list<string> */ private function weaponUpgradePool(): array { $pool = []; if ($this->level >= 3 && $this->flameRank < 3) $pool[] = 'FLAMETHROWER'; if ($this->level >= 3 && $this->rocketRank < 4) $pool[] = 'MINI_ROCKETS'; return $pool; } private function resetCombo(): void { $this->combo = 0; $this->comboTime = 0.0; $this->multiplier = 1.0; } private function movePlayer(float $dx, float $dz): void { foreach ([[$dx, 0.0], [0.0, $dz]] as [$sx, $sz]) { $nx = $this->x + $sx; $nz = $this->z + $sz; $floor = null; if ($this->grounded) { $floor = $this->supportHeight($nx, $nz, self::PLAYER_RADIUS, $this->y - self::PLAYER_HALF_HEIGHT + 0.58); if ($floor === null || $floor < $this->y - self::PLAYER_HALF_HEIGHT - 0.8) continue; } if (!$this->collides($nx, $this->y, $nz, self::PLAYER_RADIUS, self::PLAYER_HALF_HEIGHT)) { $this->x = $nx; $this->z = $nz; } elseif ($this->grounded && $floor !== null) { $rise = $floor - ($this->y - self::PLAYER_HALF_HEIGHT); if ($rise > 0.0 && $rise <= 0.58 && !$this->collides($nx, $this->y + $rise + 0.002, $nz, self::PLAYER_RADIUS, self::PLAYER_HALF_HEIGHT)) { $this->x = $nx; $this->z = $nz; $this->y += $rise + 0.002; } } } } private function vertical(float $dy): void { $this->grounded = false; if (!$this->collides($this->x, $this->y + $dy, $this->z, self::PLAYER_RADIUS, self::PLAYER_HALF_HEIGHT)) { $this->y += $dy; return; } $safe = 0.0; $blocked = 1.0; for ($i = 0; $i < 12; $i++) { $t = ($safe + $blocked) * 0.5; if ($this->collides($this->x, $this->y + $dy * $t, $this->z, self::PLAYER_RADIUS, self::PLAYER_HALF_HEIGHT)) $blocked = $t; else $safe = $t; } $this->y += $dy * $safe; $this->grounded = $dy < 0; $this->vy = 0.0; } public function lineClear(array $a, array $b): bool { $this->losTests++; foreach ($this->map->solids as $solid) { $lo = 0.001; $hi = 0.999; for ($axis = 0; $axis < 3; $axis++) { $d = $b[$axis] - $a[$axis]; if (abs($d) < 1e-8) { if ($a[$axis] < $solid['min'][$axis] || $a[$axis] > $solid['max'][$axis]) { $lo = 2; break; } } else { $t0 = ($solid['min'][$axis] - $a[$axis]) / $d; $t1 = ($solid['max'][$axis] - $a[$axis]) / $d; $lo = max($lo, min($t0, $t1)); $hi = min($hi, max($t0, $t1)); } if ($lo > $hi) break; } if ($lo <= $hi) return false; } return true; } public function collides(float $x, float $y, float $z, float $radius, float $halfHeight): bool { $this->collisionTests++; foreach ($this->map->solids as $solid) { [$minX, $minY, $minZ] = $solid['min']; [$maxX, $maxY, $maxZ] = $solid['max']; if ($y + $halfHeight <= $minY + 0.001 || $y - $halfHeight >= $maxY - 0.001) continue; $cx = $x - max($minX, min($maxX, $x)); $cz = $z - max($minZ, min($maxZ, $z)); if ($cx * $cx + $cz * $cz < $radius * $radius) return true; } return false; } private function supportHeight(float $x, float $z, float $radius, float $ceiling): ?float { $top = null; foreach ($this->map->solids as $solid) { $height = $solid['max'][1]; if ($height > $ceiling + 0.002 || ($top !== null && $height < $top)) continue; $dx = $x - max($solid['min'][0], min($solid['max'][0], $x)); $dz = $z - max($solid['min'][2], min($solid['max'][2], $z)); if ($dx * $dx + $dz * $dz < $radius * $radius) $top = $height; } return $top; } private function random(): float { $this->rng = ($this->rng * 25173 + 13849) % 65521; return $this->rng / 65521; } private static function angleDelta(float $a, float $b): float { $d = fmod($a - $b + M_PI, 2 * M_PI); if ($d < 0) $d += 2 * M_PI; return $d - M_PI; }}