ES
games/doom-jumphp / src / Engine / Map / LevelBuilder.php · commit 8e1cb9f
<?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,                ];            }        }    }}