<?phpnamespace App\Gfx;use App\Game\BrushMap;use App\Game\Run;use JumPHP\Gl\Mat4;use JumPHP\Gl\WebGl;use JumPHP\Kernel\Engine;use JumPHP\Renderer\Renderer;/** Persistent GPU resources shared by every enemy, pickup, wall and procedural decal. */final class World{ public const KEY = [255, 0, 255]; /** @var array<string,int> */ private array $ids = []; private ?int $preparedMapId = null; /** @var array<int,array<string,float|bool>> */ private array $enemyVisibility = []; /** @var array<int,int> */ private array $lastEnemyHealth = []; /** @var list<array{x:float,y:float,z:float,amount:int,born:float}> */ private array $damagePopups = []; public int $visibleEnemies = 0; public int $renderLosTests = 0; public int $mapChunksDrawn = 0; public const MAX_IMPACTS = 24; public const MAX_DAMAGE_POPUPS = 48; public function prepare(WebGl $gl, Run $run): void { if (!$gl->isAvailable()) return; $mapId = spl_object_id($run->map); if ($this->ids === [] || $this->preparedMapId !== $mapId) { $materialMap = function (string $material) use ($run): BrushMap { $m = new BrushMap(); $m->solids = array_values(array_filter($run->map->solids, static function (array $solid) use ($material): bool { return strtolower((string)($solid['material'] ?? 'stone')) === $material; })); return $m; }; $ids = [ 'program' => $gl->program(Meshes::VERTEX, Meshes::FRAGMENT), 'worldStone' => $gl->buffer(Meshes::world($materialMap('stone')), true), 'worldMetal' => $gl->buffer(Meshes::world($materialMap('metal')), true), 'worldCeil' => $gl->buffer(Meshes::world($materialMap('ceil')), true), 'worldCrate' => $gl->buffer(Meshes::world($materialMap('crate')), true), 'guardian' => $gl->buffer(Meshes::enemy('guardian'), true), 'runner' => $gl->buffer(Meshes::enemy('runner'), true), 'brute' => $gl->buffer(Meshes::enemy('brute'), true), 'pulse' => $gl->buffer(Meshes::pulse(), true), 'stoneTex' => $gl->texture('assets/textures/librequake/brick1.png', false, true), 'metalTex' => $gl->texture('assets/textures/librequake/met_blc_block.png', false, true), 'ceilTex' => $gl->texture('assets/textures/librequake/med_flat8.png', false, true), 'crateTex' => $gl->texture('assets/textures/librequake/crate4.png', false, true), ]; $poster = Meshes::decals($run->map->decals, 'poster3615'); if ($poster !== []) { $ids['poster3615'] = $gl->buffer($poster, true); $ids['poster3615Tex'] = $gl->texture('assets/textures/posters/3615-troll.png', false, false); } $advert = Meshes::decals($run->map->decals, 'posterAd'); if ($advert !== []) { $ids['posterAd'] = $gl->buffer($advert, true); $image = PosterFactory::advert(); $ids['posterAdTex'] = $gl->textureFromRgba(imagesx($image), imagesy($image), WebGl::rgbaFromGd($image), false); imagedestroy($image); } $this->ids = $ids; $this->preparedMapId = $mapId; } $gl->setLayer('below', 'native', self::KEY); $gl->setSupersample(1); } public static function eye(Run $run): array { return [$run->x, $run->y + 0.42, $run->z]; } public static function camera(Run $run, float $aspect, float $pitch = 0.0): array { $eye = self::eye($run); $cp = cos($pitch); $target = [$eye[0] + sin($run->yaw) * $cp, $eye[1] + sin($pitch), $eye[2] + cos($run->yaw) * $cp]; return Mat4::multiply(Mat4::perspective(67.0, $aspect, 0.08, 48.0), Mat4::lookAt($eye, $target)); } public function render(Engine $engine, Renderer $r, Run $run, float $pitch = 0.0): void { $this->captureDamagePopups($run); $gl = $engine->gl(); if ($gl->isAvailable() && $this->ids !== []) { $this->mapChunksDrawn = 1; $r->clear(self::KEY); $this->draw3d($gl, $r, $run, $pitch); $this->drawPickupSprites($engine, $r, $run, $pitch); $this->drawEnemySprites($engine, $r, $run, $pitch); $this->drawDeathSprites($engine, $r, $run, $pitch); $this->drawImpactSprites($engine, $r, $run, $pitch); $this->drawDamagePopups($r, $run, $pitch); return; } $this->mapChunksDrawn = 0; $this->drawFallback($r, $run); } private function draw3d(WebGl $gl, Renderer $r, Run $run, float $pitch): void { $fog = [0.14, 0.12, 0.15]; $gl->clear([18, 13, 22]); $vp = self::camera($run, $r->getWidth() / max(1, $r->getHeight()), $pitch); $common = ['u_viewProj' => $vp, 'u_eye' => self::eye($run), 'u_fog' => $fog]; $draw = function (int $buffer, array $model, float $flash = 0.0, bool $textured = false, string $texture = 'stoneTex') use ($gl, $common): void { $gl->draw($this->ids['program'], [ 'attributes' => [ 'a_pos' => [$buffer, 3, Meshes::STRIDE, 0], 'a_color' => [$buffer, 3, Meshes::STRIDE, 3], 'a_normal' => [$buffer, 3, Meshes::STRIDE, 6], 'a_uv' => [$buffer, 2, Meshes::STRIDE, 9], ], 'uniforms' => $common + ['u_model' => $model, 'u_flash' => $flash, 'u_useTex' => $textured ? 1.0 : 0.0], 'textures' => ['u_tex' => $this->ids[$texture]], ]); }; $draw($this->ids['worldStone'], Mat4::identity(), 0.0, true, 'stoneTex'); $draw($this->ids['worldMetal'], Mat4::identity(), 0.0, true, 'metalTex'); $draw($this->ids['worldCeil'], Mat4::identity(), 0.0, true, 'ceilTex'); $draw($this->ids['worldCrate'], Mat4::identity(), 0.0, true, 'crateTex'); if (isset($this->ids['poster3615'], $this->ids['poster3615Tex'])) { $draw($this->ids['poster3615'], Mat4::identity(), 0.0, true, 'poster3615Tex'); } if (isset($this->ids['posterAd'], $this->ids['posterAdTex'])) { $draw($this->ids['posterAd'], Mat4::identity(), 0.0, true, 'posterAdTex'); } if ($run->pulse > 0) { $size = (1 - $run->pulse / 0.25) * 4.2; $draw($this->ids['pulse'], Mat4::multiply(Mat4::translate($run->x, $run->y - 0.5, $run->z), Mat4::scale($size))); } foreach ($run->traces as $trace) { $start = Mat4::project($vp, [$trace['startX'], $trace['startY'], $trace['startZ']], $r->getWidth(), $r->getHeight()); $end = Mat4::project($vp, [$trace['endX'], $trace['endY'], $trace['endZ']], $r->getWidth(), $r->getHeight()); if ($start === null || $end === null || $start[2] < 0 || $start[2] > 1 || $end[2] < 0 || $end[2] > 1) continue; $color = match ($trace['type']) { 'flame' => [255, 122, 45], 'rocket' => [255, 150, 65], 'special' => [120, 240, 255], default => [255, 205, 105], }; $r->drawLine((int)$start[0], (int)$start[1], (int)$end[0], (int)$end[1], $color); } } private function drawEnemySprites(Engine $engine, Renderer $r, Run $run, float $pitch): void { $this->visibleEnemies = $this->renderLosTests = 0; $this->enemyVisibility = array_intersect_key($this->enemyVisibility, $run->enemies); $sdl = $engine->sdl; foreach (EnemyAtlas::SHEETS as $sprite) { if (!$sdl->hasTexture($sprite['texture'])) $sdl->loadTexture($sprite['texture'], $sprite['path']); } $vp = self::camera($run, $r->getWidth() / max(1, $r->getHeight()), $pitch); $enemies = array_values($run->enemies); usort($enemies, static function (array $a, array $b) use ($run): int { $da = hypot($a['x'] - $run->x, $a['z'] - $run->z); $db = hypot($b['x'] - $run->x, $b['z'] - $run->z); return $db <=> $da; }); foreach ($enemies as $enemy) { $distance = hypot($enemy['x'] - $run->x, $enemy['z'] - $run->z); if ($distance > 40.0) continue; $point = Mat4::project($vp, [$enemy['x'], $enemy['y'] + 0.10, $enemy['z']], $r->getWidth(), $r->getHeight()); if ($point === null || $point[2] < 0 || $point[2] > 1) continue; if ($point[0] < -160 || $point[0] > $r->getWidth() + 160 || $point[1] < -160 || $point[1] > $r->getHeight() + 160) continue; if (!$this->enemyVisible($run, $enemy, $distance)) continue; $sprite = EnemyAtlas::SHEETS[$enemy['type']] ?? EnemyAtlas::SHEETS['guardian']; $state = $enemy['animState'] ?? 'walk'; $facing = atan2($run->x - $enemy['x'], $run->z - $enemy['z']) - ($enemy['yaw'] ?? 0.0); $facing = fmod($facing, 2 * M_PI); if ($facing < 0) $facing += 2 * M_PI; $column = (int)round($facing / (2 * M_PI) * 8) % 8; $source = EnemyAtlas::frame($enemy['type'], $state, (int)floor(($enemy['age'] ?? 0.0) * 7.0), $column); if ($source === null || !EnemyAtlas::valid($sprite, $source)) continue; [$x, $y, $spriteW, $spriteH] = $this->spriteRect($enemy['type'], $sprite, $source, $point, $distance); $tex = $sdl->getTexture($sprite['texture']); $r->copy($tex['texture'], $source, $x, $y, $spriteW, $spriteH); $this->drawEnemyHealthBar($r, $enemy, $x, $y, $spriteW); $this->visibleEnemies++; if ($enemy['flash'] > 0) $r->drawRect($x - 1, $y - 1, $spriteW + 2, $spriteH + 2, [255, 105, 75, 190], 1); } } private function drawEnemyHealthBar(Renderer $r, array $enemy, int $x, int $y, int $spriteW): void { $max = max(1, (int)($enemy['maxHealth'] ?? 1)); $ratio = max(0.0, min(1.0, (int)$enemy['health'] / $max)); $barW = max(12, min(38, $spriteW)); $barH = 3; $barX = (int)round($x + $spriteW / 2 - $barW / 2); $barY = $y - 6; $fill = (int)round($barW * $ratio); $color = $ratio > 0.55 ? [76, 226, 104, 245] : ($ratio > 0.25 ? [242, 180, 65, 245] : [245, 68, 78, 245]); $r->fillRect($barX - 1, $barY - 1, $barW + 2, $barH + 2, [7, 5, 12, 230]); $r->fillRect($barX, $barY, $barW, $barH, [48, 42, 52, 230]); if ($fill > 0) $r->fillRect($barX, $barY, $fill, $barH, $color); } private function spriteRect(string $type, array $sprite, array $source, array $point, float $distance): array { $referenceHeight = match ($type) { 'runner' => 77.0, 'guardian' => 63.0, 'brute' => 87.0, 'enforcer' => 63.0, default => 63.0, }; $standingHeight = max(24.0, min(160.0, (480.0 / max(1.25, $distance)) * $sprite['scale'])); $pixelScale = $standingHeight / $referenceHeight; $spriteW = max(8, (int)round($source[2] * $pixelScale)); $spriteH = max(4, (int)round($source[3] * $pixelScale)); $feetY = $point[1] + $standingHeight * 0.45; return [(int)round($point[0] - $spriteW / 2), (int)round($feetY - $spriteH), $spriteW, $spriteH]; } private function enemyVisible(Run $run, array $enemy, float $distance): bool { $id = $enemy['id']; $cached = $this->enemyVisibility[$id] ?? null; $far = $distance > 18.0; $interval = $far ? 0.20 + ($id % 5) * 0.015 : 0.08 + ($id % 5) * 0.01; $movement = $far ? 0.38 + ($id % 3) * 0.025 : 0.28 + ($id % 5) * 0.025; if ($cached === null || $run->time - $cached['time'] >= $interval || hypot($run->x - $cached['playerX'], $run->z - $cached['playerZ']) > $movement || abs($run->y - $cached['playerY']) > 0.3 || hypot($enemy['x'] - $cached['enemyX'], $enemy['z'] - $cached['enemyZ']) > $movement || abs($enemy['y'] - $cached['enemyY']) > 0.3) { $this->renderLosTests++; $visible = $this->enemyLineClear($run, self::eye($run), [$enemy['x'], $enemy['y'] + 0.15, $enemy['z']]); $this->enemyVisibility[$id] = [ 'visible' => $visible, 'time' => $run->time, 'playerX' => $run->x, 'playerY' => $run->y, 'playerZ' => $run->z, 'enemyX' => $enemy['x'], 'enemyY' => $enemy['y'], 'enemyZ' => $enemy['z'], ]; } return $this->enemyVisibility[$id]['visible']; } private function enemyLineClear(Run $run, array $a, array $b): bool { foreach ($run->map->solids as $solid) { $lo = 0.001; $hi = 0.999; for ($axis = 0; $axis < 3; $axis++) { $d = $b[$axis] - $a[$axis]; if (abs($d) < 1e-8) { if ($a[$axis] < $solid['min'][$axis] || $a[$axis] > $solid['max'][$axis]) { $lo = 2; break; } } else { $t0 = ($solid['min'][$axis] - $a[$axis]) / $d; $t1 = ($solid['max'][$axis] - $a[$axis]) / $d; $lo = max($lo, min($t0, $t1)); $hi = min($hi, max($t0, $t1)); } if ($lo > $hi) break; } if ($lo > $hi) continue; $triangles = $solid['triangles'] ?? []; if ($triangles === []) return false; for ($i = 0, $n = count($triangles); $i + 2 < $n; $i += 3) { if ($this->segmentHitsTriangle($a, $b, $triangles[$i]['p'], $triangles[$i + 1]['p'], $triangles[$i + 2]['p'])) return false; } } return true; } private function segmentHitsTriangle(array $a, array $b, array $v0, array $v1, array $v2): bool { $d = [$b[0] - $a[0], $b[1] - $a[1], $b[2] - $a[2]]; $e1 = [$v1[0] - $v0[0], $v1[1] - $v0[1], $v1[2] - $v0[2]]; $e2 = [$v2[0] - $v0[0], $v2[1] - $v0[1], $v2[2] - $v0[2]]; $p = [$d[1] * $e2[2] - $d[2] * $e2[1], $d[2] * $e2[0] - $d[0] * $e2[2], $d[0] * $e2[1] - $d[1] * $e2[0]]; $det = $e1[0] * $p[0] + $e1[1] * $p[1] + $e1[2] * $p[2]; if (abs($det) < 1e-8) return false; $inv = 1.0 / $det; $tvec = [$a[0] - $v0[0], $a[1] - $v0[1], $a[2] - $v0[2]]; $u = ($tvec[0] * $p[0] + $tvec[1] * $p[1] + $tvec[2] * $p[2]) * $inv; if ($u < 0.0 || $u > 1.0) return false; $q = [$tvec[1] * $e1[2] - $tvec[2] * $e1[1], $tvec[2] * $e1[0] - $tvec[0] * $e1[2], $tvec[0] * $e1[1] - $tvec[1] * $e1[0]]; $v = ($d[0] * $q[0] + $d[1] * $q[1] + $d[2] * $q[2]) * $inv; if ($v < 0.0 || $u + $v > 1.0) return false; $t = ($e2[0] * $q[0] + $e2[1] * $q[1] + $e2[2] * $q[2]) * $inv; return $t > 0.001 && $t < 0.999; } private function drawPickupSprites(Engine $engine, Renderer $r, Run $run, float $pitch): void { if ($run->pickups === []) return; $sdl = $engine->sdl; foreach ([ 'health' => ['name' => 'pickup_health', 'path' => 'assets/sprites/pickups/health-pack.png', 'width' => 32, 'height' => 38], 'xp' => ['name' => 'pickup_xp', 'path' => 'assets/sprites/pickups/xp-orb.png', 'width' => 29, 'height' => 36], ] as $sprite) { if (!$sdl->hasTexture($sprite['name'])) $sdl->loadTexture($sprite['name'], $sprite['path']); } $vp = self::camera($run, $r->getWidth() / max(1, $r->getHeight()), $pitch); foreach ($run->pickups as $pickup) { $point3d = [$pickup['x'], $pickup['y'] + sin($run->time * 4) * 0.06 + 0.18, $pickup['z']]; if (!$run->lineClear(self::eye($run), $point3d)) continue; $point = Mat4::project($vp, $point3d, $r->getWidth(), $r->getHeight()); if ($point === null || $point[2] < 0 || $point[2] > 1) continue; $sprite = $pickup['kind'] === 'health' ? ['name' => 'pickup_health', 'width' => 32, 'height' => 38] : ['name' => 'pickup_xp', 'width' => 29, 'height' => 36]; $distance = hypot($pickup['x'] - $run->x, $pickup['z'] - $run->z); $height = (int)max(14, min(34, 170.0 / max(1.25, $distance))); $width = (int)max(12, round($height * $sprite['width'] / $sprite['height'])); $texture = $sdl->getTexture($sprite['name']); $r->copy($texture['texture'], [0, 0, $sprite['width'], $sprite['height']], (int)round($point[0] - $width / 2), (int)round($point[1] - $height / 2), $width, $height); } } private function drawImpactSprites(Engine $engine, Renderer $r, Run $run, float $pitch): void { if ($run->traces === []) return; $sdl = $engine->sdl; if (!$sdl->hasTexture('hit_spark')) $sdl->loadTexture('hit_spark', 'assets/sprites/effects/spark-impact.png'); if (!$sdl->hasTexture('flame_fx')) $sdl->loadTexture('flame_fx', 'assets/sprites/effects/muzzle-large-fire.png'); $spark = $sdl->getTexture('hit_spark'); $flame = $sdl->getTexture('flame_fx'); $vp = self::camera($run, $r->getWidth() / max(1, $r->getHeight()), $pitch); foreach (array_slice($run->traces, -self::MAX_IMPACTS) as $trace) { $point = Mat4::project($vp, [$trace['x'], $trace['y'], $trace['z']], $r->getWidth(), $r->getHeight()); if ($point === null || $point[2] < 0 || $point[2] > 1) continue; $distance = hypot($trace['x'] - $run->x, $trace['z'] - $run->z); $size = (int)max(14, min(46, 150.0 / max(1.25, $distance))); if (($trace['type'] ?? '') === 'flame') { $r->copy($flame['texture'], null, (int)round($point[0] - $size / 2), (int)round($point[1] - $size / 2), $size, $size); continue; } if (($trace['type'] ?? '') === 'shuriken') { $spin = $run->time * 12.0; $radius = max(3, (int)round($size * 0.28)); $cx = (int)round($point[0]); $cy = (int)round($point[1]); for ($arm = 0; $arm < 4; $arm++) { $a = $spin + $arm * M_PI / 2; $r->drawLine($cx, $cy, (int)round($cx + cos($a) * $radius), (int)round($cy + sin($a) * $radius), [195, 235, 255]); } $r->fillCircle($cx, $cy, 2, [80, 125, 150]); continue; } $duration = max(1 / 60, (float)($trace['totalLife'] ?? 0.09)); $elapsed = max(0.0, $duration - (float)$trace['life']); $frame = min(8, (int)floor($elapsed / $duration * 9)); $r->copy($spark['texture'], [$frame * 32, 0, 32, 32], (int)round($point[0] - $size / 2), (int)round($point[1] - $size / 2), $size, $size); } } private function drawDeathSprites(Engine $engine, Renderer $r, Run $run, float $pitch): void { if ($run->corpses === []) return; $sdl = $engine->sdl; $vp = self::camera($run, $r->getWidth() / max(1, $r->getHeight()), $pitch); foreach ($run->corpses as $corpse) { $sprite = EnemyAtlas::SHEETS[$corpse['type']] ?? EnemyAtlas::SHEETS['guardian']; $distance = hypot($corpse['x'] - $run->x, $corpse['z'] - $run->z); if ($distance > 40.0) continue; $point = Mat4::project($vp, [$corpse['x'], $corpse['y'] + 0.1, $corpse['z']], $r->getWidth(), $r->getHeight()); if ($point === null || $point[2] < 0 || $point[2] > 1) continue; if ($point[0] < -160 || $point[0] > $r->getWidth() + 160 || $point[1] < -160 || $point[1] > $r->getHeight() + 160) continue; if (!$run->lineClear(self::eye($run), [$corpse['x'], $corpse['y'] + 0.1, $corpse['z']])) continue; $deathFrames = count($sprite['frames'][$sprite['death']]); $frame = min($deathFrames - 1, (int)floor($corpse['age'] / 0.64 * $deathFrames)); $source = EnemyAtlas::frame($corpse['type'], 'death', $frame, 0); if ($source === null || !EnemyAtlas::valid($sprite, $source)) continue; [$x, $y, $spriteW, $spriteH] = $this->spriteRect($corpse['type'], $sprite, $source, $point, $distance); $texture = $sdl->getTexture($sprite['texture']); $r->copy($texture['texture'], $source, $x, $y, $spriteW, $spriteH); } } private function captureDamagePopups(Run $run): void { $current = []; foreach ($run->enemies as $enemy) $current[(int)$enemy['id']] = $enemy; foreach ($run->corpses as $corpse) $current[(int)$corpse['id']] = $corpse; foreach ($current as $id => $enemy) { $health = (int)$enemy['health']; if (isset($this->lastEnemyHealth[$id]) && $health < $this->lastEnemyHealth[$id]) { $amount = $this->lastEnemyHealth[$id] - $health; if ($amount > 0) { if (count($this->damagePopups) >= self::MAX_DAMAGE_POPUPS) array_shift($this->damagePopups); $this->damagePopups[] = ['x' => (float)$enemy['x'], 'y' => (float)$enemy['y'] + 0.72, 'z' => (float)$enemy['z'], 'amount' => $amount, 'born' => $run->time]; } } $this->lastEnemyHealth[$id] = $health; } foreach (array_keys($this->lastEnemyHealth) as $id) if (!isset($current[$id])) unset($this->lastEnemyHealth[$id]); $this->damagePopups = array_values(array_filter($this->damagePopups, static fn (array $popup): bool => $run->time - $popup['born'] < 0.72)); } private function drawDamagePopups(Renderer $r, Run $run, float $pitch): void { if ($this->damagePopups === []) return; $vp = self::camera($run, $r->getWidth() / max(1, $r->getHeight()), $pitch); $scale = max(1, min(2, (int)round($r->getHeight() / 180.0))); foreach ($this->damagePopups as $popup) { $age = max(0.0, $run->time - $popup['born']); $progress = min(1.0, $age / 0.72); $point = Mat4::project($vp, [$popup['x'], $popup['y'] + $progress * 0.38, $popup['z']], $r->getWidth(), $r->getHeight()); if ($point === null || $point[2] < 0 || $point[2] > 1) continue; if ($point[0] < -40 || $point[0] > $r->getWidth() + 40 || $point[1] < -20 || $point[1] > $r->getHeight() + 20) continue; $alpha = (int)round(255 * (1.0 - $progress)); $text = '-' . $popup['amount']; $width = $this->miniTextWidth($text, $scale); $this->drawMiniText($r, $text, (int)round($point[0] - $width / 2), (int)round($point[1] - 8 * $scale), $scale, [255, 214, 92, $alpha]); } } private function miniTextWidth(string $text, int $scale): int { return max(0, strlen($text) * 4 * $scale - $scale); } private function drawMiniText(Renderer $r, string $text, int $x, int $y, int $scale, array $color): void { $glyphs = [ '-' => ['000','000','111','000','000'], '0' => ['111','101','101','101','111'], '1' => ['010','110','010','010','111'], '2' => ['111','001','111','100','111'], '3' => ['111','001','111','001','111'], '4' => ['101','101','111','001','001'], '5' => ['111','100','111','001','111'], '6' => ['111','100','111','101','111'], '7' => ['111','001','010','010','010'], '8' => ['111','101','111','101','111'], '9' => ['111','101','111','001','111'], ]; $cursor = $x; foreach (str_split($text) as $char) { $rows = $glyphs[$char] ?? $glyphs['0']; foreach ($rows as $row => $bits) { for ($col = 0; $col < 3; $col++) if ($bits[$col] === '1') $r->fillRect($cursor + $col * $scale, $y + $row * $scale, $scale, $scale, $color); } $cursor += 4 * $scale; } } private function drawFallback(Renderer $r, Run $run): void { $r->clear([18, 14, 22]); $scale = 5.6; $cx = $r->getWidth() / 2; $cy = $r->getHeight() / 2; foreach ($run->map->solids as $solid) { [$x0, , $z0] = $solid['min']; [$x1, , $z1] = $solid['max']; $r->fillRect((int)round($cx + $x0 * $scale), (int)round($cy + $z0 * $scale), max(1, (int)round(($x1 - $x0) * $scale)), max(1, (int)round(($z1 - $z0) * $scale)), $solid['color']); } $px = (int)round($cx + $run->x * $scale); $py = (int)round($cy + $run->z * $scale); $r->fillCircle($px, $py, 3, [235,220,160]); foreach ($run->enemies as $enemy) { $r->fillCircle((int)round($cx + $enemy['x'] * $scale), (int)round($cy + $enemy['z'] * $scale), $enemy['type'] === 'brute' ? 4 : 2, $enemy['type'] === 'runner' ? [80,210,140] : [210,70,80]); } foreach ($run->pickups as $pickup) { $r->fillCircle((int)round($cx + $pickup['x'] * $scale), (int)round($cy + $pickup['z'] * $scale), 1, $pickup['kind'] === 'health' ? [250,70,80] : [80,235,235]); } $r->drawLine($px, $py, (int)round($px + sin($run->yaw) * 10), (int)round($py + cos($run->yaw) * 10), [255,240,180]); }}