chunk index => must be sent again */ private array $dirty = []; /** @var array chunk index => has a texture */ private array $has = []; private int $clear; /** @var int[] */ private array $brick; /** @var array chunk index => changed since the 3D view (World3d) last looked */ public array $touched = []; public function __construct(private Sdl $sdl, private Terrain $terrain, string $earthFile, string $steelFile, array $theme, private int $k = 1) { $w = $terrain->width; $h = $terrain->height; $this->cols = (int)ceil($w / self::CW); $this->rows = (int)ceil($h / self::CH); $k = $this->k; $img = imagecreatetruecolor($w * $k, $h * $k); imagealphablending($img, false); imagesavealpha($img, true); $this->clear = imagecolorallocatealpha($img, 0, 0, 0, 127); imagefill($img, 0, 0, $this->clear); $this->brick = [imagecolorallocate($img, 244, 200, 130), imagecolorallocate($img, 190, 132, 70), imagecolorallocate($img, 120, 76, 40)]; $earth = imagecreatefrompng($earthFile); $steel = imagecreatefrompng($steelFile); $tiles = [Terrain::EARTH => [$earth, imagesx($earth), imagesy($earth)], Terrain::STEEL => [$steel, imagesx($steel), imagesy($steel)]]; $tiles[Terrain::BRICK] = $tiles[Terrain::EARTH]; for ($y = 0; $y < $h; $y++) { $row = substr($terrain->mask, $y * $w, $w); if (trim($row, "\0") === '') { continue; } for ($x = 0; $x < $w;) { $c = $row[$x]; $n = strspn($row, $c, $x); if ($c !== "\0") { [$tile, $tw, $th] = $tiles[ord($c)]; for ($px = $x * $k; $px < ($x + $n) * $k;) { $len = min($tw - $px % $tw, ($x + $n) * $k - $px); for ($sy = $y * $k; $sy < ($y + 1) * $k; $sy++) { imagecopy($img, $tile, $px, $sy, $px % $tw, $sy % $th, $len, 1); } $px += $len; } } $x += $n; } } // surfaces: grass on what faces the sky, a shadow under it, a dark line under overhangs $grass = array_map(fn (string $c) => self::color($img, $c), $theme['grass']); $edge = self::color($img, $theme['edge']); imagealphablending($img, true); $shade = imagecolorallocatealpha($img, 0, 0, 0, 96); $light = imagecolorallocatealpha($img, 255, 255, 255, 104); $mask = $terrain->mask; $depth = []; for ($i = 0; $i < 8; $i++) { $depth[] = imagecolorallocatealpha($img, 0, 0, 0, 70 + $i * 7); } $dot = static function (int $x, int $y, int $color) use ($img, $k): void { $k === 1 ? imagesetpixel($img, $x, $y, $color) : imagefilledrectangle($img, $x * $k, $y * $k, $x * $k + $k - 1, $y * $k + $k - 1, $color); }; for ($x = 0; $x < $w; $x++) { $prev = "\0"; for ($y = 0; $y < $h; $y++) { $c = $mask[$y * $w + $x]; if ($c !== "\0" && $prev === "\0") { if ($c === "\1") { $tuft = 2 + (($x * 7 + ($x >> 2) * 13) % 3); if ($k === 1) { for ($d = 0; $d < 7 && $y + $tuft + 2 + $d < $h && $mask[($y + $tuft + 2 + $d) * $w + $x] === "\1"; $d++) { $dot($x, $y + $tuft + 2 + $d, $shade); } } else { // HD: the soil gets darker little by little under the grass, a line of light just below it for ($d = 0; $d < 16 && $y + $tuft + 1 + $d < $h && $mask[($y + $tuft + 1 + $d) * $w + $x] === "\1"; $d++) { $dot($x, $y + $tuft + 1 + $d, $depth[min(7, $d >> 1)]); } } if ($k === 1) { for ($d = 0; $d < $tuft + 2 && $y + $d < $h && $mask[($y + $d) * $w + $x] === "\1"; $d++) { imagesetpixel($img, $x, $y + $d, $d < 1 ? $grass[0] : ($d < $tuft ? $grass[1] : $grass[2])); } } else { // HD: thin blades, one per real pixel column, of uneven lengths, a bright tip for ($sx = $x * $k; $sx < ($x + 1) * $k; $sx++) { $len = ($tuft + 1) * $k + (($sx * 5 + ($sx >> 1) * 3) % 5) - 2; $top = $y * $k - (($sx * 3 + ($sx >> 2)) % 3); imageline($img, $sx, $top, $sx, $top + 2, $grass[0]); imageline($img, $sx, $top + 3, $sx, $top + (int)($len * 0.6), $grass[1]); imageline($img, $sx, $top + (int)($len * 0.6) + 1, $sx, $top + $len, $grass[2]); } } } else { $dot($x, $y, $light); } } elseif ($c === "\0" && $prev !== "\0" && $y >= 2) { $dot($x, $y - 1, $edge); $dot($x, $y - 2, $shade); } $prev = $c; } } for ($y = 0; $y < $h; $y++) { // left / right faces $row = substr($mask, $y * $w, $w); for ($x = 0; $x < $w;) { $c = $row[$x]; $n = strspn($row, $c, $x); if ($c !== "\0") { if ($x > 0) { imagefilledrectangle($img, $x * $k, $y * $k, $x * $k, $y * $k + $k - 1, $edge); if ($k > 1 && $n > 2) { // HD: a lit bevel on faces turned to the left, shade on the others imagefilledrectangle($img, $x * $k + 1, $y * $k, $x * $k + $k + 1, $y * $k + $k - 1, $light); } } if ($k > 1 && $x + $n < $w && $n > 2) { imagefilledrectangle($img, ($x + $n - 2) * $k, $y * $k, ($x + $n) * $k - 2, $y * $k + $k - 1, $shade); } if ($x + $n < $w) { imagefilledrectangle($img, ($x + $n) * $k - 1, $y * $k, ($x + $n) * $k - 1, $y * $k + $k - 1, $edge); } } $x += $n; } } imagealphablending($img, false); $this->image = $img; for ($i = 0; $i < $this->cols * $this->rows; $i++) { $this->dirty[$i] = true; } $this->refresh(9999); } private static function color(\GdImage $img, string $hex): int { $c = \JumPHP\Util\Color::of($hex); return imagecolorallocate($img, $c->r, $c->g, $c->b); } /** Replays the terrain's changes on the picture and sends up to $max chunks again. */ public function refresh(int $max = 3): void { $k = $this->k; foreach ($this->terrain->changes as [$y, $x0, $x1, $material]) { if ($material === Terrain::EMPTY) { imagefilledrectangle($this->image, $x0 * $k, $y * $k, $x1 * $k + $k - 1, $y * $k + $k - 1, $this->clear); } else { for ($x = $x0; $x <= $x1; $x++) { imagefilledrectangle($this->image, $x * $k, $y * $k, $x * $k + $k - 1, $y * $k + $k - 1, $this->brick[($y & 1) ? (($x + ($y >> 1)) % 6 === 0 ? 2 : 1) : 0]); } } $row = intdiv($y, self::CH) * $this->cols; for ($c = intdiv($x0, self::CW); $c <= intdiv($x1, self::CW); $c++) { $this->dirty[$row + $c] = true; } // the 3D faces of a pixel depend on its neighbours: the chunks around a change may change too for ($ty = intdiv(max(0, $y - 1), self::CH); $ty <= intdiv(min($this->terrain->height - 1, $y + 1), self::CH); $ty++) { for ($c = intdiv(max(0, $x0 - 1), self::CW); $c <= intdiv(min($this->terrain->width - 1, $x1 + 1), self::CW); $c++) { $this->touched[$ty * $this->cols + $c] = true; } } } $this->terrain->changes = []; foreach (array_keys($this->dirty) as $i) { if ($max-- <= 0) { break; } unset($this->dirty[$i]); $cx = ($i % $this->cols) * self::CW; $cy = intdiv($i, $this->cols) * self::CH; $name = "terrain-$i"; if (!$this->terrain->anySolid($cx, $cy, self::CW, self::CH)) { if ($this->has[$i] ?? false) { $this->sdl->releaseTexture($name); } $this->has[$i] = false; continue; } $w = min(self::CW, $this->terrain->width - $cx); $h = min(self::CH, $this->terrain->height - $cy); // one more level pixel on the right and below (the neighbour's): no seam between chunks when scaled smoothly $pw = min($w + 1, $this->terrain->width - $cx) * $k; $ph = min($h + 1, $this->terrain->height - $cy) * $k; $part = imagecreatetruecolor($pw, $ph); imagealphablending($part, false); imagecopy($part, $this->image, 0, 0, $cx * $k, $cy * $k, $pw, $ph); \SDL_SetHint(\SDL_HINT_RENDER_SCALE_QUALITY, $k > 1 ? '1' : '0'); // HD ground: smooth when the window is not 3x $this->sdl->createTextureFromGd($name, $part); \SDL_SetHint(\SDL_HINT_RENDER_SCALE_QUALITY, '0'); $this->has[$i] = true; } } public function cols(): int { return $this->cols; } public function rows(): int { return $this->rows; } public function scale(): int { return $this->k; } /** * The picture of one chunk for a GL texture: its width and height (pixels of the picture) and its pixels. With * $argb (imagegd() exists) the bytes are GD's own A R G B (alpha 0 = opaque .. 127 = clear), decoded by the shader: * no loop over the pixels in PHP. @return array{0: int, 1: int, 2: string} */ public function chunkPixels(int $i, bool $argb): array { $cx = ($i % $this->cols) * self::CW; $cy = intdiv($i, $this->cols) * self::CH; $k = $this->k; $pw = min(self::CW, $this->terrain->width - $cx) * $k; $ph = min(self::CH, $this->terrain->height - $cy) * $k; $part = imagecreatetruecolor($pw, $ph); imagealphablending($part, false); imagesavealpha($part, true); imagecopy($part, $this->image, 0, 0, $cx * $k, $cy * $k, $pw, $ph); if (!$argb) { return [$pw, $ph, \JumPHP\Gl\WebGl::rgbaFromGd($part)]; } ob_start(); imagegd($part); $bytes = (string)ob_get_clean(); return [$pw, $ph, substr($bytes, strlen($bytes) - $pw * $ph * 4)]; } public function render(Renderer $r, float $camX, float $camY = 0): void { $first = max(0, intdiv((int)$camX, self::CW)); $last = min($this->cols - 1, intdiv((int)$camX + $r->getWidth(), self::CW)); for ($row = 0; $row < $this->rows; $row++) { for ($c = $first; $c <= $last; $c++) { $i = $row * $this->cols + $c; if ($this->has[$i] ?? false) { $t = $this->sdl->getTexture("terrain-$i"); // the texture holds one more level pixel on the right and below: chunks overlap, no gap at any zoom $r->copy($t['texture'], null, $c * self::CW - $camX, $row * self::CH - $camY, $t['width'] / $this->k, $t['height'] / $this->k); } } } } public function release(): void { foreach ($this->has as $i => $has) { if ($has) { $this->sdl->releaseTexture("terrain-$i"); } } $this->has = []; } }