<?phpnamespace App\Engine\Map;/** * Turns a level drawn in text (one character = a 64 x 64 cell, see assets/maps/*.json) into the geometry of the * engine: sectors, lines with their sides, things. Neighbouring cells of the same character form one sector; * the edge between two different areas becomes a two-sided line, the edge against a solid cell a wall. * Collinear edges are merged into long lines. Diagonal cells cut a corner. * * Legend entries: * solid: {"solid": true, "tex": "BRICK", "switch": "exit" | "open:X"} * area: {"floor": 0, "ceil": 128, "light": 160, "fpic": "FLOOR1", "cpic": "CEIL1" | "SKY", "tex": "TECH" (its walls), * "trim": "STEP" (upper / lower textures seen from it), "face": "DOOR" (what others see of it), * "kind": "door" | "gate" | "lift" | "exit", "key": "red" | "blue" | "yellow", "secret": true, "hidden": true (doors), * "damage": 5 | 10 | 20, "fx": "flicker" | "glow" | "strobe", "grate": "GRATE" (bars on its edges)} * diagonal: {"diag": "ne" | "nw" | "se" | "sw" (the solid corner), "open": ".", "wall": "#"} */final class LevelBuilder{ public const CELL = 64; /** @var Sector[] */ public array $sectors = []; /** @var Line[] */ public array $lines = []; /** @var array<int, array{type: string, x: float, y: float, angle: float, def: array}> */ public array $things = []; private int $rows; private int $cols; /** @var string[] */ private array $map; private array $legend; /** @var array<int, array<int, int>> region id per cell (-1 = solid) */ private array $region = []; /** @var array<int, string> char of each region */ private array $regionChar = []; /** region => Sector */ private array $regionSector = []; public function __construct(array $level, array $thingLegend) { $this->map = $level['map']; $this->legend = $level['legend']; $this->rows = count($this->map); $this->cols = max(array_map('strlen', $this->map)); $this->regions(); $this->makeSectors(); $this->makeLines(); $this->makeThings($level['things'] ?? [], $thingLegend + ($level['thingLegend'] ?? [])); } public function char(int $c, int $r): string { if ($r < 0 || $r >= $this->rows || $c < 0 || $c >= $this->cols) { return ' '; } return $this->map[$r][$c] ?? ' '; } private function def(string $ch): array { if ($ch === ' ') { return ['solid' => true, 'tex' => 'TECH']; } if (!isset($this->legend[$ch])) { throw new \RuntimeException("Level: the character '$ch' is not in the legend"); } return $this->legend[$ch]; } /** The open character of a cell (a diagonal cell's open part), null when solid. */ private function openChar(int $c, int $r): ?string { $ch = $this->char($c, $r); $d = $this->def($ch); if (!empty($d['solid'])) { return null; } return isset($d['diag']) ? $d['open'] : $ch; } /** What a cell shows on its side $dir (n, s, e, w): its open char, or null when that side is solid. */ private function sideChar(int $c, int $r, string $dir): ?string { $ch = $this->char($c, $r); $d = $this->def($ch); if (!empty($d['solid'])) { return null; } if (isset($d['diag']) && str_contains($d['diag'], $dir)) { return null; } return isset($d['diag']) ? $d['open'] : $ch; } /** The solid char seen on side $dir of a cell (for wall textures). */ private function solidChar(int $c, int $r, string $dir): string { $ch = $this->char($c, $r); $d = $this->def($ch); return isset($d['diag']) ? $d['wall'] : $ch; } private function regions(): void { $id = 0; for ($r = 0; $r < $this->rows; $r++) { for ($c = 0; $c < $this->cols; $c++) { $this->region[$r][$c] = -1; } } $dirs = ['n' => [0, -1, 's'], 's' => [0, 1, 'n'], 'e' => [1, 0, 'w'], 'w' => [-1, 0, 'e']]; for ($r = 0; $r < $this->rows; $r++) { for ($c = 0; $c < $this->cols; $c++) { $ch = $this->openChar($c, $r); if ($ch === null || $this->region[$r][$c] !== -1) { continue; } $this->regionChar[$id] = $ch; $stack = [[$c, $r]]; $this->region[$r][$c] = $id; while ($stack) { [$x, $y] = array_pop($stack); foreach ($dirs as $dir => [$dx, $dy, $back]) { $nx = $x + $dx; $ny = $y + $dy; if ($ny < 0 || $ny >= $this->rows || $nx < 0 || $nx >= $this->cols || $this->region[$ny][$nx] !== -1) { continue; } if ($this->sideChar($x, $y, $dir) === $ch && $this->sideChar($nx, $ny, $back) === $ch) { $this->region[$ny][$nx] = $id; $stack[] = [$nx, $ny]; } } } $id++; } } } private function makeSectors(): void { $bounds = []; for ($r = 0; $r < $this->rows; $r++) { for ($c = 0; $c < $this->cols; $c++) { $g = $this->region[$r][$c]; if ($g >= 0) { [$x0, $y0] = $this->corner($c, $r + 1); $b = $bounds[$g] ?? [INF, INF, -INF, -INF]; $bounds[$g] = [min($b[0], $x0), min($b[1], $y0), max($b[2], $x0 + self::CELL), max($b[3], $y0 + self::CELL)]; } } } foreach ($this->regionChar as $g => $ch) { $d = $this->def($ch); $s = new Sector(count($this->sectors), $ch); $s->def = $d; $s->floor = (float)($d['floor'] ?? 0); $s->ceil = (float)($d['ceil'] ?? 128); $s->floorPic = $d['fpic'] ?? 'FLOOR1'; $s->ceilPic = $d['cpic'] ?? 'CEIL1'; $s->light = (int)($d['light'] ?? 160); $s->kind = $d['kind'] ?? ''; $s->secret = !empty($d['secret']); $s->damage = (int)($d['damage'] ?? 0); $s->cx = ($bounds[$g][0] + $bounds[$g][2]) / 2; $s->cy = ($bounds[$g][1] + $bounds[$g][3]) / 2; $this->sectors[] = $s; $this->regionSector[$g] = $s; } } /** Map coordinates of the top-left corner of cell column $c, grid row $r (rows go down, y goes up). */ private function corner(int $c, int $r): array { return [$c * self::CELL, ($this->rows - $r) * self::CELL]; } private function makeLines(): void { // edges: key => list of [fixed coordinate, from, to, front region, back region or solid char] $edges = []; // vertical edges between column c-1 and c for ($c = 0; $c <= $this->cols; $c++) { for ($r = 0; $r < $this->rows; $r++) { $this->edge($edges, 'v', $c, $r, [$c - 1, $r, 'e'], [$c, $r, 'w']); } } // horizontal edges between row r-1 and r for ($r = 0; $r <= $this->rows; $r++) { for ($c = 0; $c < $this->cols; $c++) { $this->edge($edges, 'h', $c, $r, [$c, $r - 1, 's'], [$c, $r, 'n']); } } foreach ($edges as $key => $list) { usort($list, fn ($a, $b) => $a[0] <=> $b[0]); [$kind, $fixed] = explode(':', $key, 3); $run = null; foreach ($list as $e) { if ($run !== null && $run[1] === $e[0]) { $run[1] = $e[1]; continue; } if ($run !== null) { $this->emit($kind, (int)$fixed, $run, $key); } $run = $e; } if ($run !== null) { $this->emit($kind, (int)$fixed, $run, $key); } } // diagonal walls for ($r = 0; $r < $this->rows; $r++) { for ($c = 0; $c < $this->cols; $c++) { $d = $this->def($this->char($c, $r)); if (!isset($d['diag'])) { continue; } $g = $this->region[$r][$c]; [$x0, $y1] = $this->corner($c, $r); $x1 = $x0 + self::CELL; $y0 = $y1 - self::CELL; // the line goes with the open part on its right [$ax, $ay, $bx, $by] = match ($d['diag']) { 'ne' => [$x0, $y1, $x1, $y0], 'sw' => [$x1, $y0, $x0, $y1], 'nw' => [$x0, $y0, $x1, $y1], default => [$x1, $y1, $x0, $y0], // se }; $this->wall($ax, $ay, $bx, $by, $this->regionSector[$g], $d['wall']); } } } /** Records the edge between cell A (side $da) and cell B (side $db). */ private function edge(array &$edges, string $kind, int $c, int $r, array $a, array $b): void { $ca = $this->sideChar($a[0], $a[1], $a[2]); $cb = $this->sideChar($b[0], $b[1], $b[2]); if ($ca === null && $cb === null) { return; } $ga = $ca === null ? -1 : $this->region[$a[1]][$a[0]]; $gb = $cb === null ? -1 : $this->region[$b[1]][$b[0]]; if ($ga === $gb) { return; } // A = west / north cell, B = east / south cell $sa = $ca === null ? $this->solidChar($a[0], $a[1], $a[2]) : ''; $sb = $cb === null ? $this->solidChar($b[0], $b[1], $b[2]) : ''; $pos = $kind === 'v' ? $r : $c; $fixed = $kind === 'v' ? $c : $r; $edges["$kind:$fixed:$ga:$gb:$sa:$sb"][] = [$pos, $pos + 1, $ga, $gb, $sa, $sb]; } private function emit(string $kind, int $fixed, array $run, string $key): void { [$from, $to, $ga, $gb, $sa, $sb] = $run; // A is west (v) / north (h) of the edge if ($kind === 'v') { $x = $fixed * self::CELL; $yTop = ($this->rows - $from) * self::CELL; $yBottom = ($this->rows - $to) * self::CELL; // front on A (west): going down (dir (0,-1)) has its right side to the west $aLine = [$x, $yTop, $x, $yBottom]; $bLine = [$x, $yBottom, $x, $yTop]; } else { $y = ($this->rows - $fixed) * self::CELL; $x0 = $from * self::CELL; $x1 = $to * self::CELL; // front on A (north): going east (dir (1,0)) has its right side to the south... so go west $aLine = [$x1, $y, $x0, $y]; $bLine = [$x0, $y, $x1, $y]; } if ($ga < 0) { $this->wall(...[...$bLine, $this->regionSector[$gb], $sa]); return; } if ($gb < 0) { $this->wall(...[...$aLine, $this->regionSector[$ga], $sb]); return; } $sA = $this->regionSector[$ga]; $sB = $this->regionSector[$gb]; // the special sector (door, lift...) goes behind: lines face away from it, as the original maps do if ($sA->kind !== '' && $sB->kind === '') { $this->portal($bLine, $sB, $sA); } else { $this->portal($aLine, $sA, $sB); } } private function wall(float $x1, float $y1, float $x2, float $y2, Sector $sector, string $solidChar): void { $d = $this->def($solidChar); $side = new Side($sector); $side->mid = $d['tex'] ?? 'TECH'; if (isset($sector->def['tex']) && empty($d['switch'])) { $side->mid = $sector->def['tex']; } $line = new Line(count($this->lines), $x1, $y1, $x2, $y2, $side); $line->flags |= Line::BLOCKING; if ($sector->kind === 'door' || $sector->kind === 'gate') { $line->flags |= Line::UNPEG_BOTTOM; // door tracks stay still } if (!empty($d['switch'])) { [$what, $arg] = array_pad(explode(':', $d['switch'], 2), 2, ''); $line->special = $what === 'exit' ? 'exit' : 'switch'; $line->args = ['target' => $arg, 'off' => $side->mid, 'on' => $d['on'] ?? self::switchOn($side->mid)]; } if (!empty($d['secret'])) { $line->flags |= Line::SECRET; } $this->link($line); } public static function switchOn(string $tex): string { return str_ends_with($tex, '0') ? substr($tex, 0, -1) . '1' : $tex; } private function portal(array $xy, Sector $front, Sector $back): void { $f = new Side($front); $b = new Side($back); $line = new Line(count($this->lines), $xy[0], $xy[1], $xy[2], $xy[3], $f); $line->setBack($b); // what each side shows of the other one's steps / door / lift foreach ([[$f, $back, $front], [$b, $front, $back]] as [$side, $other, $own]) { $tex = $other->def['face'] ?? $own->def['trim'] ?? $own->def['tex'] ?? 'STEP'; if ($other->kind === 'door' || $other->kind === 'gate') { $tex = $other->def['face'] ?? match ($other->def['key'] ?? '') { 'red' => 'DOORRED', 'blue' => 'DOORBLUE', 'yellow' => 'DOORYEL', default => 'DOOR', }; } $side->top = $tex; $side->bottom = $other->kind === 'lift' ? ($other->def['face'] ?? 'LIFT') : ($own->def['trim'] ?? $own->def['tex'] ?? 'STEP'); if (isset($other->def['grate'])) { $side->mid = $other->def['grate']; } } if (isset($back->def['grate']) || isset($front->def['grate'])) { $line->flags |= Line::BLOCKING; } if ($back->kind === 'door') { $line->special = 'door'; $line->args = ['key' => $back->def['key'] ?? '']; } elseif ($back->kind === 'lift') { $line->special = 'lift'; } if (!empty($back->def['hidden'])) { // a hidden door looks like a wall on the map $line->flags |= Line::SECRET; } if (!empty($back->def['monsterblock'])) { $line->flags |= Line::BLOCK_MONSTERS; } $this->link($line); } private function link(Line $line): void { $this->lines[] = $line; $line->frontSector->lines[] = $line; if ($line->backSector !== null) { $line->backSector->lines[] = $line; } } private function makeThings(array $rows, array $legend): void { foreach ($rows as $r => $row) { for ($c = 0, $n = strlen($row); $c < $n; $c++) { $ch = $row[$c]; if ($ch === '.' || $ch === ' ' || !isset($legend[$ch])) { continue; } $def = $legend[$ch]; [$x0, $y1] = $this->corner($c, $r); $this->things[] = [ 'type' => $def['type'], 'x' => $x0 + self::CELL / 2 + ($def['dx'] ?? 0), 'y' => $y1 - self::CELL / 2 + ($def['dy'] ?? 0), 'angle' => deg2rad((float)($def['angle'] ?? 90)), 'def' => $def, ]; } } }}