Entrar
PT
games/open-lemmings / src / View / TerrainView.php · commit 04e2e2a
<?phpnamespace App\View;use App\Sim\Terrain;use JumPHP\Renderer\Renderer;use JumPHP\Renderer\Sdl;/** * The picture of a Terrain: a GD image painted from its mask (earth texture, grass on top, dark edges, steel * plates), cut into chunk textures. What the Lumikins dig or build (Terrain::$changes) is replayed on the image and * only the chunks touched are sent again, a few per frame. */final class TerrainView{    public const CW = 64;    public const CH = 58;    private \GdImage $image;    private int $cols;    private int $rows;    /** @var array<int, true> chunk index => must be sent again */    private array $dirty = [];    /** @var array<int, bool> chunk index => has a texture */    private array $has = [];    private int $clear;    /** @var int[] */    private array $brick;    /** @var array<int, true> 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 = [];    }}