FR

Elfs & Orcs Tower Defense

par malabke · Stratégie
5,0 (1)
▶ Jouer 💬 Avis Sources 🕘 Versions Éditer GitHub
games/elfs-orcs-tower-defense / src / Ui / Sprites.php · commit 65500d9
<?phpnamespace App\Ui;use App\Td\GameData;use App\Td\Path;use JumPHP\Renderer\Renderer;use JumPHP\Renderer\Sdl;use JumPHP\Util\Color;/** * Every picture of the game, made in code the first time it is asked for (pixel strings and GD), each side with * its own palette. A PNG of the same name in assets/sprites/ replaces the generated one: *   tower-<sprite>-<level>.png (level 1..3, or the specialization's key), enemy-<sprite>.png (2 frames side by side), *   icon-<name>.png, map-<map>.png (480 x 240). */final class Sprites{    public const TOWER_W = 28;    public const TOWER_H = 40;    private ?Sdl $sdl = null;    private string $root = '';    public function attach(Sdl $sdl, string $root): void    {        $this->sdl = $sdl;        $this->root = rtrim($root, '/');    }    /** @var array<string, true> pictures of assets/sprites/cards/ that are not there */    private array $missing = [];    /**     * Draws a picture of the 3D game (assets/sprites/cards/$name.png, cut from real frames by tools/site/make.php),     * smoothed when it is scaled. False when it is not there: draw something else.     */    public function drawPicture(Renderer $r, string $name, float $x, float $y, float $w, float $h): bool    {        $sdl = $this->sdl ?? throw new \LogicException('Sprites::attach() first');        $key = "picture:$name";        if (!$sdl->hasTexture($key)) {            $png = "assets/sprites/cards/$name.png";            if (isset($this->missing[$name]) || !is_file($this->root . '/' . $png)) {                $this->missing[$name] = true;                return false;            }            // linear filtering for this one (SDL reads the hint when it makes a texture)            \SDL_SetHint(\SDL_HINT_RENDER_SCALE_QUALITY, '1');            $sdl->loadTexture($key, $png);            \SDL_SetHint(\SDL_HINT_RENDER_SCALE_QUALITY, '0');        }        $r->copy($sdl->getTexture($key)['texture'], null, $x, $y, $w, $h);        return true;    }    /**     * Draws the portrait of a 3D model (assets/sprites/portraits/$name.png: unit-<sprite> or tower-<sprite>,     * photographed by tests/PortraitShotsTest.php) centred on ($cx, $cy), $size wide and tall, smoothed;     * $flip: turned to face left. False when it is not there: draw something else.     */    public function drawPortrait(Renderer $r, string $name, float $cx, float $cy, float $size, bool $flip = false): bool    {        $sdl = $this->sdl ?? throw new \LogicException('Sprites::attach() first');        $key = "portrait:$name";        if (!$sdl->hasTexture($key)) {            $png = "assets/sprites/portraits/$name.png";            if (isset($this->missing[$key]) || !is_file($this->root . '/' . $png)) {                $this->missing[$key] = true;                return false;            }            \SDL_SetHint(\SDL_HINT_RENDER_SCALE_QUALITY, '1');            $sdl->loadTexture($key, $png);            \SDL_SetHint(\SDL_HINT_RENDER_SCALE_QUALITY, '0');        }        $r->copy($sdl->getTexture($key)['texture'], null, $cx - $size / 2, $cy - $size / 2, $size, $size, $flip);        return true;    }    /** Draws a texture made by $make() the first time (or loaded from assets/sprites/$file). */    private function texture(string $name, string $file, callable $make): array    {        $sdl = $this->sdl ?? throw new \LogicException('Sprites::attach() first');        if (!$sdl->hasTexture($name)) {            $png = 'assets/sprites/' . $file . '.png';            if (is_file($this->root . '/' . $png)) {                $sdl->loadTexture($name, $png);            } else {                $make($name);            }        }        return $sdl->getTexture($name);    }    // ---- towers -----------------------------------------------------------------------------------------------    /** Draws a tower standing with its foot at ($x, $y). $specIndex: which of its two specializations $spec is. */    public function drawTower(Renderer $r, string $sprite, int $level, ?string $spec, float $x, float $y, float $scale = 1.0,                              ?int $specIndex = null): void    {        $key = $spec ?? (string)min(3, $level);        $tex = $this->texture("tower:$sprite:$key", "tower-$sprite-$key",            fn (string $name) => $this->makeTower($name, $sprite, min(3, $level), $spec, $specIndex));        $w = $tex['width'] * $scale;        $h = $tex['height'] * $scale;        $r->copy($tex['texture'], null, round($x - $w / 2), round($y - $h), $w, $h);    }    private function makeTower(string $name, string $sprite, int $level, ?string $spec, ?int $specIndex): void    {        $w = self::TOWER_W;        $h = self::TOWER_H;        $img = imagecreatetruecolor($w, $h);        imagealphablending($img, false);        imagesavealpha($img, true);        $clear = imagecolorallocatealpha($img, 0, 0, 0, 127);        imagefill($img, 0, 0, $clear);        if (in_array($sprite, TowerArt::SPRITES, true)) {            // the other sides' towers            TowerArt::paint($img, $sprite, $level, $spec !== null ? (int)$specIndex : null);            $this->sdl?->createTextureFromGd($name, $img);            return;        }        $c = static function (string $hex) use ($img): int {            $col = Color::of($hex);            return imagecolorallocatealpha($img, $col->r, $col->g, $col->b, 127 - intdiv($col->a, 2));        };        $rect = static fn (int $x, int $y, int $rw, int $rh, int $col) => imagefilledrectangle($img, $x, $y, $x + $rw - 1, $y + $rh - 1, $col);        $poly = static fn (array $points, int $col) => imagefilledpolygon($img, $points, $col);        $oval = static fn (int $cx, int $cy, int $ow, int $oh, int $col) => imagefilledellipse($img, $cx, $cy, $ow, $oh, $col);        $ink = $c('#1a1424');        $gold = $c('#e8c040');        $bottom = $h - 1;        $top = $h - 20 - 4 * $level;   // taller with each level        switch ($sprite) {            case 'archer':                $stone = $c('#d8d4c4');                $shade = $c('#a09c8c');                $roof = $c($spec === 'keen_eye' ? '#3a8ad0' : '#3fa860');                $roofDark = $c($spec === 'keen_eye' ? '#1d4a80' : '#1d5434');                if ($spec === 'storm_of_shafts') {                    foreach ([2, 20] as $sx) {                        $rect($sx - 1, $bottom - 15, 8, 16, $ink);                        $rect($sx, $bottom - 14, 6, 14, $stone);                        $poly([$sx - 1, $bottom - 15, $sx + 6, $bottom - 15, $sx + 3, $bottom - 21], $roof);                    }                }                $rect(8, $top + 9, 12, $bottom - $top - 8, $ink);                $rect(9, $top + 10, 10, $bottom - $top - 10, $stone);                $rect(16, $top + 10, 3, $bottom - $top - 10, $shade);                for ($y = $top + 13; $y < $bottom - 1; $y += 4) {                    $rect(9, $y, 10, 1, $shade);                }                $rect(12, $top + 13, 3, 4, $ink);                $rect(12, $bottom - 5, 4, 5, $c('#5a3a20'));                if ($level >= 2) {                    $rect(6, $top + 8, 16, 3, $ink);                    $rect(7, $top + 8, 14, 2, $level >= 3 ? $gold : $shade);                }                $poly([6, $top + 9, 22, $top + 9, 14, $top], $ink);                $poly([7, $top + 8, 21, $top + 8, 14, $top + 1], $roof);                $poly([14, $top + 1, 21, $top + 8, 14, $top + 8], $roofDark);                if ($level >= 3) {                    $rect(14, $top - 6, 1, 7, $gold);                    $rect(15, $top - 6, 5, 3, $roof);                }                if ($spec === 'keen_eye') {                    $oval(14, $top + 5, 3, 3, $c('#c0f0ff'));                }                break;            case 'sanctuary':                $white = $c('#f0f0e8');                $shade = $c('#b0b8c8');                $glow = $c($spec === 'frost_moon' ? '#a0f0ff' : ($spec === 'starfall' ? '#ffe070' : '#9ab8ff'));                // the dome: an oval whose lower half is cut away                $oval(14, $top + 12, 22, 16, $ink);                $oval(14, $top + 12, 20, 14, $white);                $oval(16, $top + 13, 14, 10, $shade);                $rect(2, $top + 12, 24, 9, $clear);                $rect(4, $top + 11, 20, 2, $ink);                foreach ([6, 19] as $px) {                    $rect($px - 1, $top + 13, 5, $bottom - $top - 15, $ink);                    $rect($px, $top + 13, 3, $bottom - $top - 16, $white);                    $rect($px + 2, $top + 13, 1, $bottom - $top - 16, $shade);                }                $rect(3, $bottom - 3, 22, 4, $ink);                $rect(4, $bottom - 2, 20, 3, $white);                $rect(4, $bottom - 1, 20, 1, $shade);                $orb = 4 + $level;                $oval(14, $bottom - 9, $orb + 2, $orb + 2, $ink);                $oval(14, $bottom - 9, $orb, $orb, $glow);                // the crescent: a disc, and a transparent one over it                $oval(14, $top + 1, 8, 8, $c('#f4f0b0'));                $oval(16, $top, 7, 7, $clear);                if ($level >= 3) {                    $rect(5, $top + 9, 1, 1, $gold);                    $rect(22, $top + 9, 1, 1, $gold);                    $rect(8, $top + 4, 1, 1, $glow);                    $rect(20, $top + 6, 1, 1, $glow);                }                if ($spec === 'starfall') {                    $poly([14, $top - 6, 16, $top - 2, 20, $top - 2, 17, $top, 18, $top + 4, 14, $top + 2, 10, $top + 4, 11, $top, 8, $top - 2, 12, $top - 2], $gold);                }                break;            case 'sentinel':                $bark = $c($spec === 'ancient_oak' ? '#5a3a1c' : '#7a5230');                $barkDark = $c('#4a3018');                $leaf = $c($spec === 'thorn_grove' ? '#6a3a8a' : ($spec === 'ancient_oak' ? '#2e6a2a' : '#4caf50'));                $leafDark = $c($spec === 'thorn_grove' ? '#3a1a5a' : '#2e7d32');                $poly([7, $bottom, 11, $bottom - 6, 17, $bottom - 6, 21, $bottom, 17, $bottom - 2, 14, $bottom, 11, $bottom - 2], $bark);                $rect(10, $top + 12, 9, $bottom - $top - 13, $ink);                $rect(11, $top + 12, 7, $bottom - $top - 13, $bark);                $rect(16, $top + 12, 2, $bottom - $top - 13, $barkDark);                $rect(12, $bottom - 12, 2, 2, $c('#ffe060'));                $rect(15, $bottom - 12, 2, 2, $c('#ffe060'));                $rect(12, $bottom - 8, 5, 1, $barkDark);                $crown = 14 + 2 * $level;                $oval(14, $top + 8, $crown + 2, $crown - 2, $ink);                $oval(8, $top + 10, 10, 8, $leafDark);                $oval(20, $top + 10, 10, 8, $leafDark);                $oval(14, $top + 8, $crown, $crown - 4, $leaf);                $oval(11, $top + 5, 6, 4, $c($spec === 'thorn_grove' ? '#a070c0' : '#8ad07a'));                if ($level >= 2) {                    // branch arms                    $rect(5, $bottom - 14, 6, 2, $bark);                    $rect(18, $bottom - 16, 6, 2, $bark);                    $oval(5, $bottom - 16, 5, 4, $leaf);                    $oval(24, $bottom - 18, 5, 4, $leaf);                }                if ($level >= 3) {                    $rect(9, $top + 4, 1, 1, $gold);                    $rect(19, $top + 7, 1, 1, $gold);                    $rect(15, $top + 2, 1, 1, $gold);                }                if ($spec === 'thorn_grove') {                    foreach ([[6, 8], [22, 6], [10, 13], [19, 12]] as [$tx, $ty]) {                        $rect($tx, $top + $ty, 1, 2, $c('#e0d0f0'));                    }                }                break;            default: // songmage                $robe = $c($spec === 'dirge_of_dread' ? '#5a2a3a' : '#7a4ab8');                $robeDark = $c($spec === 'dirge_of_dread' ? '#2a1018' : '#4a2a78');                $gem = $c($spec === 'stormsinger' ? '#fff070' : ($spec === 'dirge_of_dread' ? '#ff4040' : '#90f0ff'));                $poly([7, $bottom, 21, $bottom, 17, $top + 10, 11, $top + 10], $ink);                $poly([8, $bottom - 1, 20, $bottom - 1, 16, $top + 11, 12, $top + 11], $robe);                $poly([14, $top + 11, 16, $top + 11, 20, $bottom - 1, 14, $bottom - 1], $robeDark);                $rect(12, $bottom - 10, 4, 3, $gold);                $rect(13, $bottom - 4, 2, 4, $ink);                $rect(11, $top + 8, 7, 3, $ink);                $rect(12, $top + 9, 5, 1, $gold);                $poly([14, $top - 1, 19, $top + 4, 14, $top + 9, 9, $top + 4], $ink);                $poly([14, $top, 18, $top + 4, 14, $top + 8, 10, $top + 4], $gem);                $rect(12, $top + 3, 2, 2, $c('#ffffff'));                if ($level >= 2) {                    // floating notes                    $rect(3, $top + 12, 2, 2, $gold);                    $rect(4, $top + 7, 1, 6, $gold);                    $rect(5, $top + 7, 2, 1, $gold);                }                if ($level >= 3) {                    $rect(23, $top + 16, 2, 2, $gold);                    $rect(24, $top + 11, 1, 6, $gold);                }                if ($spec === 'stormsinger') {                    $poly([22, $top, 25, $top, 23, $top + 4, 26, $top + 4, 21, $top + 10, 22, $top + 5, 20, $top + 5], $gem);                }                break;        }        $this->sdl?->createTextureFromGd($name, $img);    }    // ---- enemies ----------------------------------------------------------------------------------------------    /** Pixel art of the enemies (facing right): two frames each, and a palette. */    private const HUMANOID = [        '....kkkk....',        '...khhhhk...',        '...kssssk.w.',        '...ksesek.w.',        '...kstssk.w.',        '..kkkaakkkw.',        '.ksAaaaaAsk.',        '.kskaAAaksk.',        '...kaaaak...',    ];    private const HUMANOID_LEGS = [        ['...kAkkAk...', '...kSk.kSk..', '...kk...kk..'],        ['...kAkkAk...', '....kSkSk...', '....kk.kk...'],    ];    private const GOBLIN = [        'k........k',        'kskkkkkksk',        '.kssssssk.',        '..ksesesk.',        '...kssk..w',        '..kaaaak.w',        '.kskaakskw',        '...kaak...',    ];    private const GOBLIN_LEGS = [        ['...kSkSk..', '...kk.kk..'],        ['..kSk.kSk.', '..kk...kk.'],    ];    private const WARG = [        '......kkk.......',        '.....ksssk......',        '.....ksesk......',        '....kkaaakk.....',        '...kaaaaaaak..kk',        'kk.kffffffffkkfk',        'kfkfffffffffffek',        '.kfFffffffffffk.',        '..kfFFFFFFFfkk..',    ];    private const WARG_LEGS = [        ['..kfk.kfk.kfk...', '..kk..kk..kk....'],        ['.kfk..kfk..kfk..', '.kk...kk...kk...'],    ];    private const BAT = [        [            'k............k',            'kk..........kk',            'kmk..k..k..kmk',            'kmmk.kkkk.kmmk',            '.kmmkbbbekmmk.',            '..kmkbbbbkmk..',            '...kk.kk.kk...',            '..............',        ],        [            '..............',            '.....k..k.....',            '.....kkkk.....',            '..kkkbbbekkk..',            '.kmmmbbbbmmmk.',            'kmmk.kkkk.kmmk',            'kmk........kmk',            'kk..........kk',        ],    ];    private const RAM = [        '...kkkkkkkkkk.....',        '..krrrrrrrrrrk....',        '.krrrrrrrrrrrrk...',        '.kwwkwwkwwkwwwk...',        '.kwwkwwkwwkwwwk...',        'kklllllllllllllkkk',        'kLLLLLLLLLLLLLLLiik',        'kklllllllllllllkkk',        '.kwwwwwwwwwwwwwk..',    ];    private const RAM_WHEELS = [        ['.kkokkkkkkkkokkk..', '..kok......kok....', '...k........k.....'],        ['.kkokkkkkkkkokkk..', '..kko......kko....', '...k........k.....'],    ];    private const TROLL = [        '.......kkkkkk.......',        '......kssssssk......',        '.....kssessesk......',        '.....ksssssssk......',        '.....kssttttsk...cc.',        '......kssssssk..kcck',        '...kkkkssssssskkkcck',        '..kssssSSSSSSssskck.',        '.kssskSSSSSSSSksskk.',        '.ksskSaaaaaaaaSksk..',        '.ksk.kaaaaaaaak.kk..',        '.ksk.kaAAaaAAak.....',        '.kk..kaaaaaaaak.....',        '.....kSSSkkSSSk.....',    ];    private const TROLL_LEGS = [        ['.....kSSk..kSSk.....', '....kSSSk..kSSSk....', '....kkkkk..kkkkk....'],        ['......kSSk.kSSk.....', '.....kSSSkkSSSk.....', '.....kkkkkkkkkk.....'],    ];    private const DWARF = [        '...khhhhk...',        '..khhhhhhk..',        '..kssesesk.w',        '..kbbbbbbk.w',        '.kabbbbbbak.',        'kaakbbbbkaak',        '.k.kaaaak.k.',        '...kaaaak...',    ];    private const DWARF_LEGS = [        ['...kAkkAk...', '...kk..kk...'],        ['..kAk..kAk..', '..kk....kk..'],    ];    /**     * Each sprite: [rows, legs per frame, palette], or [frames, null, palette] for the winged ones.     * @return array<string, array{0: list<mixed>, 1: list<list<string>>|null, 2: array<string, string>}>     */    private static function enemyArt(): array    {        $ink = '#141018';        $wings = static fn (string $wing, string $body, string $eye) => [self::BAT, null, ['k' => $ink, 'm' => $wing, 'b' => $body, 'e' => $eye]];        $human = static fn (array $p) => [self::HUMANOID, self::HUMANOID_LEGS, ['k' => $ink, 't' => $p['s']] + $p];        $dwarf = static fn (array $p) => [self::DWARF, self::DWARF_LEGS, ['k' => $ink] + $p];        $small = static fn (array $p) => [self::GOBLIN, self::GOBLIN_LEGS, ['k' => $ink] + $p];        $mounted = static fn (array $p) => [self::WARG, self::WARG_LEGS, ['k' => $ink] + $p];        return [            // the alliance            'elf' => $human(['h' => '#f0d870', 's' => '#f0d0b0', 'S' => '#5a8a3a', 'e' => '#2a5a2a', 'a' => '#3f8a50', 'A' => '#1d5434', 'w' => '#8a6a3a']),            'blade' => $human(['h' => '#e8e8f0', 's' => '#f0d0b0', 'S' => '#4a3a6a', 'e' => '#3a2a6a', 'a' => '#7a5ab0', 'A' => '#3a2a6a', 'w' => '#e0e8f0']),            'warden' => $human(['h' => '#fff0a0', 's' => '#f8e0c8', 'S' => '#c8a040', 'e' => '#2a6aa0', 'a' => '#f0f0e0', 'A' => '#c8a040', 'w' => '#a0e0ff']),            'eagle' => $wings('#8a5a2a', '#5a3a1a', '#ffd020'),            'dwarf' => $dwarf(['h' => '#9aa0aa', 's' => '#e8b890', 'e' => '#2a2a2a', 'b' => '#c86a2a', 'a' => '#6a7280', 'A' => '#3a4048', 'w' => '#c8c8d0']),            'miner' => $dwarf(['h' => '#e8c040', 's' => '#e8b890', 'e' => '#2a2a2a', 'b' => '#8a5a2a', 'a' => '#6a5040', 'A' => '#3a2a20', 'w' => '#a0a0a8']),            'runebearer' => $dwarf(['h' => '#3a5a9a', 's' => '#e8b890', 'e' => '#2a2a2a', 'b' => '#e8e8e8', 'a' => '#3a5a9a', 'A' => '#1e3050', 'w' => '#80e0ff']),            'thane' => $dwarf(['h' => '#e8c040', 's' => '#e8b890', 'e' => '#2a2a2a', 'b' => '#b03a20', 'a' => '#a02a20', 'A' => '#5a1810', 'w' => '#e8c040']),            'rider' => $mounted(['s' => '#f0c8a0', 'e' => '#3a2a1a', 'a' => '#c08a30', 'f' => '#a0703a', 'F' => '#6a4a26']),            'horsearcher' => $mounted(['s' => '#f0c8a0', 'e' => '#3a2a1a', 'a' => '#3f7a40', 'f' => '#d8d0c0', 'F' => '#9a9080']),            'horselord' => $mounted(['s' => '#f0c8a0', 'e' => '#3a2a1a', 'a' => '#e8c040', 'f' => '#f0f0f0', 'F' => '#b0b0b0']),            'scout' => $small(['s' => '#e0b890', 'S' => '#4a4a50', 'e' => '#2a2a2a', 'a' => '#3a3a44', 'w' => '#c8c8d0']),            'littleman' => $small(['s' => '#e8c098', 'S' => '#6a4a2a', 'e' => '#2a2a2a', 'a' => '#5a8a3a', 'w' => '#8a6a3a']),            'captain' => $small(['s' => '#e8c098', 'S' => '#6a4a2a', 'e' => '#2a2a2a', 'a' => '#c84a3a', 'w' => '#e8c040']),            // the horde            'warlord' => $human(['h' => '#3a3a40', 's' => '#4a7a2a', 'S' => '#2a4a1a', 'e' => '#ff3010', 'a' => '#7a1a14', 'A' => '#3a0c08', 'w' => '#c8c8c8']),            'grunt' => [self::HUMANOID, self::HUMANOID_LEGS, ['k' => $ink, 'h' => '#5e9e3a', 's' => '#5e9e3a', 'S' => '#3c6e26',                'e' => '#e03030', 't' => '#f0f0d0', 'a' => '#7a4a2a', 'A' => '#4e2e18', 'w' => '#b8b8c0']],            'iron_orc' => [self::HUMANOID, self::HUMANOID_LEGS, ['k' => $ink, 'h' => '#8a8f99', 's' => '#4f8a32', 'S' => '#2f5a1e',                'e' => '#ff5020', 't' => '#f0f0d0', 'a' => '#7d828c', 'A' => '#4c5058', 'w' => '#5a3a20']],            'goblin' => [self::GOBLIN, self::GOBLIN_LEGS, ['k' => $ink, 's' => '#a8b840', 'S' => '#6a7a20', 'e' => '#ffe020',                'a' => '#6a4a8a', 'w' => '#c8c8d0']],            'warg' => [self::WARG, self::WARG_LEGS, ['k' => $ink, 's' => '#5e9e3a', 'e' => '#ff3030', 'a' => '#7a4a2a',                'f' => '#6b5a4a', 'F' => '#4a3c30']],            'bat' => $wings('#5a3a6a', '#3a2a40', '#ff4040'),            'ram' => [self::RAM, self::RAM_WHEELS, ['k' => $ink, 'r' => '#8a3a2a', 'w' => '#8a6a3a', 'l' => '#6a4a2a',                'L' => '#4a3018', 'i' => '#a0a0aa', 'o' => '#3a3a3a']],            'troll' => [self::TROLL, self::TROLL_LEGS, ['k' => $ink, 's' => '#7a8aa0', 'S' => '#55657a', 'e' => '#ffd030',                't' => '#f0f0d0', 'a' => '#6a5030', 'A' => '#4a3420', 'c' => '#5a4028']],        ];    }    /** Size of an enemy's picture. @return array{0: int, 1: int} */    public function enemySize(string $sprite): array    {        $tex = $this->enemyTexture($sprite, 0);        return [$tex['width'], $tex['height']];    }    /** Draws an enemy centred on ($x, $y) (its feet a little below). */    public function drawEnemy(Renderer $r, string $sprite, int $frame, float $x, float $y, bool $flip = false, float $scale = 1.0): void    {        $tex = $this->enemyTexture($sprite, $frame % 2);        $w = $tex['width'] * $scale;        $h = $tex['height'] * $scale;        $r->copy($tex['texture'], null, round($x - $w / 2), round($y - $h / 2), $w, $h, $flip);    }    private function enemyTexture(string $sprite, int $frame): array    {        $art = self::enemyArt()[$sprite] ?? self::enemyArt()['grunt'];        return $this->texture("enemy:$sprite:$frame", "enemy-$sprite-$frame", function (string $name) use ($art, $frame): void {            $rows = $art[1] === null ? $art[0][$frame] : array_merge($art[0], $art[1][$frame]);            $this->sdl?->createTextureFromPixels($name, $rows, $art[2]);        });    }    // ---- icons ------------------------------------------------------------------------------------------------    private const ICONS = [        'heart' => [['.kk.kk.', 'krrkrrk', 'krrrrrk', 'krrrrrk', '.krrrk.', '..krk..', '...k...'], ['k' => '#3a0a10', 'r' => '#e83850']],        'coin' => [['.kkkk.', 'kyyyyk', 'kyYyyk', 'kyYyyk', 'kyyyyk', '.kkkk.'], ['k' => '#6a4a10', 'y' => '#f0c840', 'Y' => '#fff0a0']],        'flag' => [['kk.....', 'krrrrk.', 'krrrrrk', 'krrrrk.', 'kk.....', 'k......', 'k......'], ['k' => '#2a2a2a', 'r' => '#d04040']],        'star' => [['....k....', '...kyk...', 'kkkkyykkk', '.kyyyyyk.', '..kyyyk..', '.kyykyyk.', '.kk...kk.'], ['k' => '#6a4a10', 'y' => '#ffd84a']],        'star_off' => [['....k....', '...kgk...', 'kkkkggkkk', '.kgggggk.', '..kgggk..', '.kggkggk.', '.kk...kk.'], ['k' => '#202028', 'g' => '#505060']],        'up' => [['...k...', '..kwk..', '.kwwwk.', 'kwwwwwk', '..kwk..', '..kwk..', '..kkk..'], ['k' => '#103010', 'w' => '#80f080']],        'sell' => [['.kkkk.', 'kyyyyk', 'ky..yk', 'ky..yk', 'kyyyyk', '.kkkk.'], ['k' => '#6a4a10', 'y' => '#f0c840']],        'target' => [['..kkk..', '.k...k.', 'k..r..k', 'k.rrr.k', 'k..r..k', '.k...k.', '..kkk..'], ['k' => '#e0e0e0', 'r' => '#ff5050']],        'pause' => [['ww.ww', 'ww.ww', 'ww.ww', 'ww.ww', 'ww.ww'], ['w' => '#ffffff']],        'play' => [['w....', 'www..', 'wwwww', 'www..', 'w....'], ['w' => '#ffffff']],        'skull' => [['.kkkk.', 'kwwwwk', 'kwkkwk', 'kwwwwk', '.kwwk.', '.kkkk.'], ['k' => '#202020', 'w' => '#e8e8e0']],        'lock' => [['.kkk.', 'k...k', 'kkkkk', 'kyyyk', 'kyyyk', 'kkkkk'], ['k' => '#202020', 'y' => '#a0a0a8']],    ];    public function drawIcon(Renderer $r, string $icon, float $x, float $y, float $scale = 1.0): void    {        $tex = $this->texture("icon:$icon", "icon-$icon", function (string $name) use ($icon): void {            [$rows, $palette] = self::ICONS[$icon];            $this->sdl?->createTextureFromPixels($name, $rows, $palette);        });        $r->copy($tex['texture'], null, round($x), round($y), $tex['width'] * $scale, $tex['height'] * $scale);    }    /** @return array{0: int, 1: int} */    public function iconSize(string $icon): array    {        return [strlen(self::ICONS[$icon][0][0]), count(self::ICONS[$icon][0])];    }    // ---- maps -------------------------------------------------------------------------------------------------    /** The map's ground (scenery, road, slots, the bastion in the side's colours), one texture of 480 x 240. */    public function drawMap(Renderer $r, string $mapId, array $map, array $colors, float $x, float $y, float $scale = 1.0): void    {        $variant = ltrim((string)($colors['main'] ?? ''), '#');        $tex = $this->texture("map:$mapId:$variant", "map-$mapId", fn (string $name) => $this->makeMap($name, $map, $colors));        $r->copy($tex['texture'], null, $x, $y, $tex['width'] * $scale, $tex['height'] * $scale);    }    /** The colours of each map theme: ground (4), road (3, edge, pebble), trees (dark, mid, light), water (2). */    public const THEMES = [        'meadow' => ['grass' => ['#4f8f3a', '#58993f', '#467f33', '#5fa346'], 'road' => ['#c0a06a', '#b4945e', '#caa974', '#8a7048', '#9a7e50'],            'tree' => ['#1f4a22', '#2f6a2c', '#4a8a3a'], 'water' => ['#3a6ea8', '#6a9ed0']],        'marsh' => ['grass' => ['#3f7a44', '#47854a', '#36683c', '#5a9450'], 'road' => ['#8a7a54', '#7e6e4a', '#968660', '#5a4e34', '#6e6040'],            'tree' => ['#1a3a2a', '#2a5a3a', '#3e7a4a'], 'water' => ['#2e5a6e', '#5a8a9a']],        'quarry' => ['grass' => ['#8a7a5a', '#94845f', '#7a6c50', '#a09068'], 'road' => ['#c8bca0', '#bcb094', '#d4c8ac', '#7a6e58', '#a09478'],            'tree' => ['#2a4a2a', '#3a5a32', '#5a7a44'], 'water' => ['#4a6a8a', '#7a9aba']],        'autumn' => ['grass' => ['#7a7a32', '#86843a', '#6a6a2a', '#9a8a3a'], 'road' => ['#b8925a', '#ac8650', '#c49e66', '#7a5a34', '#8e6e44'],            'tree' => ['#7a2a14', '#b04a1a', '#e08a2a'], 'water' => ['#3a5a8a', '#6a8aba']],        'snow' => ['grass' => ['#dce4ec', '#e8eef4', '#c8d2dc', '#f4f8fc'], 'road' => ['#a0a8b4', '#949ca8', '#acb4c0', '#6a7280', '#7e8694'],            'tree' => ['#1e3a3a', '#2e5050', '#e8f0f4'], 'water' => ['#7aa0c8', '#c8e0f4']],        'ash' => ['grass' => ['#4a4440', '#544e48', '#3e3a36', '#625a52'], 'road' => ['#8a6a50', '#7e6048', '#967458', '#4a3428', '#6a4a38'],            'tree' => ['#1a1614', '#2e2620', '#4a3a2e'], 'water' => ['#7a2a14', '#c8501e']],    ];    /** The scenery rows of a map: its own, or scattered from its `scenery` (the same each time). @return list<string> */    public static function sceneryRows(array $map, string $seed): array    {        if (isset($map['rows'])) {            return $map['rows'];        }        $rows = [];        for ($ty = 0; $ty < GameData::ROWS; $ty++) {            $row = '';            for ($tx = 0; $tx < GameData::COLS; $tx++) {                $roll = (crc32("$seed:$tx:$ty") & 0xffff) / 65536;                $char = '.';                foreach ((array)($map['scenery'] ?? []) as $kind => $share) {                    if ($roll < (float)$share) {                        $char = (string)$kind;                        break;                    }                    $roll -= (float)$share;                }                $row .= $char;            }            $rows[] = $row;        }        return $rows;    }    private function makeMap(string $name, array $map, array $colors): void    {        $t = GameData::TILE;        $w = GameData::COLS * $t;        $h = GameData::ROWS * $t;        $img = imagecreatetruecolor($w, $h);        imagealphablending($img, false);        $c = static function (string $hex) use ($img): int {            $col = Color::of($hex);            return imagecolorallocate($img, $col->r, $col->g, $col->b);        };        $theme = self::THEMES[$map['theme'] ?? 'meadow'] ?? self::THEMES['meadow'];        $grass = array_map($c, $theme['grass']);        $road = array_map($c, array_slice($theme['road'], 0, 3));        $roadEdge = $c($theme['road'][3]);        $pebble = $c($theme['road'][4]);        $paths = array_map(static fn (array $p) => Path::fromTiles($p), $map['paths']);        $nearRoad = static function (float $px, float $py) use ($paths): float {            $best = INF;            foreach ($paths as $path) {                $best = min($best, $path->distanceTo($px, $py));            }            return $best;        };        for ($py = 0; $py < $h; $py++) {            for ($px = 0; $px < $w; $px++) {                $noise = crc32("$px,$py") & 0xff;                $d = $nearRoad($px + 0.5, $py + 0.5);                if ($d <= 13) {                    $col = $noise < 12 ? $pebble : $road[$noise % 3];                } elseif ($d <= 15) {                    $col = $roadEdge;                } else {                    // grass with darker patches every few tiles                    $patch = (crc32(intdiv($px, 6) . ':' . intdiv($py, 6)) & 7) === 0 ? 2 : $noise % 2;                    $col = $noise > 248 ? $grass[3] : $grass[$patch];                }                imagesetpixel($img, $px, $py, $col);            }        }        imagealphablending($img, true);        // scenery, but never on the road nor on a slot or the castle        $blocked = [];        foreach ($map['slots'] as [$sx, $sy]) {            for ($dy = 0; $dy < 2; $dy++) {                for ($dx = 0; $dx < 2; $dx++) {                    $blocked[($sx + $dx) . ',' . ($sy + $dy)] = true;                }            }        }        [$bx, $by] = $map['bastion'];        for ($dy = 0; $dy < 6; $dy++) {            for ($dx = 0; $dx < 3; $dx++) {                $blocked[($bx + $dx) . ',' . ($by + $dy)] = true;            }        }        [$treeDark, $tree, $treeLight] = array_map($c, $theme['tree']);        $trunk = $c('#4a3018');        $rock = $c('#8a8a88');        $rockLight = $c('#b0b0aa');        $rockDark = $c('#5a5a58');        [$water, $waterLight] = array_map($c, $theme['water']);        $shadow = imagecolorallocatealpha($img, 0, 0, 0, 90);        $flowers = [$c('#f0e060'), $c('#f080a0'), $c('#ffffff'), $c('#a0a0ff')];        foreach (self::sceneryRows($map, (string)($map['name'] ?? $name)) as $ty => $row) {            for ($tx = 0, $n = strlen($row); $tx < $n; $tx++) {                $char = $row[$tx];                $cx = $tx * $t + 8;                $cy = $ty * $t + 8;                if ($char === '.' || isset($blocked["$tx,$ty"]) || $nearRoad($cx, $cy) < 18) {                    continue;                }                $seed = crc32("$tx;$ty") & 0xffff;                if ($char === 't') {                    $ox = $seed % 5 - 2;                    imagefilledellipse($img, $cx + $ox + 2, $cy + 6, 14, 6, $shadow);                    imagefilledrectangle($img, $cx + $ox - 1, $cy + 1, $cx + $ox + 1, $cy + 6, $trunk);                    imagefilledellipse($img, $cx + $ox, $cy - 2, 15, 13, $treeDark);                    imagefilledellipse($img, $cx + $ox - 1, $cy - 3, 12, 10, $tree);                    imagefilledellipse($img, $cx + $ox - 3, $cy - 5, 5, 4, $treeLight);                } elseif ($char === 'r') {                    imagefilledellipse($img, $cx + 1, $cy + 4, 12, 5, $shadow);                    imagefilledellipse($img, $cx, $cy + 1, 12, 9, $rockDark);                    imagefilledellipse($img, $cx - 1, $cy, 10, 7, $rock);                    imagefilledellipse($img, $cx - 2, $cy - 1, 4, 3, $rockLight);                } elseif ($char === 'w') {                    imagefilledrectangle($img, $tx * $t, $ty * $t, $tx * $t + $t - 1, $ty * $t + $t - 1, $water);                    imageline($img, $tx * $t + 3 + $seed % 4, $cy - 2, $tx * $t + 7 + $seed % 4, $cy - 2, $waterLight);                    imageline($img, $tx * $t + 6 + $seed % 3, $cy + 4, $tx * $t + 10 + $seed % 3, $cy + 4, $waterLight);                } elseif ($char === 'f') {                    for ($k = 0; $k < 5; $k++) {                        $fx = $tx * $t + 2 + (($seed >> $k) * 7) % 12;                        $fy = $ty * $t + 2 + (($seed >> ($k + 3)) * 5) % 12;                        imagesetpixel($img, $fx, $fy, $flowers[($seed + $k) % 4]);                    }                }            }        }        // building slots: round stone platforms        $slotDark = $c('#5a5850');        $slot = $c('#8e8a7c');        $slotLight = $c('#b4b0a0');        $rune = $c($colors['light'] ?? '#c8f0b0');        foreach ($map['slots'] as [$sx, $sy]) {            $cx = ($sx + 1) * $t;            $cy = ($sy + 1) * $t;            imagefilledellipse($img, $cx + 1, $cy + 3, 30, 20, $shadow);            imagefilledellipse($img, $cx, $cy + 1, 30, 20, $slotDark);            imagefilledellipse($img, $cx, $cy, 28, 17, $slot);            imagefilledellipse($img, $cx - 2, $cy - 2, 18, 9, $slotLight);            imageline($img, $cx - 3, $cy, $cx + 3, $cy, $rune);            imageline($img, $cx, $cy - 2, $cx, $cy + 2, $rune);        }        $this->paintBastion($img, $c, $bx * $t, $by * $t, $colors);        $this->sdl?->createTextureFromGd($name, $img);    }    /** The castle the player defends, in the side's colours. */    private function paintBastion(\GdImage $img, callable $c, int $x, int $y, array $colors): void    {        $ink = $c('#1a1424');        $wall = $c('#d8d4c4');        $wallDark = $c('#a09c8c');        $banner = $c($colors['main'] ?? '#3fa860');        $bannerDark = $c($colors['dark'] ?? '#1d5434');        $gate = $c('#3a2614');        $rect = static fn (int $rx, int $ry, int $rw, int $rh, int $col) => imagefilledrectangle($img, $rx, $ry, $rx + $rw - 1, $ry + $rh - 1, $col);        // main wall with the gate on the road (row 12)        $rect($x + 2, $y + 26, 44, 60, $ink);        $rect($x + 3, $y + 27, 42, 58, $wall);        $rect($x + 36, $y + 27, 9, 58, $wallDark);        for ($bx = $x + 3; $bx < $x + 45; $bx += 6) {            $rect($bx, $y + 22, 4, 5, $ink);            $rect($bx + 1, $y + 23, 2, 4, $wall);        }        $rect($x + 1, $y + 40, 14, 18, $ink);        $rect($x + 2, $y + 41, 12, 17, $gate);        $rect($x + 2, $y + 49, 12, 1, $ink);        // two towers        foreach ([[$x + 10, 0], [$x + 32, 6]] as [$tx, $dy]) {            $rect($tx - 7, $y + $dy + 6, 15, 30, $ink);            $rect($tx - 6, $y + $dy + 7, 13, 29, $wall);            $rect($tx + 2, $y + $dy + 7, 5, 29, $wallDark);            imagefilledpolygon($img, [$tx - 8, $y + $dy + 7, $tx + 8, $y + $dy + 7, $tx, $y + $dy - 6], $banner);            imagefilledpolygon($img, [$tx, $y + $dy - 6, $tx + 8, $y + $dy + 7, $tx, $y + $dy + 7], $bannerDark);            $rect($tx - 1, $y + $dy + 14, 3, 5, $ink);            $rect($tx, $y + $dy - 12, 1, 7, $ink);            $rect($tx + 1, $y + $dy - 12, 6, 4, $banner);        }        $rect($x + 22, $y + 62, 10, 14, $banner);        $rect($x + 22, $y + 62, 10, 2, $c('#e8c040'));    }}