<?phpnamespace App\Sim;/** * The destructible ground of a level: one byte per pixel (EMPTY, EARTH, STEEL) in a string. * Pure PHP (no SDL): the simulation and its tests run without a window. Every change is also logged in * $changes as row spans, which the view (TerrainView) replays on its picture. */final class Terrain{ public const EMPTY = 0; public const EARTH = 1; public const STEEL = 2; public const BRICK = 3; public string $mask; /** @var array<int, array{0:int,1:int,2:int,3:int}> [y, x0, x1, material] spans changed since the view read them */ public array $changes = []; public function __construct(public readonly int $width, public readonly int $height) { $this->mask = str_repeat("\0", $width * $height); } /** Solid at this pixel? The left / right sides of the level are walls, above and below are open. */ public function solid(int $x, int $y): bool { if ($x < 0 || $x >= $this->width) { return true; } if ($y < 0 || $y >= $this->height) { return false; } return $this->mask[$y * $this->width + $x] !== "\0"; } public function at(int $x, int $y): int { if ($x < 0 || $x >= $this->width) { return self::STEEL; } if ($y < 0 || $y >= $this->height) { return self::EMPTY; } return ord($this->mask[$y * $this->width + $x]); } /** Fills a row span with a material (level building: no change log). */ public function fillSpan(int $y, int $x0, int $x1, int $material): void { if ($y < 0 || $y >= $this->height) { return; } $x0 = max(0, $x0); $x1 = min($this->width - 1, $x1); if ($x1 < $x0) { return; } $this->mask = substr_replace($this->mask, str_repeat(chr($material), $x1 - $x0 + 1), $y * $this->width + $x0, $x1 - $x0 + 1); } public function fillRect(int $x, int $y, int $w, int $h, int $material): void { for ($row = $y; $row < $y + $h; $row++) { $this->fillSpan($row, $x, $x + $w - 1, $material); } } /** * Digs a row span: earth and bricks go, steel stays. Returns true when steel was in the way. */ public function digSpan(int $y, int $x0, int $x1): bool { if ($y < 0 || $y >= $this->height) { return false; } $hitSteel = $x0 < 0 || $x1 >= $this->width; $x0 = max(0, $x0); $x1 = min($this->width - 1, $x1); if ($x1 < $x0) { return $hitSteel; } $offset = $y * $this->width + $x0; $length = $x1 - $x0 + 1; $part = substr($this->mask, $offset, $length); if (trim($part, "\0") === '') { return $hitSteel; } if (!str_contains($part, "\2")) { $this->mask = substr_replace($this->mask, str_repeat("\0", $length), $offset, $length); $this->changes[] = [$y, $x0, $x1, self::EMPTY]; return $hitSteel; } // steel in the span: dig around it, run by run $start = null; for ($i = 0; $i <= $length; $i++) { $c = $i < $length ? $part[$i] : "\2"; if ($c !== "\2") { $start ??= $i; continue; } if ($start !== null) { $n = $i - $start; $this->mask = substr_replace($this->mask, str_repeat("\0", $n), $offset + $start, $n); $this->changes[] = [$y, $x0 + $start, $x0 + $i - 1, self::EMPTY]; $start = null; } } return true; } /** Digs a rectangle; true when steel was in the way. */ public function digRect(int $x, int $y, int $w, int $h): bool { $steel = false; for ($row = $y; $row < $y + $h; $row++) { $steel = $this->digSpan($row, $x, $x + $w - 1) || $steel; } return $steel; } public function digCircle(int $cx, int $cy, int $radius): void { for ($dy = -$radius; $dy <= $radius; $dy++) { $dx = (int)floor(sqrt($radius * $radius - $dy * $dy)); $this->digSpan($cy + $dy, $cx - $dx, $cx + $dx); } } /** Lays bricks on the empty pixels of a row span (what is solid stays as it is). */ public function buildSpan(int $y, int $x0, int $x1): void { if ($y < 0 || $y >= $this->height) { return; } $x0 = max(0, $x0); $x1 = min($this->width - 1, $x1); for ($x = $x0; $x <= $x1; $x++) { $i = $y * $this->width + $x; if ($this->mask[$i] === "\0") { $this->mask[$i] = "\3"; $this->changes[] = [$y, $x, $x, self::BRICK]; } } } /** Any solid pixel in this rectangle? */ public function anySolid(int $x, int $y, int $w, int $h): bool { $x0 = max(0, $x); $x1 = min($this->width - 1, $x + $w - 1); if ($x1 < $x0) { return false; } for ($row = max(0, $y); $row < min($this->height, $y + $h); $row++) { if (trim(substr($this->mask, $row * $this->width + $x0, $x1 - $x0 + 1), "\0") !== '') { return true; } } return false; } /** Any steel pixel in this rectangle (the level sides count as steel)? */ public function anySteel(int $x, int $y, int $w, int $h): bool { if ($x < 0 || $x + $w > $this->width) { return true; } for ($row = max(0, $y); $row < min($this->height, $y + $h); $row++) { if (str_contains(substr($this->mask, $row * $this->width + $x, $w), "\2")) { return true; } } return false; } /** The level as text, one character per $cell x $cell block (debugging, level design). */ public function ascii(int $cell = 8): string { $out = ''; for ($y = 0; $y < $this->height; $y += $cell) { for ($x = 0; $x < $this->width; $x += $cell) { $m = $this->at($x + intdiv($cell, 2), $y + intdiv($cell, 2)); $out .= $m === self::EMPTY ? '.' : ($m === self::STEEL ? 'S' : '#'); } $out .= "\n"; } return $out; }}