<?phpnamespace App\Game;/** * Quake .map reader for Slave Rift. * * Worldspawn brushes are rebuilt from their planes. Brush entities such as * doors/lifts are deliberately ignored for collision in this lightweight * runtime, while Quake deathmatch starts are retained as wave spawn anchors. */final class BrushMap{ public const UNITS = 32.0; private const EPS = 0.01; /** @var array<int,array{min:float[],max:float[],material:string,color:int[],triangles:array<int,array{p:float[],n:float[],color:int[]}>>} */ public array $solids = []; /** @var array<int,float[]> */ public array $spawnPoints = []; /** @var list<array{material:string,position:float[],normal:float[],width:float,height:float}> */ public array $decals = []; public array $playerStart = [0.0, 0.75, -4.5]; public float $playerYaw = 0.0; public array $guardianStart = [0.0, 0.75, 4.5]; private bool $hasExplicitPlayerStart = false; public static function load(string $path): self { $map = new self(); $text = file_get_contents($path); if ($text === false) throw new \RuntimeException("Cannot read map: {$path}"); $depth = 0; $props = []; $brush = []; $entityClass = ''; foreach (preg_split('/\R/', $text) ?: [] as $raw) { $line = trim(preg_replace('~//.*$~', '', $raw) ?? ''); if ($line === '') continue; if ($line === '{') { $depth++; if ($depth === 1) { $props = []; $entityClass = ''; } elseif ($depth === 2) { $brush = []; } continue; } if ($line === '}') { if ($depth === 2 && $brush !== [] && ($entityClass === '' || $entityClass === 'worldspawn')) { $map->addBrush($brush); } elseif ($depth === 1) { $map->finishEntity($props); } $depth--; continue; } if ($depth === 1 && preg_match('/^"([^"]+)"\s+"([^"]*)"/', $line, $m)) { $props[$m[1]] = $m[2]; if ($m[1] === 'classname') $entityClass = $m[2]; continue; } if ($depth === 2) { preg_match_all('/\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/', $line, $points, PREG_SET_ORDER); if (count($points) < 3) continue; $material = 'stone'; if (preg_match('/\)\s+([A-Za-z0-9_.\-\/]+)\s+[-\d.]+\s+[-\d.]+\s+[-\d.]+/', $line, $m)) $material = $m[1]; $brush[] = [ 'points' => [ [(float)$points[0][1], (float)$points[0][2], (float)$points[0][3]], [(float)$points[1][1], (float)$points[1][2], (float)$points[1][3]], [(float)$points[2][1], (float)$points[2][2], (float)$points[2][3]], ], 'material' => $material, ]; } } if ($map->solids === []) throw new \RuntimeException('Map contains no supported world brushes.'); return $map; } /** @param array<int,array{points:array<int,float[]>,material:string}> $faces */ private function addBrush(array $faces): void { $planes = []; $allPoints = []; foreach ($faces as $face) { [$a, $b, $c] = $face['points']; array_push($allPoints, $a, $b, $c); $n = self::cross(self::sub($b, $a), self::sub($c, $a)); $len = self::length($n); if ($len < 1e-8) continue; $n = self::scale($n, 1.0 / $len); $planes[] = ['n' => $n, 'd' => self::dot($n, $a), 'material' => $face['material']]; } if (count($planes) < 4) return; $centroid = [0.0, 0.0, 0.0]; foreach ($allPoints as $p) $centroid = self::add($centroid, $p); $centroid = self::scale($centroid, 1.0 / max(1, count($allPoints))); foreach ($planes as &$plane) { if (self::dot($plane['n'], $centroid) > $plane['d']) { $plane['n'] = self::scale($plane['n'], -1.0); $plane['d'] = -$plane['d']; } } unset($plane); $vertices = []; $count = count($planes); for ($i = 0; $i < $count - 2; $i++) { for ($j = $i + 1; $j < $count - 1; $j++) { for ($k = $j + 1; $k < $count; $k++) { $p = self::intersect3($planes[$i], $planes[$j], $planes[$k]); if ($p !== null && self::insideAll($p, $planes)) self::appendUnique($vertices, $p); } } } if (count($vertices) < 4) return; $xs = array_column($vertices, 0); $ys = array_column($vertices, 1); $zs = array_column($vertices, 2); $qMin = [min($xs), min($ys), min($zs)]; $qMax = [max($xs), max($ys), max($zs)]; $triangles = []; $dominantMaterial = $planes[0]['material'] ?? 'stone'; foreach ($planes as $plane) { $onFace = []; foreach ($vertices as $p) { if (abs(self::dot($plane['n'], $p) - $plane['d']) <= self::EPS * 4) $onFace[] = $p; } if (count($onFace) < 3) continue; $center = [0.0, 0.0, 0.0]; foreach ($onFace as $p) $center = self::add($center, $p); $center = self::scale($center, 1.0 / count($onFace)); $ref = abs($plane['n'][2]) < 0.9 ? [0.0, 0.0, 1.0] : [0.0, 1.0, 0.0]; $u = self::normalize(self::cross($ref, $plane['n'])); $v = self::cross($plane['n'], $u); usort($onFace, function (array $a, array $b) use ($center, $u, $v): int { $da = self::sub($a, $center); $db = self::sub($b, $center); return atan2(self::dot($da, $v), self::dot($da, $u)) <=> atan2(self::dot($db, $v), self::dot($db, $u)); }); $worldNormal = self::normalToWorld($plane['n']); $color = self::materialColor($plane['material']); for ($i = 1; $i < count($onFace) - 1; $i++) { foreach ([$onFace[0], $onFace[$i], $onFace[$i + 1]] as $p) { $triangles[] = ['p' => self::toWorld($p), 'n' => $worldNormal, 'color' => $color]; } } } $this->solids[] = [ 'min' => [$qMin[0] / self::UNITS, $qMin[2] / self::UNITS, -$qMax[1] / self::UNITS], 'max' => [$qMax[0] / self::UNITS, $qMax[2] / self::UNITS, -$qMin[1] / self::UNITS], 'material' => self::materialGroup($dominantMaterial), 'color' => self::materialColor($dominantMaterial), 'triangles' => $triangles, ]; } /** @param array<string,string> $props */ private function finishEntity(array $props): void { $class = $props['classname'] ?? ''; if (!isset($props['origin'])) return; $q = array_map('floatval', preg_split('/\s+/', trim($props['origin'])) ?: []); if (count($q) < 3) return; $p = self::toWorld([$q[0], $q[1], $q[2]]); if ($class === 'info_player_start') { $this->playerStart = $p; $this->playerYaw = -deg2rad((float)($props['angle'] ?? 0)); $this->hasExplicitPlayerStart = true; } elseif ($class === 'info_player_deathmatch') { $this->spawnPoints[] = $p; if (!$this->hasExplicitPlayerStart && count($this->spawnPoints) === 1) { $this->playerStart = $p; $this->playerYaw = -deg2rad((float)($props['angle'] ?? 0)); } } elseif ($class === 'monster_guardian') { $this->guardianStart = $p; } } private static function materialGroup(string $name): string { $key = strtolower(basename(str_replace('\\', '/', $name))); if (str_contains($key, 'metal') || str_contains($key, 'tech') || str_contains($key, 'trim')) return 'metal'; if (str_contains($key, 'crate') || str_contains($key, 'box') || str_contains($key, 'wood')) return 'crate'; if (str_contains($key, 'ceil') || str_contains($key, 'sky')) return 'ceil'; return 'stone'; } private static function toWorld(array $p): array { return [$p[0] / self::UNITS, $p[2] / self::UNITS, -$p[1] / self::UNITS]; } private static function normalToWorld(array $n): array { return self::normalize([$n[0], $n[2], -$n[1]]); } private static function intersect3(array $a, array $b, array $c): ?array { $bc = self::cross($b['n'], $c['n']); $ca = self::cross($c['n'], $a['n']); $ab = self::cross($a['n'], $b['n']); $den = self::dot($a['n'], $bc); if (abs($den) < 1e-8) return null; return self::scale(self::add(self::add(self::scale($bc, $a['d']), self::scale($ca, $b['d'])), self::scale($ab, $c['d'])), 1.0 / $den); } private static function insideAll(array $p, array $planes): bool { foreach ($planes as $plane) if (self::dot($plane['n'], $p) - $plane['d'] > self::EPS) return false; return true; } private static function appendUnique(array &$vertices, array $p): void { foreach ($vertices as $v) if (self::length(self::sub($v, $p)) < self::EPS * 2) return; $vertices[] = $p; } private static function add(array $a, array $b): array { return [$a[0] + $b[0], $a[1] + $b[1], $a[2] + $b[2]]; } private static function sub(array $a, array $b): array { return [$a[0] - $b[0], $a[1] - $b[1], $a[2] - $b[2]]; } private static function scale(array $a, float $s): array { return [$a[0] * $s, $a[1] * $s, $a[2] * $s]; } private static function dot(array $a, array $b): float { return $a[0] * $b[0] + $a[1] * $b[1] + $a[2] * $b[2]; } private static function cross(array $a, array $b): array { return [$a[1] * $b[2] - $a[2] * $b[1], $a[2] * $b[0] - $a[0] * $b[2], $a[0] * $b[1] - $a[1] * $b[0]]; } private static function length(array $a): float { return sqrt(self::dot($a, $a)); } private static function normalize(array $a): array { $l = max(1e-9, self::length($a)); return self::scale($a, 1.0 / $l); } private static function materialColor(string $name): array { $key = strtolower(basename(str_replace('\\', '/', $name))); if (str_contains($key, 'metal')) return [92, 112, 124]; if (str_contains($key, 'brick')) return [116, 90, 82]; if (str_contains($key, 'city')) return [94, 106, 114]; if (str_contains($key, 'floor')) return [84, 76, 93]; return match ($key) { 'basalt' => [72, 65, 78], 'rust' => [148, 74, 48], 'sand' => [172, 142, 82], 'metal', 'tech', 'trim' => [92, 112, 124], default => [112, 105, 118], }; }}