PT

Elfs & Orcs Tower Defense

por malabke · Estratégia
5,0 (1)
▶ Jogar 💬 Avaliações Código-fonte 🕘 Versões Editar GitHub
games/elfs-orcs-tower-defense / src / Scene / PlayScene.php · commit 65500d9
<?phpnamespace App\Scene;use App\Save\Progress;use App\Td\Battle;use App\Td\Bot;use App\Td\Enemy;use App\Td\GameData;use App\Td\Hero;use App\Td\Tower;use App\Ui\FontProvider;use App\Ui\Paint;use App\Ui\Sfx;use App\Ui\Sprites;use App\Ui\Text;use App\Ui\World3d;use App\View3d\FlatView;use App\View3d\MapModel;use App\View3d\Mesh;use App\View3d\Particles;use App\View3d\Projection;use App\View3d\Props;use App\View3d\Rig;use App\View3d\Towers;use App\View3d\Units;use JumPHP\Renderer\Renderer;use JumPHP\Scene\AbstractScene;use JumPHP\Scene\HotReloadable;use JumPHP\Util\Lang;/** * A battle on one map. The simulation is App\Td\Battle (stepped at 60 Hz, 1 to 3 steps per frame); this scene * reads the player's input (mouse / touch, keys / pad), draws, and plays the sounds of what happened. * * Building: a click (or confirm on the keyboard cursor) on a stone platform opens a ring of 4 choices around it * (up / right / down / left: the keys pick them too); the first click on a choice shows what it does, the second * one does it. * * Drawing: the map and everything on it are 3D (World3d, a WebGL layer under the 2D); the HUD, the ring menu, the * bars and the numbers are 2D over it, placed through $cam (the camera's projection). Without WebGL (or with the * param view: flat) the scene draws the flat 2D version of the map instead, through the same $cam interface. * * Params: map, faction; test / demo ones: demo (the bot plays), speed (1..3), gold, lives, countdown (seconds * before the first wave), wave (waves already done), seed, view ('flat': no 3D). */final class PlayScene extends AbstractScene implements HotReloadable{    public const MAP_Y = 30;    private const RING = 28;    private const RING_HIT = 13;    private const DIRS = ['up' => [0, -1], 'right' => [1, 0], 'down' => [0, 1], 'left' => [-1, 0]];    /** the sound of each projectile when it is thrown (Sfx) */    private const SHOT_SOUNDS = ['arrow' => 'arrow', 'bolt' => 'arrow', 'javelin' => 'arrow', 'stone' => 'arrow', 'orb' => 'orb',        'pitch' => 'orb', 'spark' => 'orb', 'root' => 'root', 'rock' => 'root', 'note' => 'note', 'star' => 'star'];    private Battle $battle;    private ?Bot $bot = null;    private GameData $data;    private array $params = [];    private array $map = [];    private array $colors = [];    private int $speed = 1;    private float $accumulator = 0.0;    private float $time = 0.0;    /** the slot whose ring is open */    private ?int $menuSlot = null;    /** the chosen option of the ring (shown, done on a second press) */    private ?string $choice = null;    /** the keyboard / pad cursor */    private int $cursor = 0;    private bool $keyboard = false;    /** @var list<array{text: string, x: float, y: float, t: float, color: array}> rising texts, screen coordinates */    private array $floaters = [];    /** @var list<array{x: float, y: float, kind: string, t: float, splash: float}> */    private array $flashes = [];    /** sparks, dust, embers, coins of the 3D battle */    private Particles $particles;    /** @var list<array{0: string, 1: string, 2: float}> banners waiting: title, line, seconds */    private array $banners = [];    private float $bannerTime = 0.0;    /** @var array<string, bool> enemies already announced */    private array $seen = [];    private float $endTimer = -1.0;    private float $shake = 0.0;    /** choosing where to send the hero ('hero') or to cast the side's power ('power'); null: building */    private ?string $aim = null;    /** where the keyboard / pad points while aiming (play area pixels) */    private float $reticleX = 0.0;    private float $reticleY = 0.0;    /** the 3D layer is on (else the flat 2D drawing) */    private bool $use3d = false;    private Projection $cam;    private ?Rig $rig = null;    /** creatures are drawn this much bigger than their models (readable from the camera's height) */    private const UNIT_SCALE = 2.0;    /** towers are drawn this much bigger than their models (a platform is 30 wide) */    private const TOWER_SCALE = 1.3;    public function __construct(private FontProvider $fonts, private Sprites $sprites, private Progress $progress, private World3d $world)    {        $this->cam = new FlatView(self::MAP_Y);        $this->particles = new Particles();    }    /** Where a point of the map is on the screen (the 3D camera, or the flat view): for tests and overlays. @return array{0: float, 1: float} */    public function screenOf(float $x, float $y, float $height = 0.0): array    {        return $this->cam->screen($x, $y, $height);    }    public function exit(): void    {        if ($this->use3d) {            $this->world->shutdown($this->engine->gl());            $this->use3d = false;        }    }    /** The 3D layer, the camera fitted on the platforms, the ground built. Flat when WebGL is not there. */    private function setUpView(string $mapId, bool $wanted): void    {        $gl = $this->engine->gl();        $this->use3d = $wanted && $this->world->boot($gl);        if (!$this->use3d) {            $this->world->shutdown($gl);            $this->cam = new FlatView(self::MAP_Y);            $this->rig = null;            return;        }        $this->world->loadMap($mapId, $this->map, $this->colors);        $minX = $minZ = INF;        $maxX = $maxZ = -INF;        $grow = static function (float $x0, float $z0, float $x1, float $z1) use (&$minX, &$minZ, &$maxX, &$maxZ): void {            $minX = min($minX, $x0);            $minZ = min($minZ, $z0);            $maxX = max($maxX, $x1);            $maxZ = max($maxZ, $z1);        };        foreach ($this->map['slots'] as [$sx, $sy]) {            $grow(($sx + 1) * 16.0 - 18, ($sy + 1) * 16.0 - 18, ($sx + 1) * 16.0 + 18, ($sy + 1) * 16.0 + 18);        }        [$bx, $by] = $this->map['bastion'];        $grow($bx * 16.0, $by * 16.0, $bx * 16.0 + 48, $by * 16.0 + 96);        foreach ($this->map['paths'] as $points) {            foreach ($points as [$px, $pz]) {                $grow(max(0.0, min(480.0, $px * 16.0)), max(0.0, min(240.0, $pz * 16.0)), max(0.0, min(480.0, $px * 16.0)), max(0.0, min(240.0, $pz * 16.0)));            }        }        $this->rig = new Rig((float)$this->width(), (float)$this->height());        $this->rig->fit($minX, $minZ, $maxX, $maxZ, 60.0, self::MAP_Y + 6.0, $this->height() - 4.0, 52.0);        $this->cam = $this->rig;    }    public function enter(array $params = []): void    {        $this->params = $params;        $this->fonts->apply($this->engine);        $this->sprites->attach($this->sdl(), $this->engine->config->path());        $this->progress->load($this->engine->storage);        Sfx::load($this->sound(), $this->engine->config->path());        $mapId = (string)($params['map'] ?? 'thornvale');        $faction = (string)($params['faction'] ?? 'elves');        $this->data = GameData::load($this->engine->config->path());        $this->map = $this->data->map($mapId);        $this->colors = $this->data->faction($faction)['colors'];        $this->battle = new Battle($this->data, $mapId, $faction, (int)($params['seed'] ?? 7), !empty($params['hard']));        if (isset($params['gold'])) {            $this->battle->gold = (int)$params['gold'];        }        if (isset($params['lives'])) {            $this->battle->lives = (int)$params['lives'];        }        if (isset($params['wave'])) {            $this->battle->skipTo((int)$params['wave']);        }        if (isset($params['countdown'])) {            $this->battle->countdown = (float)$params['countdown'];        }        $this->setUpView($mapId, ($params['view'] ?? '3d') !== 'flat');        $demo = !empty($params['demo']);        $this->bot = $demo ? new Bot($this->battle, true, true) : null;        $this->aim = null;        $this->reticleX = GameData::COLS * GameData::TILE / 2;        $this->reticleY = GameData::ROWS * GameData::TILE / 2;        $this->speed = max(1, min(3, (int)($params['speed'] ?? ($demo ? 3 : 1))));        $this->accumulator = 0.0;        $this->time = 0.0;        $this->menuSlot = null;        $this->choice = null;        $this->cursor = 0;        $this->keyboard = false;        $this->floaters = [];        $this->flashes = [];        $this->particles = new Particles((int)($params['seed'] ?? 7));        $this->seen = [];        $this->endTimer = -1.0;        $this->shake = 0.0;        $this->banners = [[Lang::t($this->map['name']), Lang::t('PREPARE YOUR DEFENSES'), 3.5]];        if (!$demo) {            $this->banners[] = [Lang::t('BUILD ON THE STONE PLATFORMS'), '', 3.0];        }        $this->bannerTime = 0.0;    }    public function hotState(): array    {        return ['battle' => $this->battle->snapshot(), 'speed' => $this->speed];    }    public function hotRestore(array $state): void    {        $this->battle->restore($state['battle'] ?? []);        $this->speed = (int)($state['speed'] ?? 1);    }    /** The PlayScene's parameters without the test ones: to play the same map again. */    private function restartParams(): array    {        return array_intersect_key($this->params, ['map' => 1, 'faction' => 1, 'demo' => 1, 'hard' => 1]);    }    // ---- update -----------------------------------------------------------------------------------------------    public function update(float $dt): void    {        $this->time += $dt;        $this->updateEffects($dt);        $this->rig?->update($this->shake > 0 ? $this->shake * 5.0 : 0.0, $this->time);        if ($this->endTimer >= 0) {            $this->endTimer += $dt;            if ($this->endTimer > 1.5) {                $this->endTimer = -2.0;                $this->finish();            }            return;        }        if ($this->endTimer < -1.5) {            return;   // the end overlay is shown        }        if (!$this->handleInput($dt)) {            return;        }        $this->bot?->play();        $this->accumulator += $dt * $this->speed;        for ($steps = 0; $this->accumulator >= Battle::STEP - 1e-9 && $steps < 12; $steps++) {            $this->battle->step();            $this->accumulator -= Battle::STEP;            if ($this->bot !== null && $steps % 6 === 5) {                $this->bot->play();            }        }        $this->accumulator = min($this->accumulator, Battle::STEP);        $this->handleEvents();    }    /** False when the scene changed (pause). */    private function handleInput(float $dt): bool    {        $actions = $this->actions();        $mouse = $this->engine->input->getMouse();        if ($this->menuSlot !== null && ($actions->isPressed('back') || $mouse->isPressed(3))) {            $this->closeMenu();            return true;        }        if ($this->aim !== null) {            $this->handleAim($dt);            return true;        }        if ($actions->isPressed('pause')) {            $this->scenes()->push(PauseScene::class, ['restart' => $this->restartParams()]);            return false;        }        if ($mouse->isPressed()) {            $this->keyboard = false;            return $this->click($mouse->x, $mouse->y);        }        if ($actions->isPressed('speed')) {            $this->cycleSpeed();        }        if ($actions->isPressed('call_wave')) {            $this->callWave();        }        if ($actions->isPressed('hero')) {            $this->keyboard = true;            $this->startAim('hero');            return true;        }        if ($actions->isPressed('power')) {            $this->keyboard = true;            $this->startAim('power');            return true;        }        foreach (self::DIRS as $dir => $vector) {            if ($actions->isPressed($dir)) {                $this->keyboard = true;                if ($this->menuSlot !== null) {                    $this->chooseByDirection($dir);                } else {                    $this->moveCursor($vector);                }            }        }        if ($actions->isPressed('confirm')) {            $this->keyboard = true;            if ($this->menuSlot === null) {                $this->openMenu($this->cursor);            } elseif ($this->choice !== null) {                $this->activate($this->choice);            }        }        return true;    }    // ---- the hero and the side's power ------------------------------------------------------------------------    /** @return list<array{id: string, x: float, y: float, r: float}> the round buttons at the bottom left of the map */    private function mapButtons(): array    {        $buttons = [];        $y = $this->height() - 17;        if ($this->battle->hero !== null) {            $buttons[] = ['id' => 'hero', 'x' => 17.0, 'y' => (float)$y, 'r' => 14.0];        }        $buttons[] = ['id' => 'power', 'x' => count($buttons) * 32 + 17.0, 'y' => (float)$y, 'r' => 14.0];        return $buttons;    }    private function startAim(string $what): void    {        $this->closeMenu();        if ($what === 'hero' && ($this->battle->hero === null || !$this->battle->hero->isAlive())) {            $this->sound()->playSfx('deny');            return;        }        if ($what === 'power' && !$this->battle->powerReady()) {            $this->sound()->playSfx('deny');            return;        }        $this->aim = $what;        if ($this->keyboard) {            // from the hero, or from the enemy nearest to the bastion            $from = $what === 'hero' ? $this->battle->hero : null;            if ($from === null) {                foreach ($this->battle->enemies as $enemy) {                    if ($enemy->isTargetable() && ($from === null || $enemy->remaining() < $from->remaining())) {                        $from = $enemy;                    }                }            }            if ($from !== null) {                $this->reticleX = $from->x;                $this->reticleY = $from->y;            }        }        $this->sound()->playSfx('menu_move');    }    /** Aiming: the mouse (or the arrows) points, a click (or confirm) sends the hero / casts the power. */    private function handleAim(float $dt): void    {        $actions = $this->actions();        $mouse = $this->engine->input->getMouse();        if ($actions->isPressed('back') || $mouse->isPressed(3)) {            $this->aim = null;            return;        }        $dx = ($actions->isHeld('right') ? 1 : 0) - ($actions->isHeld('left') ? 1 : 0);        $dy = ($actions->isHeld('down') ? 1 : 0) - ($actions->isHeld('up') ? 1 : 0);        if ($dx !== 0 || $dy !== 0) {            $this->keyboard = true;            $this->reticleX = max(0.0, min(GameData::COLS * GameData::TILE, $this->reticleX + $dx * 150 * $dt));            $this->reticleY = max(0.0, min(GameData::ROWS * GameData::TILE, $this->reticleY + $dy * 150 * $dt));        } elseif (!$this->keyboard && $mouse->y > self::MAP_Y) {            [$this->reticleX, $this->reticleY] = $this->cam->world($mouse->x, $mouse->y);        }        if ($mouse->isPressed()) {            $this->keyboard = false;            foreach ($this->mapButtons() as $b) {                if (($mouse->x - $b['x']) ** 2 + ($mouse->y - $b['y']) ** 2 <= $b['r'] ** 2) {                    // the same button again: cancel; the other one: aim with it                    $this->aim = $b['id'] === $this->aim ? null : $this->aim;                    if ($this->aim !== null) {                        $this->startAim($b['id']);                    }                    return;                }            }            if ($mouse->y > self::MAP_Y) {                [$wx, $wy] = $this->cam->world($mouse->x, $mouse->y);                $this->applyAim($wx, $wy);            } else {                $this->aim = null;            }            return;        }        if ($actions->isPressed('confirm')) {            $this->applyAim($this->reticleX, $this->reticleY);        }    }    private function applyAim(float $x, float $y): void    {        $done = $this->aim === 'hero' ? $this->battle->moveHero($x, $y) : $this->battle->castPower($x, $y);        $this->sound()->playSfx($done ? 'menu_confirm' : 'deny');        $this->aim = null;    }    private function click(float $x, float $y): bool    {        foreach ($this->mapButtons() as $b) {            if (($x - $b['x']) ** 2 + ($y - $b['y']) ** 2 <= $b['r'] ** 2) {                $this->startAim($b['id']);                return true;            }        }        foreach ($this->hudButtons() as $button) {            if ($x >= $button['x'] && $x < $button['x'] + $button['w'] && $y >= $button['y'] && $y < $button['y'] + $button['h']) {                if ($button['id'] === 'pause') {                    $this->scenes()->push(PauseScene::class, ['restart' => $this->restartParams()]);                    return false;                }                if ($button['id'] === 'speed') {                    $this->cycleSpeed();                } else {                    $this->callWave();                }                return true;            }        }        if ($this->menuSlot !== null) {            foreach ($this->ringOptions($this->menuSlot) as $option) {                if (($x - $option['x']) ** 2 + ($y - $option['y']) ** 2 <= self::RING_HIT ** 2) {                    if ($option['id'] === $this->choice) {                        $this->activate($option['id']);                    } else {                        $this->choose($option['id']);                    }                    return true;                }            }        }        foreach ($this->battle->slots as $i => $slot) {            // the platform, or the tower standing on it            [$px, $py] = $this->cam->screen($slot['x'], $slot['y']);            [, $ty] = $this->cam->screen($slot['x'], $slot['y'], 22.0);            $rad = max(14.0, 17.0 * $this->cam->scale($slot['x'], $slot['y']));            if (($x - $px) ** 2 + ($y - $py) ** 2 <= $rad ** 2 || ($x - $px) ** 2 + ($y - $ty) ** 2 <= $rad ** 2) {                $this->cursor = $i;                if ($this->menuSlot === $i) {                    $this->closeMenu();                } else {                    $this->openMenu($i);                }                return true;            }        }        $this->closeMenu();        return true;    }    private function cycleSpeed(): void    {        $this->speed = $this->speed % 3 + 1;        $this->sound()->playSfx('menu_move');    }    private function callWave(): void    {        if (!$this->battle->canCallWave()) {            $this->sound()->playSfx('deny');            return;        }        $bonus = $this->battle->callNextWave();        if ($bonus > 0) {            $this->floaters[] = ['text' => '+' . $bonus, 'x' => 80, 'y' => 22, 't' => 0.0, 'color' => [255, 220, 90]];            $this->sound()->playSfx('coin');        }    }    private function openMenu(int $slot): void    {        $this->menuSlot = $slot;        $this->choice = null;        $this->sound()->playSfx('menu_move');    }    private function closeMenu(): void    {        $this->menuSlot = null;        $this->choice = null;    }    private function choose(string $id): void    {        $this->choice = $id;        $this->sound()->playSfx('menu_move');    }    private function chooseByDirection(string $dir): void    {        foreach ($this->ringOptions((int)$this->menuSlot) as $option) {            if ($option['dir'] === $dir) {                $this->choose($option['id']);                return;            }        }    }    /** Jumps to the nearest slot in a direction. */    private function moveCursor(array $vector): void    {        $from = $this->battle->slots[$this->cursor];        $best = null;        $bestScore = INF;        foreach ($this->battle->slots as $i => $slot) {            $dx = $slot['x'] - $from['x'];            $dy = $slot['y'] - $from['y'];            $along = $dx * $vector[0] + $dy * $vector[1];            if ($i === $this->cursor || $along <= 0) {                continue;            }            $score = $along + 2 * abs($dx * $vector[1] - $dy * $vector[0]);            if ($score < $bestScore) {                $bestScore = $score;                $best = $i;            }        }        if ($best !== null) {            $this->cursor = $best;            $this->sound()->playSfx('menu_move');        }    }    private function activate(string $id): void    {        $slot = (int)$this->menuSlot;        $option = null;        foreach ($this->ringOptions($slot) as $candidate) {            if ($candidate['id'] === $id) {                $option = $candidate;            }        }        if ($option === null || !$option['enabled']) {            $this->sound()->playSfx('deny');            return;        }        [$what, $arg] = array_pad(explode(':', $id, 2), 2, '');        $done = match ($what) {            'build' => $this->battle->build($slot, $arg),            'upgrade' => $this->battle->upgrade($slot),            'spec' => $this->battle->specialize($slot, $arg),            'sell' => $this->battle->sell($slot) > 0,            'target' => (bool)$this->battle->tower($slot)?->cycleTargeting(),            default => false,        };        if (!$done) {            $this->sound()->playSfx('deny');            return;        }        if ($what === 'target') {            $this->sound()->playSfx('menu_move');   // the ring stays open: press again for the next mode            return;        }        $this->closeMenu();    }    /**     * The choices around a slot.     * @return list<array{id: string, dir: string, x: float, y: float, icon: string, tower: ?string, cost: ?int, enabled: bool}>     */    private function ringOptions(int $slot): array    {        $tower = $this->battle->tower($slot);        $options = [];        if ($tower === null) {            foreach (array_values($this->battle->towerTypes()) as $i => $type) {                $cost = $this->data->upgradeCost($type, 1);                $options[] = ['id' => "build:$type", 'dir' => array_keys(self::DIRS)[$i % 4], 'icon' => '', 'tower' => $type,                    'cost' => $cost, 'enabled' => $this->battle->gold >= $cost];            }        } else {            if ($tower->canSpecialize()) {                foreach ($tower->specNames() as $i => $spec) {                    $cost = (int)$tower->nextCost($spec);                    $options[] = ['id' => "spec:$spec", 'dir' => $i === 0 ? 'up' : 'left', 'icon' => 'star', 'tower' => null,                        'cost' => $cost, 'enabled' => $this->battle->gold >= $cost];                }            } elseif ($tower->nextCost() !== null) {                $cost = (int)$tower->nextCost();                $options[] = ['id' => 'upgrade', 'dir' => 'up', 'icon' => 'up', 'tower' => null, 'cost' => $cost, 'enabled' => $this->battle->gold >= $cost];            }            if ($tower->hasTargeting()) {                $options[] = ['id' => 'target', 'dir' => 'right', 'icon' => 'target', 'tower' => null, 'cost' => null, 'enabled' => true];            }            $options[] = ['id' => 'sell', 'dir' => 'down', 'icon' => 'sell', 'tower' => null, 'cost' => $tower->sellValue(), 'enabled' => true];        }        [$cx, $cy] = $this->cam->screen($this->battle->slots[$slot]['x'], $this->battle->slots[$slot]['y']);        foreach ($options as &$option) {            [$dx, $dy] = self::DIRS[$option['dir']];            $option['x'] = max(14, min($this->width() - 14, $cx + $dx * self::RING));            $option['y'] = max(self::MAP_Y + 14, min($this->height() - 20, $cy + $dy * self::RING));        }        unset($option);        return $options;    }    /** @return list<array{id: string, x: float, y: float, w: float, h: float}> the top bar's buttons */    private function hudButtons(): array    {        $w = $this->width();        $font = $this->font();        $label = $this->callLabel();        $callW = $font->width($label) + 26;        return [            ['id' => 'pause', 'x' => $w - 28, 'y' => 2, 'w' => 26, 'h' => 26],            ['id' => 'speed', 'x' => $w - 62, 'y' => 2, 'w' => 32, 'h' => 26],            ['id' => 'call', 'x' => $w - 64 - $callW, 'y' => 2, 'w' => $callW, 'h' => 26],        ];    }    private function callLabel(): string    {        $bonus = $this->battle->earlyBonus();        return $this->battle->canCallWave() && $bonus > 0 ? Lang::t('CALL WAVE') . ' +' . $bonus : Lang::t('CALL WAVE');    }    // ---- what happened ----------------------------------------------------------------------------------------    private function handleEvents(): void    {        $played = [];        $play = function (string $sfx) use (&$played): void {            if (!isset($played[$sfx])) {                $played[$sfx] = true;                $this->sound()->playSfx($sfx);            }        };        foreach ($this->battle->events as $event) {            if ($this->use3d) {                $this->particlesOf($event);            }            switch ($event['type']) {                case 'shot':                    $play(self::SHOT_SOUNDS[(string)$event['projectile']] ?? 'arrow');                    break;                case 'hit':                    $this->flashes[] = ['x' => $event['x'], 'y' => $event['y'], 'kind' => $event['pulse'] ? 'pulse' : $event['kind'], 't' => 0.0,                        'splash' => $event['splash']];                    if ($event['splash'] > 0) {                        $play('boom');                    }                    if ($event['crit']) {                        $this->floatAt('!', $event['x'], $event['y'], 18.0, [255, 120, 80]);                    }                    break;                case 'kill':                    $play('kill');                    if ($event['gold'] > 0) {                        $this->floatAt('+' . $event['gold'], $event['x'], $event['y'], 16.0, [255, 220, 90]);                    }                    break;                case 'leak':                    $play('leak');                    $this->shake = 0.3;                    break;                case 'wave':                    $play('wave');                    $this->announceWave((int)$event['wave'], (bool)$event['boss']);                    break;                case 'wave_cleared':                    if ($event['gold'] > 0) {                        $this->floaters[] = ['text' => '+' . $event['gold'], 'x' => 80, 'y' => 22, 't' => 0.0, 'color' => [255, 220, 90]];                        $play('coin');                    }                    break;                case 'dodge':                    $this->floatAt(Lang::t('MISS'), $event['x'], $event['y'], 18.0, [200, 220, 255]);                    break;                case 'income':                    $this->floatAt('+' . $event['gold'], $event['x'], $event['y'], 40.0, [255, 220, 90]);                    $play('coin');                    break;                case 'heal':                    $this->flashes[] = ['x' => $event['x'], 'y' => $event['y'], 'kind' => 'heal', 't' => 0.0, 'splash' => $event['radius']];                    break;                case 'trap':                    $play('root');                    break;                case 'wall_down':                    $play('boom');                    break;                case 'soldier_down':                case 'hero_down':                    $play('kill');                    break;                case 'hero_skill':                    $play('boom');                    $this->shake = max($this->shake, 0.15);                    break;                case 'power':                    $play('star');                    $this->flashes[] = ['x' => $event['x'], 'y' => $event['y'], 'kind' => 'power', 't' => 0.0, 'splash' => $event['radius']];                    $this->shake = max($this->shake, 0.2);                    break;                case 'build':                    $play('build');                    break;                case 'upgrade':                    $play('upgrade');                    break;                case 'sell':                    $play('sell');                    break;                case 'won':                case 'lost':                    $this->endTimer = 0.0;                    $this->closeMenu();                    break;            }        }        $this->battle->events = [];    }    /** The particles of what happened (3D only). */    private function particlesOf(array $event): void    {        $p = $this->particles;        $slot = fn (): array => $this->battle->slots[(int)$event['slot']] ?? ['x' => 0.0, 'y' => 0.0];        switch ($event['type']) {            case 'hit':                $p->hit($event['pulse'] ? 'pulse' : (string)$event['kind'], (float)$event['x'], (float)$event['y'], (float)$event['splash']);                break;            case 'kill':                $p->kill((float)$event['x'], (float)$event['y'], (int)$event['gold']);                break;            case 'soldier_down':            case 'hero_down':                $p->kill((float)$event['x'], (float)$event['y'], 0);                break;            case 'build':            case 'upgrade':                $s = $slot();                $p->build((float)$s['x'], MapModel::PLATFORM, (float)$s['y'], $event['type'] === 'upgrade');                break;            case 'sell':                $s = $slot();                $p->sell((float)$s['x'], MapModel::PLATFORM, (float)$s['y']);                break;            case 'heal':                $p->heal((float)$event['x'], (float)$event['y'], (float)$event['radius']);                break;            case 'power':                $p->power((float)$event['x'], (float)$event['y'], (float)$event['radius']);                break;            case 'hero_skill':                $p->hit('star', (float)$event['x'], (float)$event['y'], (float)$event['radius']);                break;            case 'wall_down':                $p->hit('rock', (float)$event['x'], (float)$event['y'], 14.0);                break;            case 'trap':                $p->hit('root', (float)$event['x'], (float)$event['y'], 0.0);                break;            case 'leak':                [$bx, $by] = $this->map['bastion'];                $p->leak($bx * 16.0 + 24.0, $by * 16.0 + 48.0);                break;        }    }    /** Embers over what burns, sparks behind the glowing shots: a few each frame, as the battle goes. */    private function ambientParticles(float $dt): void    {        $p = $this->particles;        $rate = $dt * $this->speed;        foreach ($this->battle->enemies as $e) {            if ($e->isBurning() && !$e->isUnderground() && $p->rand() < $rate * 9.0) {                $p->ember($e->x, ($e->isFlying() ? 18.0 : 8.0), $e->y);            }        }        foreach ($this->battle->projectiles as $shot) {            $kind = $shot->kind();            if (in_array($kind, ['orb', 'note', 'star', 'spark', 'pitch'], true) && $p->rand() < $rate * 30.0) {                $p->trail($kind, $shot->x, 18.0, $shot->y);            }        }    }    /** A number or a word that rises from a point of the map (above the ground by $height). */    private function floatAt(string $text, float $x, float $y, float $height, array $color): void    {        [$sx, $sy] = $this->cam->screen($x, $y, $height);        $this->floaters[] = ['text' => $text, 'x' => $sx, 'y' => $sy, 't' => 0.0, 'color' => $color];    }    private function announceWave(int $wave, bool $boss): void    {        $title = $wave === $this->battle->waveCount() ? Lang::t('FINAL WAVE') : Lang::t('WAVE {n}', ['n' => $wave]);        $this->banners[] = [$title, $boss ? Lang::t('A BOSS IS COMING!') : '', 2.0];        // enemies of this wave never seen before get a word (not the first ones)        $groups = $this->data->wavesFor($this->battle->mapId, $this->battle->enemySide)[$wave - 1]['groups'] ?? [];        foreach ($groups as $group) {            $type = (string)$group['enemy'];            if (isset($this->seen[$type])) {                continue;            }            $this->seen[$type] = true;            if (count($this->seen) > 1) {                $def = $this->data->enemy($type);                $this->banners[] = [Lang::t('NEW ENEMY') . ': ' . Lang::t($def['name']), $this->traits($def), 2.5];            }        }    }    /** What sets an enemy apart, in words. */    private function traits(array $def): string    {        $words = [];        if (!empty($def['boss'])) {            $words[] = Lang::t('BOSS');        }        if (!empty($def['flying'])) {            $words[] = Lang::t('FLYING');        }        if (($def['armor'] ?? '') === 'armored') {            $words[] = Lang::t('ARMORED');        }        if (($def['armor'] ?? '') === 'magic_resist') {            $words[] = Lang::t('RESISTS MAGIC');        }        if ((float)$def['speed'] >= 40) {            $words[] = Lang::t('FAST');        }        if (!empty($def['dodge'])) {            $words[] = Lang::t('DODGES');        }        if (!empty($def['stealth'])) {            $words[] = Lang::t('HIDDEN');        }        if (!empty($def['tunnel'])) {            $words[] = Lang::t('DIGS UNDER THE ROAD');        }        if (!empty($def['heal'])) {            $words[] = Lang::t('HEALS');        }        return implode(' - ', $words);    }    private function finish(): void    {        $this->banners = [];        $restart = $this->restartParams();        if ($this->battle->state === 'won') {            $this->progress->record($this->battle->faction, $this->battle->mapId, $this->battle->stars(), $this->battle->hard);            $this->scenes()->push(VictoryScene::class, ['stars' => $this->battle->stars(), 'lives' => $this->battle->lives, 'restart' => $restart]);        } else {            $this->scenes()->push(DefeatScene::class, ['wave' => $this->battle->waveIndex, 'restart' => $restart]);        }    }    private function updateEffects(float $dt): void    {        foreach ($this->floaters as $k => &$floater) {            $floater['t'] += $dt;            $floater['y'] -= 14 * $dt;            if ($floater['t'] > 0.9) {                unset($this->floaters[$k]);            }        }        unset($floater);        foreach ($this->flashes as $k => &$flash) {            $flash['t'] += $dt;            if ($flash['t'] > 0.25) {                unset($this->flashes[$k]);            }        }        unset($flash);        if ($this->use3d) {            // the particles follow the battle's speed            $this->particles->update($dt * $this->speed);            if ($this->endTimer < 0) {                $this->ambientParticles($dt);            }        }        $this->shake = max(0.0, $this->shake - $dt);        if ($this->banners !== []) {            $this->bannerTime += $dt;            if ($this->bannerTime > $this->banners[0][2]) {                array_shift($this->banners);                $this->bannerTime = 0.0;            }        }    }    // ---- render -----------------------------------------------------------------------------------------------    public function render(Renderer $r): void    {        if ($this->use3d) {            $this->render3d($r);            return;        }        $r->clear([20, 30, 20]);        $sy = self::MAP_Y + ($this->shake > 0 ? (int)round(sin($this->time * 80) * 2) : 0);        $this->sprites->drawMap($r, $this->battle->mapId, $this->map, $this->colors, 0, $sy);        // walls and traps lie on the road, under everything        foreach ($this->battle->towers() as $tower) {            if ($tower->road !== null && $tower->kind() !== 'barracks') {                $this->drawRoadObject($r, $tower, $sy);            }        }        // towers, soldiers and enemies, the lower ones in front        $things = [];        foreach ($this->battle->towers() as $tower) {            $things[] = [$tower->y + 8, 'tower', $tower];            foreach ($tower->soldiers as $soldier) {                if ($soldier->isAlive()) {                    $things[] = [$soldier->y, 'soldier', [$soldier, (string)$tower->stats['soldiers']['sprite']]];                }            }        }        foreach ($this->battle->enemies as $enemy) {            $things[] = [$enemy->y + ($enemy->isFlying() ? 40 : 0), 'enemy', $enemy];        }        if ($this->battle->hero?->isAlive()) {            $things[] = [$this->battle->hero->y, 'hero', $this->battle->hero];        }        usort($things, static fn (array $a, array $b) => $a[0] <=> $b[0]);        foreach ($things as [, $kind, $thing]) {            if ($kind === 'tower') {                $this->drawTower($r, $thing, $sy);            } elseif ($kind === 'hero') {                $this->drawHero($r, $thing, $sy);            } elseif ($kind === 'soldier') {                $this->drawSoldier($r, $thing[0], $thing[1], $sy);            } else {                $this->drawEnemy($r, $thing, $sy);            }        }        $this->drawProjectiles($r, $sy);        foreach ($this->floaters as $floater) {            $this->font()->draw($r, $floater['text'], $floater['x'], $floater['y'], 1, $floater['color'], 'center', [0, 0, 0]);        }        if ($this->keyboard && $this->menuSlot === null && $this->aim === null && isset($this->battle->slots[$this->cursor])) {            $slot = $this->battle->slots[$this->cursor];            $pulse = fmod($this->time, 0.8) < 0.4 ? 1 : 0;            $r->drawRect($slot['x'] - 16 - $pulse, $slot['y'] + $sy - 14 - $pulse, 32 + 2 * $pulse, 30 + 2 * $pulse, [255, 230, 100], 1);        }        // the banner under the ring: it never hides what the player is choosing        $this->drawBanner($r);        if ($this->menuSlot !== null) {            $this->drawRing($r, $this->menuSlot);        }        if ($this->aim !== null) {            $this->drawAim($r, $sy);        }        $this->drawMapButtons($r);        $this->drawHud($r);    }    // ---- the 3D version ---------------------------------------------------------------------------------------    /** The 3D layer (ground, towers, creatures, shots, glows), then the 2D over it: bars, banner, ring menu, HUD. */    private function render3d(Renderer $r): void    {        $r->clear($this->world->clearColour(), true);        if ($this->rig === null) {            return;        }        $w = $this->world;        $b = $this->battle;        $w->begin($this->rig, $this->time);        // soft shadows first, towards the east (the sun is low in the west): everything else stands over them        [$sx, $sz, $k] = MapModel::shadowStep();        $yaw = -atan2($sz, $sx);        $unitShadow = function (float $x, float $y, float $r, float $height, float $alpha) use ($w, $sx, $sz, $k, $yaw): void {            $len = $height * $k * 0.35;            $w->shadow($x + $sx * $len, $y + $sz * $len, $r + $len * 0.6, $r * 0.8, $yaw, $alpha);        };        foreach ($b->towers() as $t) {            $len = Towers::height(min(3, $t->level)) * self::TOWER_SCALE * $k * 0.8;            $w->shadow($t->x + $sx * $len * 0.5, $t->y + $sz * $len * 0.5, $len * 0.5 + 12.0, 12.5, $yaw, 0.4);            foreach ($t->soldiers as $s) {                if ($s->isAlive()) {                    $unitShadow($s->x, $s->y, 5.0, 24.0, 0.42);                }            }        }        foreach ($b->enemies as $e) {            if (!$e->isUnderground()) {                $size = (float)($e->def['scale'] ?? 1.0) * self::UNIT_SCALE;                $unitShadow($e->x, $e->y, ($e->isFlying() ? 3.0 : 4.6) * $size * 0.9, $e->isFlying() ? 30.0 : 12.0 * $size, $e->isFlying() ? 0.22 : 0.42);            }        }        if ($b->hero?->isAlive()) {            $unitShadow($b->hero->x, $b->hero->y, 5.6, 28.0, 0.42);            $w->disc($b->hero->x, $b->hero->y, 6.4, Mesh::rgb((string)($this->colors['main'] ?? '#ffffff'), 0.9), 0.55, 0.7, 1.0);        }        foreach ($b->towers() as $t) {            if ($t->road !== null && $t->kind() !== 'barracks') {                $this->roadObject3d($t);            }            $this->tower3d($t);            foreach ($t->soldiers as $s) {                if ($s->isAlive()) {                    $sprite = (string)$t->stats['soldiers']['sprite'];                    $yaw = $this->headingOf($s->x, $s->y, $s->target, $s->facingLeft);                    [$lx, $lz] = $this->lunge($yaw, $s->isFighting(), $s->age);                    $walking = $s->target !== null || hypot($s->x - $s->homeX, $s->y - $s->homeY) > 0.8;                    [$anim, $at] = $this->gait($sprite, 1.0, $s->isFighting(), $walking, $s->age, 60.0, $s->homeX * 0.37);                    $this->creature3d($sprite, 1.0, $s->x + $lx, $s->y + $lz, $yaw, $anim, $at);                }            }        }        if ($b->hero?->isAlive()) {            $h = $b->hero;            $sprite = (string)$h->def['sprite'];            $scale = (float)($h->def['scale'] ?? 1.0);            $yaw = $this->headingOf($h->x, $h->y, $h->target, $h->facingLeft);            [$lx, $lz] = $this->lunge($yaw, $h->isFighting(), $h->age);            [$anim, $at] = $this->gait($sprite, $scale, $h->isFighting(), $h->isWalking(), $h->age, (float)$h->def['speed'], 0.0);            $this->creature3d($sprite, $scale, $h->x + $lx, $h->y + $lz, $yaw, $anim, $at);        }        foreach ($b->enemies as $e) {            $this->enemy3d($e);        }        foreach ($b->projectiles as $p) {            $this->projectile3d($p);        }        $this->effects3d();        // the 2D over the 3D        $this->bars3d($r);        foreach ($this->floaters as $floater) {            $this->font()->draw($r, $floater['text'], $floater['x'], $floater['y'], 1, $floater['color'], 'center', [0, 0, 0]);        }        $this->drawBanner($r);        if ($this->menuSlot !== null) {            $this->drawRing($r, $this->menuSlot);        }        if ($this->aim !== null) {            $this->drawAim($r, 0);        }        $this->drawMapButtons($r);        $this->drawHud($r);    }    /** The yaw (about y) of a creature that looks at its target, or the way it walks. */    private function headingOf(float $x, float $y, ?Enemy $target, bool $left): float    {        if ($target !== null) {            return -atan2($target->y - $y, $target->x - $x);        }        return $left ? M_PI : 0.0;    }    /**     * What a creature is doing and how far into it, for World3d::creature(): fighting (the blow in step with lunge()),     * walking ($age: its walking time; $speed px/s: its legs in step with the ground), else resting ($seed: each in     * its own time). @return array{0: string, 1: float}     */    private function gait(string $sprite, float $size, bool $fighting, bool $walking, float $age, float $speed, float $seed): array    {        if ($fighting) {            return ['fight', $age * 2.2];        }        if ($walking) {            return ['walk', $age * $speed / $this->strideOf($sprite, $size)];        }        return ['idle', $this->time + $seed];    }    /** How far (map px) a creature goes in one walking cycle at a size. */    private function strideOf(string $sprite, float $size): float    {        return max(1.0, Units::stride($sprite) * Units::size($sprite) * $size * self::UNIT_SCALE);    }    private function creature3d(string $sprite, float $size, float $x, float $y, float $yaw, string $anim, float $t, array $tint = [1.0, 1.0, 1.0],                                float $alpha = 1.0, float $lift = 0.3): void    {        $this->world->creature($sprite, $anim, $t, $x, $lift, $y, $yaw, $size * self::UNIT_SCALE, $tint, $alpha);    }    private function enemy3d(Enemy $e): void    {        $def = $e->def;        if ($e->isUnderground()) {            $this->world->draw('mound', static fn () => Props::mound(), $e->x, 0.4, $e->y, 0.0, 1.4);            return;        }        $tint = [1.0, 1.0, 1.0];        if ($e->isStunned()) {            $tint = [0.75, 0.75, 0.82];        }        if ($e->isSlowed()) {            $tint = [0.72, 0.92, 1.35];        }        if ($e->isBurning()) {            $tint = [1.4, 0.8 + 0.25 * sin($this->time * 20), 0.62];        }        $alpha = $e->isStealthy() && !$e->revealed ? 0.28 : 1.0;        $lift = $e->isFlying() ? 15.0 + sin($e->age * 5) * 1.5 : 0.3;        $yaw = -atan2($e->dirY, $e->dirX);        // held by a soldier or a wall: it strikes at what holds it        $fighting = $e->isHeld() && !$e->isStunned();        [$lx, $lz] = $this->lunge($yaw, $fighting, $e->age + 0.4);        $sprite = (string)$def['sprite'];        $scale = (float)($def['scale'] ?? 1.0);        [$anim, $at] = match (true) {            $e->isStunned() => ['stun', $e->age],            $fighting => ['fight', ($e->age + 0.4) * 2.2],            $e->isFlying() => ['fly', $e->age * 2.0],            default => ['walk', $e->distance / $this->strideOf($sprite, $scale)],        };        $this->creature3d($sprite, $scale, $e->x + $lx, $e->y + $lz, $yaw, $anim, $at, $tint, $alpha, $lift);    }    /**     * A fighter's blow: a quick step towards where it looks and back, about twice a second (none when not fighting).     * @return array{0: float, 1: float} the offset on the ground     */    private function lunge(float $yaw, bool $fighting, float $age): array    {        if (!$fighting) {            return [0.0, 0.0];        }        $phase = fmod($age * 2.2, 1.0);        // fast forward, slow back        $reach = $phase < 0.25 ? $phase / 0.25 : max(0.0, 1.0 - ($phase - 0.25) / 0.5);        $d = 3.2 * $reach;        // yaw turns +x towards -z (World3d::matrix): the facing on the ground is (cos, -sin)        return [cos($yaw) * $d, -sin($yaw) * $d];    }    private function tower3d(Tower $t): void    {        $sprite = (string)$this->data->tower($t->type)['sprite'];        $lv = min(3, $t->level);        $spec = $t->spec !== null ? (int)array_search($t->spec, $t->specNames(), true) : null;        $flag = (string)($this->colors['main'] ?? '#3fa860');        // a small jolt when it shoots        $jolt = $t->sinceShot < 0.1 ? 1.0 + 0.05 * (1 - $t->sinceShot / 0.1) : 1.0;        $this->world->draw("t:$sprite:$lv:" . ($spec ?? 'n') . ":$flag", static fn () => Towers::build($sprite, $lv, $spec, $flag),            $t->x, MapModel::PLATFORM, $t->y, 0.0, [self::TOWER_SCALE, self::TOWER_SCALE * $jolt, self::TOWER_SCALE]);    }    /** A wall across the road (broken: stumps), or a trap (open jaws when armed). */    private function roadObject3d(Tower $t): void    {        $road = (array)$t->road;        [, , $dx, $dz] = $this->battle->paths[$road['path']]->at($road['distance']);        $yaw = -atan2($dz, $dx);        if ($t->kind() === 'barrier') {            $stone = $t->spec === 'stone_rampart';            $spiked = $t->spec === 'spiked_palisade';            $standing = $road['hp'] > 0;            $this->world->draw('wall:' . (int)$stone . (int)$standing . (int)$spiked, static fn () => Props::wall($stone, $standing, $spiked),                $road['x'], 0.3, $road['y'], $yaw);            return;        }        $armed = $road['timer'] <= 0;        $this->world->draw('trap:' . (int)$armed, static fn () => Props::trap($armed), $road['x'], 0.3, $road['y'], $yaw);    }    private function projectile3d(\App\Td\Projectile $p): void    {        $kind = $p->kind();        $total = hypot($p->targetX - $p->fromX, $p->targetY - $p->fromY);        $frac = $total > 0 ? max(0.0, min(1.0, 1 - hypot($p->targetX - $p->x, $p->targetY - $p->y) / $total)) : 1.0;        $arc = in_array($kind, ['rock', 'pitch'], true) ? 16.0 : 3.0;        $height = 27.0 + (9.0 - 27.0) * $frac + sin(M_PI * $frac) * $arc;        $yaw = $kind === 'star' ? $this->time * 9.0 : -atan2($p->targetY - $p->y, $p->targetX - $p->x);        $glow = in_array($kind, ['orb', 'note', 'star', 'spark', 'pitch'], true) ? 1.0 : 0.0;        $this->world->draw("p:$kind", static fn () => Props::projectile($kind), $p->x, $height, $p->y, $yaw, 1.3, [1.0, 1.0, 1.0], 1.0, $glow);    }    /** Blasts, glows and the markers of what the player is choosing: translucent, so drawn last. */    private function effects3d(): void    {        $w = $this->world;        foreach ($this->flashes as $f) {            $k = max(0.0, 1 - $f['t'] / 0.25);            $rgb = match ($f['kind']) {                'star' => [1.0, 0.94, 0.55],                'rock' => [0.67, 0.55, 0.4],                'pitch', 'spark' => [1.0, 0.55, 0.16],                'heal' => [0.47, 1.0, 0.55],                'pulse' => [1.0, 1.0, 1.0],                'power' => [1.0, 0.94, 0.78],                default => [0.78, 0.55, 1.0],            };            if ($f['splash'] > 0) {                $radius = $f['splash'] * (0.5 + 0.5 * (1 - $k));                $w->disc($f['x'], $f['y'], $radius, $rgb, 0.12 + 0.32 * $k, 0.9, 1.0);                $w->ring($f['x'], $f['y'], $radius, $rgb, 0.4 + 0.6 * $k, 1.0);            } elseif ($f['kind'] === 'root') {                foreach ([[-3.0, 1.0], [3.0, -1.5], [0.0, 3.0]] as [$dx, $dz]) {                    $w->ball($f['x'] + $dx, 2.0 + (1 - $k) * 4.0, $f['y'] + $dz, 2.2, [0.36, 0.5, 0.2], max(0.3, $k), 0.0);                }            } elseif ($k > 0.05) {                $w->ball($f['x'], 8.0, $f['y'], 1.2 + (1 - $k) * 2.4, [1.0, 1.0, 0.86], $k);            }        }        foreach ($this->particles->list as $p) {            [$alpha, $size] = Particles::look($p);            if ($alpha > 0.03) {                $w->ball($p['x'], $p['y'], $p['z'], $size, $p['rgb'], $alpha, $p['glow']);            }        }        // the platform in focus, its range        $slot = $this->menuSlot ?? ($this->keyboard && $this->aim === null ? $this->cursor : null);        if ($slot !== null && isset($this->battle->slots[$slot])) {            $s = $this->battle->slots[$slot];            $pulse = $this->menuSlot === null ? 0.65 + 0.35 * sin($this->time * 8) : 1.0;            $w->ring($s['x'], $s['y'], 19.0, [1.0, 0.88, 0.35], $pulse * 0.9, MapModel::PLATFORM + 0.3);            $range = $this->menuSlot !== null ? $this->rangeFor($slot) : null;            if ($range !== null) {                $w->disc($s['x'], $s['y'], $range, [1.0, 1.0, 1.0], 0.13, MapModel::PLATFORM + 0.2);                $w->ring($s['x'], $s['y'], $range, [1.0, 1.0, 1.0], 0.7, MapModel::PLATFORM + 0.3);            }        }        // where the hero would go, where the power would fall        if ($this->aim === 'power') {            $radius = (float)$this->battle->power()['radius'];            $w->disc($this->reticleX, $this->reticleY, $radius, [1.0, 0.9, 0.5], 0.22, 1.2, 1.0);            $w->ring($this->reticleX, $this->reticleY, $radius, [1.0, 0.95, 0.7], 0.9, 1.3);        } elseif ($this->aim === 'hero') {            $point = $this->battle->nearestRoad($this->reticleX, $this->reticleY);            $w->disc($point['x'], $point['y'], 9.0, [0.5, 0.85, 1.0], 0.35, 1.2, 1.0);            $w->ring($point['x'], $point['y'], 9.0, [0.8, 0.95, 1.0], 0.9, 1.3);        }    }    /** Health bars of what is hurt, over the 3D (2D: crisp at any size). */    private function bars3d(Renderer $r): void    {        $bar = function (float $x, float $y, float $height, float $ratio, float $width, array $fill) use ($r): void {            [$sx, $sy] = $this->cam->screen($x, $y, $height);            Paint::bar($r, $sx - $width / 2, $sy, $width, 2, $ratio, $fill);        };        foreach ($this->battle->enemies as $e) {            if ($e->hp < $e->maxHp && !$e->isUnderground() && ($e->isTargetable() || $e->isStealthy())) {                $ratio = $e->hp / $e->maxHp;                $height = Units::height((string)$e->def['sprite']) * (float)($e->def['scale'] ?? 1.0) * self::UNIT_SCALE + ($e->isFlying() ? 18.0 : 5.0);                $bar($e->x, $e->y, $height, $ratio, !empty($e->def['boss']) ? 24.0 : 13.0,                    $ratio > 0.5 ? [90, 220, 80] : ($ratio > 0.25 ? [240, 200, 60] : [230, 70, 50]));            }        }        foreach ($this->battle->towers() as $t) {            foreach ($t->soldiers as $s) {                if ($s->isAlive() && $s->hp < $s->maxHp) {                    $bar($s->x, $s->y, 24.0, $s->hp / $s->maxHp, 12.0, [110, 190, 255]);                }            }            if ($t->kind() === 'barrier' && $t->road !== null && $t->road['hp'] > 0 && $t->road['hp'] < $t->road['maxHp']) {                $bar($t->road['x'], $t->road['y'], 18.0, $t->road['hp'] / max(1.0, $t->road['maxHp']), 18.0, [240, 200, 60]);            }        }        $h = $this->battle->hero;        if ($h?->isAlive()) {            $bar($h->x, $h->y, Units::height((string)$h->def['sprite']) * (float)($h->def['scale'] ?? 1.0) * self::UNIT_SCALE + 6.0,                $h->hp / $h->maxHp, 18.0, [110, 190, 255]);        }    }    private function drawTower(Renderer $r, Tower $tower, int $sy): void    {        $def = $this->data->tower($tower->type);        // a small jump when it shoots        $kick = $tower->sinceShot < 0.08 ? 1 : 0;        $specIndex = $tower->spec !== null ? (int)array_search($tower->spec, $tower->specNames(), true) : null;        $this->sprites->drawTower($r, (string)$def['sprite'], $tower->level, $tower->spec, $tower->x, $tower->y + $sy + 8 - $kick, 1.0, $specIndex);        // one pip per level above the first        for ($i = 0; $i < min(3, $tower->level - 1); $i++) {            $r->fillRect($tower->x - 5 + $i * 4, $tower->y + $sy + 10, 3, 2, [255, 210, 80]);        }    }    /** A wall across the road (broken: stumps), or a trap (open jaws when armed). */    private function drawRoadObject(Renderer $r, Tower $tower, int $sy): void    {        $road = (array)$tower->road;        $x = $road['x'];        $y = $road['y'] + $sy;        [, , $dx, $dy] = $this->battle->paths[$road['path']]->at($road['distance']);        // across the road: perpendicular to its direction        $px = -$dy;        $py = $dx;        if ($tower->kind() === 'barrier') {            $standing = $road['hp'] > 0;            $stone = $tower->spec === 'stone_rampart';            for ($i = -2; $i <= 2; $i++) {                $sx = $x + $px * $i * 5;                $sy2 = $y + $py * $i * 5;                $h = $standing ? 12 : 3;                $r->fillRect($sx - 2, $sy2 - $h + 3, 5, $h, [26, 20, 36]);                $r->fillRect($sx - 1, $sy2 - $h + 4, 3, $h - 1, $stone ? [150, 150, 140] : [154, 112, 64]);            }            if ($standing && $road['hp'] < $road['maxHp']) {                $ratio = $road['hp'] / max(1.0, $road['maxHp']);                $r->fillRect($x - 9, $y - 14, 18, 3, [20, 10, 10]);                $r->fillRect($x - 8, $y - 13, 16 * $ratio, 1, [240, 200, 60]);            }            return;        }        // a trap        $armed = $road['timer'] <= 0;        $r->fillRect($x - 6, $y - 2, 12, 5, [26, 20, 36]);        $r->fillRect($x - 5, $y - 1, 10, 3, $armed ? [170, 170, 180] : [90, 80, 70]);        if ($armed) {            for ($i = -4; $i <= 4; $i += 2) {                $r->fillRect($x + $i, $y - 3, 1, 2, [220, 220, 230]);            }        }    }    private function drawHero(Renderer $r, Hero $hero, int $sy): void    {        $def = $hero->def;        $scale = (float)($def['scale'] ?? 1.0);        $x = $hero->x;        $y = $hero->y + $sy;        [$w, $h] = $this->sprites->enemySize((string)$def['sprite']);        $h *= $scale;        // a ring of the side's colour under its feet: the one the player commands        $r->fillRect($x - 8, $y + $h / 2 - 2, 16, 3, $this->colors['main'] ?? [200, 200, 200]);        $this->sprites->drawEnemy($r, (string)$def['sprite'], (int)($hero->age * 6), $x, $y - 2, $hero->facingLeft, $scale);        if ($hero->sinceSkill < 0.3) {            $r->fillCircle($x, $y, (float)$def['skill']['radius'] * (0.4 + $hero->sinceSkill * 2), [255, 255, 200, 60]);        }        $ratio = max(0.0, $hero->hp / $hero->maxHp);        $r->fillRect($x - 9, $y - $h / 2 - 7, 18, 4, [10, 10, 20]);        $r->fillRect($x - 8, $y - $h / 2 - 6, 16 * $ratio, 2, [110, 190, 255]);    }    /** The round hero and power buttons, with what is left of their wait. */    private function drawMapButtons(Renderer $r): void    {        foreach ($this->mapButtons() as $b) {            $x = $b['x'];            $y = $b['y'];            Paint::medallion($r, $x, $y, $b['r'], $this->aim === $b['id']);            $hero = $this->battle->hero;            if ($b['id'] === 'hero' && $hero !== null) {                $def = $hero->def;                if (!$this->sprites->drawPortrait($r, 'unit-' . $def['sprite'], $x, $y, $b['r'] * 1.9)) {                    $this->sprites->drawEnemy($r, (string)$def['sprite'], 0, $x, $y, false);                }                $wait = $hero->isAlive() ? $hero->skillTimer / max(1.0, (float)$def['skill']['cooldown']) : 1.0;            } else {                Paint::icon($r, 'star', $x, $y, 15);                $wait = $this->battle->powerTimer / max(1.0, (float)$this->battle->power()['cooldown']);            }            if ($wait > 0) {                // the part still to wait, darkened from the top of the round button                Paint::discTop($r, $x, $y, $b['r'] - 1.5, $wait, [0, 0, 0, 140]);            }            if ($b['id'] === 'hero' && $hero !== null && !$hero->isAlive()) {                $this->font()->draw($r, (string)(int)ceil($hero->respawn), $x, $y - $this->font()->height() / 2, 1, [255, 150, 130], 'center', [40, 10, 10]);            }        }    }    /** Where the hero goes / the power falls: a circle at the pointer, and what it is. */    private function drawAim(Renderer $r, int $sy): void    {        [$x, $y] = $this->cam->screen($this->reticleX, $this->reticleY);        if ($this->aim === 'hero') {            $point = $this->battle->nearestRoad($this->reticleX, $this->reticleY);            if (!$this->use3d) {                [$rx, $ry] = $this->cam->screen($point['x'], $point['y']);                $this->drawRange($r, $rx, $ry, 10);                $r->drawLine($x, $y, $rx, $ry, [255, 255, 255, 120]);            }            $def = (array)$this->battle->hero?->def;            $lines = [[Lang::t((string)($def['name'] ?? '')), [255, 220, 90]], [Lang::t('CHOOSE WHERE'), [240, 240, 230]],                [Lang::t((string)($def['skill']['name'] ?? '')) . ': ' . Lang::t((string)($def['skill']['desc'] ?? '')), [180, 200, 220]]];        } else {            $power = $this->battle->power();            if (!$this->use3d) {                $this->drawRange($r, $x, $y, (float)$power['radius']);            }            $lines = [[Lang::t($power['name']), [255, 220, 90]], [Lang::t($power['desc']), [240, 240, 230]], [Lang::t('CHOOSE WHERE'), [180, 200, 220]]];        }        $font = $this->font();        $panelW = 196;        $height = 8;        foreach ($lines as [$text]) {            $height += Text::height($font, $text, $panelW - 12) + 2;        }        $px = $x < $this->width() / 2 ? $this->width() - $panelW - 4 : 4;        $r->fillRect($px, self::MAP_Y + 4, $panelW, $height, [14, 12, 20]);        $r->drawRect($px, self::MAP_Y + 4, $panelW, $height, $this->colors['main'] ?? [80, 160, 90]);        $ty = self::MAP_Y + 9;        foreach ($lines as [$text, $color]) {            $ty += Text::draw($r, $font, $text, $px + 6, $ty, $panelW - 12, $color) + 2;        }    }    private function drawSoldier(Renderer $r, \App\Td\Soldier $soldier, string $sprite, int $sy): void    {        $x = $soldier->x;        $y = $soldier->y + $sy;        [$w, $h] = $this->sprites->enemySize($sprite);        $r->fillRect($x - $w / 2 + 2, $y + $h / 2 - 2, $w - 4, 2, [0, 0, 0, 60]);        $this->sprites->drawEnemy($r, $sprite, (int)($soldier->age * 6), $x, $y - 2, $soldier->facingLeft);        // a pennant of the side over its head        $r->fillRect($x - 1, $y - $h / 2 - 5, 3, 2, $this->colors['main'] ?? [200, 200, 200]);        if ($soldier->hp < $soldier->maxHp) {            $ratio = max(0.0, $soldier->hp / $soldier->maxHp);            $r->fillRect($x - 7, $y - $h / 2 - 8, 14, 3, [10, 10, 20]);            $r->fillRect($x - 6, $y - $h / 2 - 7, 12 * $ratio, 1, [110, 190, 255]);        }    }    private function drawEnemy(Renderer $r, Enemy $enemy, int $sy): void    {        $def = $enemy->def;        $frame = $enemy->isStunned() ? 0 : (int)($enemy->age * 6);        $x = $enemy->x;        $y = $enemy->y + $sy;        if ($enemy->isUnderground()) {            // a mound of earth moving under the road            $r->fillRect($x - 5, $y + 1, 10, 3, [110, 80, 50]);            $r->fillRect($x - 3, $y - 1, 6, 2, [140, 104, 66]);            return;        }        $scale = (float)($def['scale'] ?? 1.0);        [$w, $h] = $this->sprites->enemySize((string)$def['sprite']);        $w *= $scale;        $h *= $scale;        if ($enemy->isStealthy() && !$enemy->revealed && (int)($this->time * 8) % 4 !== 0) {            // hidden: only a shimmer, now and then            $r->fillRect($x - 3, $y - 3, 6, 6, [255, 255, 255, 30]);            return;        }        if ($enemy->isFlying()) {            $r->fillRect($x - 5, $y + 6, 10, 2, [0, 0, 0, 70]);            $y -= 10 + sin($enemy->age * 5) * 2;        } else {            $r->fillRect($x - $w / 2 + 2, $y + $h / 2 - 2, $w - 4, 2, [0, 0, 0, 60]);        }        $this->sprites->drawEnemy($r, (string)$def['sprite'], $frame, $x, $y - 2, $enemy->dirX < -0.1, $scale);        if ($enemy->isStunned()) {            $r->fillRect($x - 6, $y + $h / 2 - 3, 2, 4, [110, 70, 30]);            $r->fillRect($x + 4, $y + $h / 2 - 4, 2, 5, [110, 70, 30]);            $r->fillRect($x - 1, $y + $h / 2 - 5, 2, 4, [90, 160, 60]);        }        if ($enemy->isSlowed()) {            $r->fillRect($x - $w / 2, $y - $h / 2, $w, $h, [120, 170, 255, 60]);        }        if ($enemy->isBurning() && (int)($this->time * 10) % 2 === 0) {            $r->fillRect($x - 2, $y - $h / 2 - 2, 2, 2, [255, 160, 40]);            $r->fillRect($x + 2, $y - $h / 2, 2, 2, [255, 90, 30]);        }        if ($enemy->hp < $enemy->maxHp) {            $bw = !empty($def['boss']) ? 22 : 12;            $ratio = max(0.0, $enemy->hp / $enemy->maxHp);            $by = $y - $h / 2 - 5;            $r->fillRect($x - $bw / 2 - 1, $by - 1, $bw + 2, 4, [20, 10, 10]);            $r->fillRect($x - $bw / 2, $by, $bw * $ratio, 2, $ratio > 0.5 ? [90, 220, 80] : ($ratio > 0.25 ? [240, 200, 60] : [230, 70, 50]));        }    }    private function drawProjectiles(Renderer $r, int $sy): void    {        foreach ($this->battle->projectiles as $p) {            $x = $p->x;            $y = $p->y + $sy;            switch ($p->kind()) {                case 'arrow':                case 'bolt':                case 'javelin':                    $dx = $p->targetX - $p->fromX;                    $dy = $p->targetY - $p->fromY;                    $len = max(1.0, hypot($dx, $dy));                    $long = $p->kind() === 'arrow' ? 5 : 8;                    $color = $p->crit ? [255, 120, 80] : ($p->kind() === 'bolt' ? [160, 170, 190] : [230, 210, 160]);                    $r->drawLine($x - $dx / $len * $long, $y - $dy / $len * $long, $x, $y, $color);                    break;                case 'stone':                    $r->fillRect($x - 1, $y - 1, 2, 2, [190, 190, 180]);                    break;                case 'rock':                    $r->fillCircle($x, $y, 3, [60, 56, 50]);                    $r->fillCircle($x - 0.5, $y - 0.5, 2, [150, 146, 136]);                    break;                case 'pitch':                    $r->fillCircle($x, $y, 2.5, [30, 24, 30]);                    $r->fillRect($x - 1, $y - 3, 2, 2, [255, 140, 40]);                    break;                case 'spark':                    $r->fillRect($x - 1, $y - 1, 3, 3, [255, 160, 40]);                    $r->fillRect($x, $y, 1, 1, [255, 240, 160]);                    break;                case 'orb':                    $r->fillCircle($x, $y, 3, [140, 180, 255, 160]);                    $r->fillCircle($x, $y, 1.5, [230, 240, 255]);                    break;                case 'note':                    $r->fillRect($x - 1, $y, 3, 2, [255, 220, 90]);                    $r->fillRect($x + 1, $y - 4, 1, 4, [255, 220, 90]);                    break;                case 'star':                    $r->fillRect($x - 2, $y, 5, 1, [255, 240, 140]);                    $r->fillRect($x, $y - 2, 1, 5, [255, 240, 140]);                    break;                default:                    $r->fillRect($x - 1, $y - 1, 3, 3, [255, 255, 255]);            }        }        foreach ($this->flashes as $flash) {            $x = $flash['x'];            $y = $flash['y'] + $sy;            $k = 1 - $flash['t'] / 0.25;            if ($flash['kind'] === 'root') {                $r->fillRect($x - 7, $y + 1, 3, 5, [120, 80, 40]);                $r->fillRect($x + 5, $y, 3, 6, [120, 80, 40]);                $r->fillRect($x - 1, $y + 3, 3, 4, [80, 150, 60]);            } elseif ($flash['splash'] > 0) {                $color = match ($flash['kind']) {                    'star' => [255, 240, 140],                    'rock' => [170, 140, 100],                    'pitch', 'spark' => [255, 140, 40],                    'heal' => [120, 255, 140],                    'power' => [255, 240, 200],                    'pulse' => [255, 255, 255],                    default => [200, 140, 255],                };                $color[] = (int)(($flash['kind'] === 'pulse' || $flash['kind'] === 'heal' ? 50 : 110) * $k);                $r->fillCircle($x, $y, $flash['splash'] * (0.5 + 0.5 * (1 - $k)), $color);            } else {                $r->fillRect($x - 1, $y - 1, 3, 3, [255, 255, 220, (int)(255 * $k)]);            }        }    }    /** The range to show for a platform: of its tower, or of the tower about to be built / upgraded. */    private function rangeFor(int $slot): ?float    {        $tower = $this->battle->tower($slot);        if ($this->choice !== null && str_starts_with($this->choice, 'build:')) {            return (float)$this->data->towerStats(substr($this->choice, 6), 1)['range'];        }        if ($tower === null) {            return null;        }        if ($this->choice === 'upgrade') {            return (float)$this->data->towerStats($tower->type, $tower->level + 1)['range'];        }        if ($this->choice !== null && str_starts_with($this->choice, 'spec:')) {            return (float)$this->data->towerStats($tower->type, Tower::MAX_LEVEL, substr($this->choice, 5))['range'];        }        return $tower->range();    }    private function drawRing(Renderer $r, int $slot): void    {        // in 3D the range and the frame of the platform are drawn on the ground (indicators3d)        if (!$this->use3d) {            [$sx, $sy] = $this->cam->screen($this->battle->slots[$slot]['x'], $this->battle->slots[$slot]['y']);            $range = $this->rangeFor($slot);            if ($range !== null) {                $this->drawRange($r, $sx, $sy, $range);            }            $r->drawRect($sx - 16, $sy - 14, 32, 30, [255, 255, 255, 120]);        }        foreach ($this->ringOptions($slot) as $option) {            $x = $option['x'];            $y = $option['y'];            Paint::medallion($r, $x, $y, 12, $option['id'] === $this->choice, $option['enabled']);            if ($option['tower'] !== null) {                $def = $this->data->tower($option['tower']);                if (!$this->sprites->drawPortrait($r, 'tower-' . $def['sprite'], $x, $y - 1, 22)) {                    $this->sprites->drawTower($r, (string)$def['sprite'], 1, null, $x, $y + 9, 0.5);                }            } else {                Paint::icon($r, $option['icon'], $x, $y, 13);            }            if ($option['cost'] !== null) {                $text = ($option['id'] === 'sell' ? '+' : '') . $option['cost'];                $color = $option['id'] === 'sell' ? [170, 244, 150] : ($option['enabled'] ? [255, 226, 120] : [244, 120, 104]);                $font = $this->font();                $tw = $font->width($text);                $th = $font->height();                Paint::round($r, $x - $tw / 2 - 3, $y + 9, $tw + 6, $th + 1, ($th + 1) / 2, [24, 16, 12, 220]);                $font->draw($r, $text, $x, $y + 9.5, 1, $color, 'center');            }        }        $this->drawInfo($r, $slot);    }    /** A dotted circle. */    private function drawRange(Renderer $r, float $cx, float $cy, float $radius): void    {        $r->fillCircle($cx, $cy, $radius, [255, 255, 255, 22]);        $n = (int)max(24, $radius * 0.8);        for ($i = 0; $i < $n; $i++) {            $a = $i / $n * 2 * M_PI + $this->time * 0.4;            $r->fillRect($cx + cos($a) * $radius, $cy + sin($a) * $radius, 1, 1, [255, 255, 255, 200]);        }    }    /** The box that says what the chosen option (or the tower) does. */    private function drawInfo(Renderer $r, int $slot): void    {        $lines = $this->infoLines($slot);        if ($lines === []) {            return;        }        $font = $this->font();        $panelW = 196;        $inner = $panelW - 12;        $height = 8;        foreach ($lines as [$text]) {            $height += Text::height($font, $text, $inner) + 2;        }        // on the other side of the map from the slot, so that it never hides the ring        [$sx, $sy] = $this->cam->screen($this->battle->slots[$slot]['x'], $this->battle->slots[$slot]['y']);        $x = $sx < $this->width() / 2 ? $this->width() - $panelW - 4 : 4;        $y = $sy < $this->height() / 2 ? $this->height() - $height - 4 : self::MAP_Y + 4;        Paint::panel($r, $x, $y, $panelW, $height);        $r->fillRect($x + 6, $y + 1, $panelW - 12, 1, $this->colors['main'] ?? [80, 160, 90]);        $ty = $y + 5;        foreach ($lines as [$text, $color]) {            $ty += Text::draw($r, $font, $text, $x + 6, $ty, $inner, $color) + 2;        }    }    /** @return list<array{0: string, 1: array}> */    private function infoLines(int $slot): array    {        $gold = [255, 220, 90];        $white = [240, 240, 230];        $grey = [180, 190, 200];        $tower = $this->battle->tower($slot);        $choice = $this->choice;        if ($choice === null) {            if ($tower === null) {                return [[Lang::t('CHOOSE A TOWER'), $white]];            }            $def = $this->data->tower($tower->type);            $name = Lang::t($def['name']) . ($tower->spec !== null ? ' - ' . Lang::t($def['specs'][$tower->spec]['name']) : '');            $lines = [[$name, $gold], [Lang::t('LEVEL {n}', ['n' => $tower->level]), $white], ...$this->statLines($tower->stats)];            if ($tower->hasTargeting()) {                $lines[] = [Lang::t('TARGET') . ': ' . self::targetingLabel($tower->targeting), $grey];            }            return $lines;        }        [$what, $arg] = array_pad(explode(':', $choice, 2), 2, '');        $again = [Lang::t('PRESS AGAIN TO CONFIRM'), [150, 150, 160]];        switch ($what) {            case 'build':                $def = $this->data->tower($arg);                return [[Lang::t($def['name']), $gold], [Lang::t($def['desc']), $white], ...$this->statLines($this->data->towerStats($arg, 1)), $again];            case 'upgrade':                $next = $this->data->towerStats($tower->type, $tower->level + 1);                return [[Lang::t('UPGRADE') . ' - ' . Lang::t('LEVEL {n}', ['n' => $tower->level + 1]), $gold], ...$this->statLines($next), $again];            case 'spec':                $spec = $this->data->tower($tower->type)['specs'][$arg];                return [[Lang::t($spec['name']), $gold], [Lang::t($spec['desc']), $white],                    ...$this->statLines($this->data->towerStats($tower->type, Tower::MAX_LEVEL, $arg)), $again];            case 'target':                $modes = array_map(static fn (string $m) => self::targetingLabel($m), Tower::TARGETING);                return [[Lang::t('TARGET') . ': ' . self::targetingLabel($tower->targeting), $gold], [implode(' / ', $modes), $grey], $again];            case 'sell':                return [[Lang::t('SELL') . ' +' . $tower->sellValue(), $gold], $again];        }        return [];    }    private static function targetingLabel(string $mode): string    {        return match ($mode) {            'last' => Lang::t('LAST'),            'strongest' => Lang::t('STRONGEST'),            'closest' => Lang::t('CLOSEST'),            default => Lang::t('FIRST'),        };    }    /** @return list<array{0: string, 1: array}> what the tower does, in numbers: soldiers, wall, aura, gold, attack */    private function statLines(array $stats): array    {        $blue = [200, 230, 255];        $green = [170, 200, 170];        $kind = (string)$stats['kind'];        $lines = [];        if ($kind === 'barracks') {            $s = $stats['soldiers'];            $lines[] = [Lang::t('SOLDIERS {n}', ['n' => (int)$s['count']]) . '  ' . Lang::t('HEALTH {n}', ['n' => (int)$s['hp']]), $blue];            $lines[] = [Lang::t('DAMAGE {n}', ['n' => (int)$s['damage']]), $green];        }        if ($kind === 'barrier') {            $lines[] = [Lang::t('HEALTH {n}', ['n' => (int)$stats['barrier']['hp']]), $blue];        }        if (isset($stats['aura'])) {            $damage = (int)round(((float)$stats['aura']['damage'] - 1) * 100);            $speed = (int)round((1 / max(0.1, (float)$stats['aura']['rate']) - 1) * 100);            if ($damage > 0) {                $lines[] = [Lang::t('NEARBY TOWERS: DAMAGE +{n}%', ['n' => $damage]), $blue];            }            if ($speed > 0) {                $lines[] = [Lang::t('NEARBY TOWERS: SPEED +{n}%', ['n' => $speed]), $blue];            }        }        if (isset($stats['income'])) {            $lines[] = [Lang::t('+{n} GOLD EVERY {s} S', ['n' => (int)$stats['income']['gold'], 's' => (int)$stats['income']['every']]), [255, 220, 90]];        }        if (!empty($stats['detect'])) {            $lines[] = [Lang::t('REVEALS HIDDEN ENEMIES'), $green];        }        if ((float)$stats['damage'] <= 0 || in_array($kind, ['barracks', 'barrier'], true)) {            return $lines;        }        $type = match ((string)$stats['damage_type']) {            'physical' => Lang::t('PHYSICAL'),            'pierce' => Lang::t('PIERCING'),            'magic' => Lang::t('MAGIC'),            'fire' => Lang::t('FIRE'),            default => Lang::t('SIEGE'),        };        $targets = match ((string)$stats['targets']) {            'ground' => Lang::t('GROUND ONLY'),            'air' => Lang::t('AIR ONLY'),            default => Lang::t('GROUND AND AIR'),        };        // per second for towers that shoot, per snap for traps        $damage = $kind === 'trap' ? (float)$stats['damage'] : (float)$stats['damage'] / max(0.1, (float)$stats['rate']);        $lines[] = [Lang::t('DAMAGE {n}', ['n' => round($damage)]) . '  ' . Lang::t('RANGE {n}', ['n' => (int)$stats['range']]), $blue];        $lines[] = [$type . ' - ' . $targets, $green];        return $lines;    }    private function drawHud(Renderer $r): void    {        $w = $this->width();        $font = $this->font();        // a wooden bar, its soft shadow on the land, a thread of the side's colour        $r->fillRect(0, self::MAP_Y, $w, 2, [0, 0, 0, 80]);        $r->fillRect(0, self::MAP_Y + 2, $w, 3, [0, 0, 0, 34]);        $r->fillRect(0, 0, $w, self::MAP_Y, Paint::WOOD_DARK);        $r->fillRect(0, 0, $w, self::MAP_Y - 1.5, [96, 62, 38]);        $r->fillRect(0, 0, $w, 12, [114, 76, 46]);        $r->fillRect(0, 0.5, $w, 0.75, [255, 236, 200, 40]);        $r->fillRect(0, self::MAP_Y - 1.5, $w, 1.5, $this->colors['main'] ?? [60, 140, 80]);        $ty = 15 - $font->height() / 2;        // lives, gold, the wave: each in a dark pill        $x = 4;        $pill = function (string $icon, string $text, array $color, float $min) use ($r, $font, $ty, &$x): void {            $tw = max($min, $font->width($text));            Paint::round($r, $x, 6, $tw + 26, 18, 9, [20, 12, 8, 120]);            Paint::icon($r, $icon, $x + 10, 15, 11);            $font->draw($r, $text, $x + 19, $ty, 1, $color, 'left', [30, 16, 10]);            $x += $tw + 30;        };        $pill('heart', (string)$this->battle->lives, [255, 196, 202], 12);        $pill('coin', (string)$this->battle->gold, [255, 226, 120], 20);        $pill('flag', Lang::t('WAVE {n}', ['n' => $this->battle->waveIndex . '/' . $this->battle->waveCount()]), [244, 238, 226], 0);        $left = $x;        if ($this->battle->hard) {            Paint::icon($r, 'skull', $left + 6, 15, 11);            $left += 16;        }        $mouse = $this->engine->input->getMouse();        foreach ($this->hudButtons() as $b) {            $enabled = $b['id'] !== 'call' || $this->battle->canCallWave();            $hover = $mouse->x >= $b['x'] && $mouse->x < $b['x'] + $b['w'] && $mouse->y >= $b['y'] && $mouse->y < $b['y'] + $b['h'];            Paint::button($r, $b['x'], $b['y'] + 1, $b['w'], $b['h'] - 2, $enabled, $hover, $b['id'] === 'speed' && $this->speed > 1);            $cy = $b['y'] + $b['h'] / 2;            if ($b['id'] === 'pause') {                Paint::icon($r, 'pause', $b['x'] + $b['w'] / 2, $cy, 10);            } elseif ($b['id'] === 'speed') {                $font->draw($r, 'x' . $this->speed, $b['x'] + $b['w'] / 2, $cy - $font->height() / 2, 1, $this->speed > 1 ? [255, 226, 120] : [244, 238, 226], 'center', [40, 22, 12]);            } else {                Paint::icon($r, 'play', $b['x'] + 11, $cy, 9);                $font->draw($r, $this->callLabel(), $b['x'] + 20, $cy - $font->height() / 2, 1, $enabled ? [255, 242, 214] : [196, 186, 176], 'left', [40, 22, 12]);                $left = $this->drawPreview($r, $left, $b['x'] - 4);                if ($enabled && $this->battle->countdown !== null) {                    // the time left, as a bar under the button                    $total = $this->battle->waveIndex === 0 ? (float)($this->params['countdown'] ?? $this->map['first_wave']) : 25.0;                    Paint::bar($r, $b['x'] + 4, $b['y'] + $b['h'] - 4, $b['w'] - 8, 1.5, min(1.0, $this->battle->countdown / max(1.0, $total)), Paint::GOLD);                }            }        }    }    /** The next wave's enemies before the call button, when there is room. Returns the x used up to. */    private function drawPreview(Renderer $r, float $left, float $right): float    {        $preview = $this->battle->nextWavePreview();        if ($preview === []) {            return $left;        }        $font = $this->font();        $width = 0;        foreach ($preview as $count) {            $width += 14 + $font->width('x' . $count) + 4;        }        if ($right - $width < $left) {            return $left;        }        $x = $right - $width;        foreach ($preview as $type => $count) {            $sprite = (string)$this->data->enemy($type)['sprite'];            if (!$this->sprites->drawPortrait($r, "unit-$sprite", $x + 7, 15, 18)) {                [$w, $h] = $this->sprites->enemySize($sprite);                $scale = min(1.0, 14 / max($w, $h));                $this->sprites->drawEnemy($r, $sprite, 0, $x + 7, 15, false, $scale);            }            $font->draw($r, 'x' . $count, $x + 15, 15 - $font->height() / 2, 1, [220, 220, 220], 'left', [0, 0, 0]);            $x += 14 + $font->width('x' . $count) + 4;        }        return $x;    }    private function drawBanner(Renderer $r): void    {        if ($this->banners === []) {            return;        }        [$title, $line, $seconds] = $this->banners[0];        $t = $this->bannerTime;        // 4 steps of fading: each colour of text is a texture of its own        $alpha = round(min(1.0, $t * 4, ($seconds - $t) * 4) * 4) / 4;        if ($alpha <= 0) {            return;        }        $font = $this->font();        $w = $this->width();        $scale = Text::fitScale($font, $title, $w - 40, 1.7);        $height = $font->lineHeight($scale) + ($line !== '' ? Text::height($font, $line, $w - 60) + 4 : 0) + 12;        $y = self::MAP_Y + 56;        // a ribbon of ink over the land, edged with gold, fading in and out with its text (over a key-coloured 3D        // layer: a black ribbon and opaque text, see World3d)        $a = Paint::$keyed ? 255 : (int)(255 * $alpha);        $r->fillRect(0, $y, $w, $height, World3d::veil([20, 26, 46, (int)(200 * $alpha)]));        Paint::round($r, 0, $y, $w, 1, 0, [214, 176, 92, (int)(220 * $alpha)]);        Paint::round($r, 0, $y + $height - 1, $w, 1, 0, [214, 176, 92, (int)(220 * $alpha)]);        $font->draw($r, $title, $w / 2, $y + 5, $scale, [255, 225, 130, $a], 'center', [40, 22, 10, $a]);        if ($line !== '') {            Text::draw($r, $font, $line, $w / 2, $y + 8 + $font->lineHeight($scale), $w - 60, [232, 236, 244, $a], 'center', [16, 20, 36, $a]);        }    }}