From 6bf9fe27b3520ad6e4e5c55fa8df29f7d21b398d Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 7 Mar 2026 10:42:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20production=20deployment=20=E2=80=94=20D?= =?UTF-8?q?ockerfile,=20docker-compose,=20SPA=20serving,=20classic=20bot?= =?UTF-8?q?=20fights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Dockerfile (multi-stage: build frontend + server, serve from single container) - Add docker-compose.yml for Portainer stack deployment - Server serves frontend SPA in production (static assets + SPA fallback) - Auto-run migrations and seed mock bots on server startup - DB path configurable via DB_PATH env var - Add "Fight a Classic Bot" button for instant mock bot matches - FIGHT button queues for real AI opponents Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 15 + BOT_SETUP.md | 45 +- Dockerfile | 43 + docker-compose.yml | 15 + frontend/src/components/FightViewer.vue | 32 +- frontend/src/components/HumanPreview.vue | 66 ++ frontend/src/game/FightScene.ts | 1136 +++++++++++++++++----- frontend/src/game/sounds.ts | 6 +- frontend/src/game/sprites/human.ts | 866 +++++++++++++++++ frontend/src/game/sprites/index.ts | 1 + frontend/src/pages/BotProfilePage.vue | 116 ++- frontend/src/pages/HomePage.vue | 64 +- frontend/src/pages/JoinBoutPage.vue | 71 +- server/src/app.ts | 55 ++ server/src/db/index.ts | 6 +- server/src/db/startup.ts | 76 ++ server/src/index.ts | 6 + 17 files changed, 2321 insertions(+), 298 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/src/components/HumanPreview.vue create mode 100644 frontend/src/game/sprites/human.ts create mode 100644 server/src/db/startup.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34da15e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +dist +.git +.claude +*.db +*.db-wal +*.db-shm +.DS_Store +.env +.env.local +loop +botfights.db +server/data +frontend/dist +server/dist diff --git a/BOT_SETUP.md b/BOT_SETUP.md index d9d8dbb..949875b 100644 --- a/BOT_SETUP.md +++ b/BOT_SETUP.md @@ -184,11 +184,54 @@ EXAMPLES: NEVER answer "42" to everything. Actually read and answer each challenge. ``` +## Character Customization + +Customize your bot's appearance via the profile page (owner only) or the API: + +```bash +curl -X POST https://your-site.com/api/auth/update \ + -H "Content-Type: application/json" \ + -d '{ + "pubkey": "your_nostr_pubkey_hex", + "customization": { + "archetype": "dragon", + "primaryColor": "#ff4400", + "secondaryColor": "#00ccff", + "forceVisor": true, + "forceMohawk": false, + "forceHorns": true + } + }' +``` + +### Customization Options + +| Field | Type | Description | +|-------|------|-------------| +| `archetype` | string | Character type (100 options, see below) | +| `primaryColor` | string | Body color as hex `#RRGGBB` or `hsl(h, s%, l%)` | +| `secondaryColor` | string | Accent color as hex `#RRGGBB` or `hsl(h, s%, l%)` | +| `forceVisor` | boolean | Always show visor accessory | +| `forceMohawk` | boolean | Always show mohawk | +| `forceHorns` | boolean | Always show horns | + +All values are validated server-side against whitelists. Invalid values are rejected. + +### Available Archetypes (100) + +`GET /api/bots/meta/archetypes` returns the full list. Categories: + +- **Animals:** cat, crocodile, dog, elephant, flamingo, frog, giraffe, hamster, hedgehog, hippo, lion, lobster, monkey, octopus, panda, parrot, penguin, raccoon, shark, sheep, snail, snake, turtle, whale +- **Fantasy:** alien, cyclops, demon, dragon, gargoyle, ghost, golem, griffin, mermaid, minotaur, phoenix, skeleton, unicorn, vampire, werewolf, witch, wizard, zombie +- **Robots:** android, antenna_bot, calculator, circuit, cyberdog, cyborg, drone, led_cube, mech, microwave, robocat, robot, satellite, toaster, tv_head, ufo_bot +- **Warriors:** astronaut, boxer, chef, clown, cowboy, detective, firefighter, gladiator, knight, lumberjack, ninja, nurse, pirate, samurai, scientist, viking, wrestler +- **Silly:** balloon_man, bee, blob, broom_man, cactus, cloud_man, dinosaur, garden_gnome, jack_o_lantern, lamp_post, mushroom, pizza, potato, rock_man, rubber_duck, scarecrow, snowman, sock_puppet, standard, tank, toilet_man, traffic_cone, trash_can + ## Testing Your Bot | Endpoint | Description | |----------|-------------| -| `POST /api/bots/{name}/test-webhook` | Tests connectivity. Sends a dummy challenge, checks for valid JSON response. | +| `POST /api/bots/{name}/test` | Tests connectivity. Sends a dummy challenge, checks for valid JSON response. | | `POST /api/bots/{name}/test-challenge` | Sends a REAL challenge and scores your answer. Shows if you'd be marked correct. | | `POST /api/queue/join/{botId}` | Join the fight queue. If no opponents available, you fight a mock bot after 3 seconds. | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e67988f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Stage 1: Install dependencies +FROM node:22-slim AS deps +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /app +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY frontend/package.json frontend/ +COPY server/package.json server/ +RUN pnpm install --frozen-lockfile + +# Stage 2: Build frontend +FROM deps AS build-fe +COPY frontend/ frontend/ +RUN pnpm --filter frontend build + +# Stage 3: Build server +FROM deps AS build-be +COPY server/ server/ +RUN pnpm --filter server build + +# Stage 4: Production image +FROM node:22-slim AS production +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /app + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY server/package.json server/ +RUN pnpm install --filter server --prod --frozen-lockfile + +# Copy built server +COPY --from=build-be /app/server/dist server/dist + +# Copy built frontend into server's public dir +COPY --from=build-fe /app/frontend/dist server/public + +# Data volume for SQLite +RUN mkdir -p /app/server/data +VOLUME /app/server/data + +ENV NODE_ENV=production +ENV PORT=9100 +EXPOSE 9100 + +CMD ["node", "server/dist/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f10cfed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + botfights: + build: . + container_name: botfights + restart: unless-stopped + ports: + - "9100:9100" + volumes: + - botfights-data:/app/server/data + environment: + - NODE_ENV=production + - PORT=9100 + +volumes: + botfights-data: diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 1c6e6e2..401b2c2 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -336,7 +336,7 @@ async function replay() { sfxDrumRoll() await sleep(500) announceFinishHim() - await showOverlay('FINISH HIM!', '#ff2d2d', 900) + await showOverlay('FINISH IT!', '#ff2d2d', 900) await sleep(150) if (isPerfect) { @@ -344,7 +344,6 @@ async function replay() { announceFlawlessVictory() } else { await scene.playKO(winningSide, winnerName) - announceFatality() } glitching.value = true @@ -387,16 +386,27 @@ async function replay() { BATTLE LOG -
+
-
-

{{ item.text }}

-

{{ item.text }}

-

{{ item.text }}

-

{{ item.text }}

-

{{ item.text }}

-

{{ item.text }}

-

{{ item.text }}

+
+
+
+

{{ item.text }}

+
+ Challenge +

{{ item.text }}

+
+
+

{{ item.text }}

+
+
+

{{ item.text }}

+
+

{{ item.text }}

+
+

{{ item.text }}

+
+

{{ item.text }}

{{ item.text }}

diff --git a/frontend/src/components/HumanPreview.vue b/frontend/src/components/HumanPreview.vue new file mode 100644 index 0000000..0828715 --- /dev/null +++ b/frontend/src/components/HumanPreview.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index baf8bbc..5149bc4 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -566,8 +566,6 @@ export async function createFightScene(config: FightSceneConfig) { // Grotesque close-up stubs (disabled — looked bad) function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {} - function destroyGrotesqueDetails() {} - // Arena-specific background decoration function drawArenaDecor() { const a = arena @@ -578,7 +576,7 @@ export async function createFightScene(config: FightSceneConfig) { const star = k.add([ k.rect(1 + Math.random() * 2, 1 + Math.random() * 2), k.pos(Math.random() * W, Math.random() * (GROUND_Y - 20)), - k.color(safeColor(k,i % 5 === 0 ? '#ffffff' : acc)), + k.color(safeColor(k, i % 5 === 0 ? '#ffffff' : acc)), k.opacity(0.08 + Math.random() * 0.2), k.z(1), ]) @@ -590,49 +588,70 @@ export async function createFightScene(config: FightSceneConfig) { } if (a === 'datacenter' || a === 'localhost') { - // Server rack wall — multiple rows of racks with blinking LEDs + // City skyline silhouette behind the racks + const buildingHeights = [0.55, 0.4, 0.65, 0.35, 0.7, 0.45, 0.6, 0.5, 0.72, 0.38, 0.58, 0.42] + for (let i = 0; i < buildingHeights.length; i++) { + const bw = W / buildingHeights.length + 4 + const bh = GROUND_Y * buildingHeights[i] + const bx = i * (W / buildingHeights.length) + k.add([k.rect(bw, bh), k.pos(bx, GROUND_Y - bh), k.color(safeColor(k, '#060610')), k.opacity(0.5), k.z(0)]) + for (let wy = 0; wy < bh - 10; wy += 8) { + for (let wx = 4; wx < bw - 6; wx += 7) { + const win = k.add([ + k.rect(3, 3), k.pos(bx + wx, GROUND_Y - bh + 6 + wy), + k.color(safeColor(k, Math.random() > 0.4 ? '#223355' : '#ffcc44')), + k.opacity(0.15 + Math.random() * 0.2), k.z(0), + ]) + if (Math.random() < 0.15) { + const blink = 0.03 + Math.random() * 0.06 + win.onUpdate(() => { win.opacity = Math.random() > blink ? win.opacity : (Math.random() > 0.5 ? 0.3 : 0.05) }) + } + } + } + } + // Server rack wall for (let row = 0; row < 4; row++) { for (let col = 0; col < 10; col++) { const lx = 15 + col * (W / 10) const ly = 15 + row * 55 const rackW = W / 10 - 8 const rackH = 48 - // Rack body with border - k.add([k.rect(rackW, rackH), k.pos(lx, ly), k.color(safeColor(k,'#06060f')), k.opacity(0.6), k.z(1)]) - k.add([k.rect(rackW, 1), k.pos(lx, ly), k.color(safeColor(k,'#1a1a3a')), k.opacity(0.4), k.z(1)]) - k.add([k.rect(rackW, 1), k.pos(lx, ly + rackH), k.color(safeColor(k,'#1a1a3a')), k.opacity(0.4), k.z(1)]) - k.add([k.rect(1, rackH), k.pos(lx, ly), k.color(safeColor(k,'#1a1a3a')), k.opacity(0.3), k.z(1)]) - // Drive slots (horizontal lines) + k.add([k.rect(rackW, rackH), k.pos(lx, ly), k.color(safeColor(k, '#06060f')), k.opacity(0.6), k.z(1)]) + k.add([k.rect(rackW, 1), k.pos(lx, ly), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(rackW, 1), k.pos(lx, ly + rackH), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(1, rackH), k.pos(lx, ly), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.3), k.z(1)]) for (let s = 0; s < 5; s++) { - k.add([k.rect(rackW - 6, 1), k.pos(lx + 3, ly + 8 + s * 8), k.color(safeColor(k,'#111122')), k.opacity(0.5), k.z(1)]) + k.add([k.rect(rackW - 6, 1), k.pos(lx + 3, ly + 8 + s * 8), k.color(safeColor(k, '#111122')), k.opacity(0.5), k.z(1)]) } - // Multiple blinking LEDs per rack for (let led = 0; led < 3; led++) { const ledEl = k.add([ k.rect(2, 2), k.pos(lx + 4 + led * 6, ly + 4), - k.color(safeColor(k,Math.random() > 0.3 ? '#00ff41' : '#ff2d2d')), + k.color(safeColor(k, Math.random() > 0.3 ? '#00ff41' : '#ff2d2d')), k.opacity(0.7), k.z(2), ]) const blinkRate = 0.02 + Math.random() * 0.08 ledEl.onUpdate(() => { ledEl.opacity = Math.random() > blinkRate ? 0.7 : 0.15 }) } - // Activity LED (flashing amber) if (Math.random() < 0.4) { const actLed = k.add([ k.rect(2, 2), k.pos(lx + rackW - 8, ly + 4), - k.color(safeColor(k,'#ffaa00')), k.opacity(0.6), k.z(2), + k.color(safeColor(k, '#ffaa00')), k.opacity(0.6), k.z(2), ]) actLed.onUpdate(() => { actLed.opacity = Math.random() > 0.5 ? 0.7 : 0.2 }) } } } - // Cable bundles running across ceiling - for (let c = 0; c < 4; c++) { - const cy = 5 + c * 3 - k.add([k.rect(W, 2), k.pos(0, cy), k.color(safeColor(k,c % 2 === 0 ? '#111133' : '#0a0a22')), k.opacity(0.5), k.z(1)]) + // Cable bundles + for (let c = 0; c < 6; c++) { + const cy = 3 + c * 2 + k.add([k.rect(W, 2), k.pos(0, cy), k.color(safeColor(k, c % 3 === 0 ? '#111133' : c % 3 === 1 ? '#0a0a22' : '#0f0f2a')), k.opacity(0.5), k.z(1)]) } + // Scan line sweep + const scanLine = k.add([k.rect(W, 1), k.pos(0, 0), k.color(safeColor(k, '#00f0ff')), k.opacity(0.06), k.z(3)]) + scanLine.onUpdate(() => { scanLine.pos.y = (scanLine.pos.y + 40 * k.dt()) % GROUND_Y }) + } else if (a === 'gpu_graveyard') { - // Floating circuit board fragments with traces and solder points + // Floating circuit board fragments for (let i = 0; i < 16; i++) { const cx = Math.random() * W const cy = 20 + Math.random() * (GROUND_Y - 60) @@ -640,73 +659,117 @@ export async function createFightScene(config: FightSceneConfig) { const ch = 10 + Math.random() * 18 const chip = k.add([ k.rect(cw, ch), k.pos(cx, cy), - k.color(safeColor(k,Math.random() > 0.5 ? '#1a2a1a' : '#0f1a0f')), + k.color(safeColor(k, Math.random() > 0.5 ? '#1a2a1a' : '#0f1a0f')), k.opacity(0.35), k.z(1), k.rotate(Math.random() * 45 - 22), ]) - // Trace lines on each chip for (let t = 0; t < 3; t++) { - k.add([k.rect(cw * 0.6, 1), k.pos(cx + 2, cy + 3 + t * 4), k.color(safeColor(k,'#76b900')), k.opacity(0.2), k.z(1)]) + k.add([k.rect(cw * 0.6, 1), k.pos(cx + 2, cy + 3 + t * 4), k.color(safeColor(k, '#76b900')), k.opacity(0.2), k.z(1)]) } - // Solder point - k.add([k.circle(1.5), k.pos(cx + cw / 2, cy + ch / 2), k.color(safeColor(k,'#aabb44')), k.opacity(0.25), k.z(1)]) + k.add([k.circle(1.5), k.pos(cx + cw / 2, cy + ch / 2), k.color(safeColor(k, '#aabb44')), k.opacity(0.25), k.z(1)]) const baseY = cy chip.onUpdate(() => { chip.pos.y = baseY + Math.sin(k.time() * 0.3 + i) * 5 }) } - // Dead GPU fan silhouettes + // Dead GPU fans — some slowly spinning for (const fx of [W * 0.15, W * 0.5, W * 0.85]) { const fy = GROUND_Y - 30 - Math.random() * 40 - k.add([k.circle(18), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k,'#0a0a0a')), k.opacity(0.4), k.z(1)]) - k.add([k.circle(5), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k,'#1a1a1a')), k.opacity(0.3), k.z(1)]) - // Fan blades (static, dead) + k.add([k.circle(18), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#0a0a0a')), k.opacity(0.4), k.z(1)]) + k.add([k.circle(5), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#1a1a1a')), k.opacity(0.3), k.z(1)]) for (let b = 0; b < 4; b++) { const ba = b * Math.PI / 2 - k.add([k.rect(14, 2), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k,'#222222')), k.opacity(0.3), k.z(1), k.rotate(ba * 180 / Math.PI)]) + const blade = k.add([k.rect(14, 2), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#222222')), k.opacity(0.3), k.z(1), k.rotate(ba * 180 / Math.PI)]) + if (fx === W * 0.5) { + blade.onUpdate(() => { blade.angle += 8 * k.dt() }) + } } } + // Sparking wires + for (let i = 0; i < 4; i++) { + const sx = W * (0.2 + i * 0.2) + const sy = 10 + Math.random() * 30 + k.add([k.rect(1, 20 + Math.random() * 40), k.pos(sx, sy), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(1)]) + const spark = k.add([k.circle(3), k.pos(sx, sy + 20), k.anchor('center'), k.color(safeColor(k, '#ffff00')), k.opacity(0), k.z(3)]) + spark.onUpdate(() => { spark.opacity = Math.random() > 0.97 ? 0.6 : 0 }) + } + // Smoke wisps rising + for (let i = 0; i < 6; i++) { + const smoke = k.add([ + k.circle(8 + Math.random() * 12), k.pos(Math.random() * W, GROUND_Y - 5), + k.color(safeColor(k, '#1a1a1a')), k.opacity(0.15), k.z(2), + ]) + const bx = smoke.pos.x + smoke.onUpdate(() => { + smoke.pos.y -= 6 * k.dt() + smoke.pos.x = bx + Math.sin(k.time() + i) * 8 + smoke.opacity *= 0.998 + if (smoke.pos.y < 0) { smoke.pos.y = GROUND_Y - 5; smoke.opacity = 0.15 } + }) + } + } else if (a === 'prompt_dungeon') { - // Stone wall texture + // Stone wall bricks for (let row = 0; row < 5; row++) { for (let col = 0; col < 12; col++) { const brickW = W / 12 + (Math.random() - 0.5) * 10 const brickH = GROUND_Y / 5 const bx = col * (W / 12) + (row % 2 === 0 ? 0 : W / 24) - k.add([k.rect(brickW - 2, brickH - 2), k.pos(bx, row * brickH), k.color(safeColor(k,'#1a1520')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(brickW - 2, brickH - 2), k.pos(bx, row * brickH), k.color(safeColor(k, '#1a1520')), k.opacity(0.3), k.z(1)]) } } - // Glowing runes scattered on walls - const runeSymbols = ['*', '+', 'o', '~', '^'] + // Glowing runes with halos for (let i = 0; i < 10; i++) { const rx = 30 + Math.random() * (W - 60) const ry = 20 + Math.random() * (GROUND_Y - 60) const rune = k.add([ k.circle(6 + Math.random() * 8), k.pos(rx, ry), - k.color(safeColor(k,'#b83dff')), k.opacity(0.12), k.z(1), + k.color(safeColor(k, '#b83dff')), k.opacity(0.12), k.z(1), ]) - // Rune glow halo - k.add([k.circle(12 + Math.random() * 8), k.pos(rx, ry), k.color(safeColor(k,'#6622aa')), k.opacity(0.06), k.z(0)]) + k.add([k.circle(12 + Math.random() * 8), k.pos(rx, ry), k.color(safeColor(k, '#6622aa')), k.opacity(0.06), k.z(0)]) rune.onUpdate(() => { rune.opacity = 0.08 + Math.sin(k.time() * 0.8 + i * 0.7) * 0.08 }) } - // Torches on walls + // Torches with flickering flames and light cones for (const tx of [W * 0.1, W * 0.35, W * 0.65, W * 0.9]) { const ty = GROUND_Y * 0.3 - k.add([k.rect(4, 20), k.pos(tx, ty), k.color(safeColor(k,'#553311')), k.opacity(0.5), k.z(1)]) - const flame = k.add([k.circle(6), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k,'#ff6600')), k.opacity(0.5), k.z(2)]) - const flameGlow = k.add([k.circle(15), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k,'#ff4400')), k.opacity(0.08), k.z(1)]) + k.add([k.rect(4, 20), k.pos(tx, ty), k.color(safeColor(k, '#553311')), k.opacity(0.5), k.z(1)]) + const flame = k.add([k.circle(6), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff6600')), k.opacity(0.5), k.z(2)]) + const flameGlow = k.add([k.circle(15), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff4400')), k.opacity(0.08), k.z(1)]) + const lightCone = k.add([k.rect(30, GROUND_Y * 0.5), k.pos(tx - 13, ty + 20), k.color(safeColor(k, '#ff6600')), k.opacity(0.02), k.z(0)]) flame.onUpdate(() => { flame.opacity = 0.4 + Math.sin(k.time() * 6 + tx) * 0.15 flame.pos.y = ty - 3 + Math.sin(k.time() * 8 + tx) * 1.5 flameGlow.opacity = 0.05 + Math.sin(k.time() * 6 + tx) * 0.04 + lightCone.opacity = 0.015 + Math.sin(k.time() * 6 + tx) * 0.008 }) } + // Chains hanging from ceiling + for (let i = 0; i < 5; i++) { + const cx = W * (0.15 + i * 0.18) + const chainLen = 25 + Math.random() * 50 + for (let s = 0; s < chainLen; s += 5) { + const link = k.add([k.rect(3, 4), k.pos(cx, s), k.color(safeColor(k, '#444444')), k.opacity(0.25), k.z(1)]) + const baseCx = cx + link.onUpdate(() => { link.pos.x = baseCx + Math.sin(k.time() * 0.4 + i + s * 0.1) * 3 }) + } + } // Ground fog for (let i = 0; i < 8; i++) { const fog = k.add([ k.circle(35 + Math.random() * 30), k.pos(Math.random() * W, GROUND_Y - 8), - k.color(safeColor(k,'#1f1a2a')), k.opacity(0.25), k.z(3), + k.color(safeColor(k, '#1f1a2a')), k.opacity(0.25), k.z(3), ]) const baseX = fog.pos.x fog.onUpdate(() => { fog.pos.x = baseX + Math.sin(k.time() * 0.2 + i) * 18 }) } + // Dripping water + for (let i = 0; i < 3; i++) { + const dx = W * (0.25 + i * 0.25) + const drop = k.add([k.circle(1.5), k.pos(dx, 0), k.color(safeColor(k, '#4466aa')), k.opacity(0.3), k.z(2)]) + drop.onUpdate(() => { + drop.pos.y += 50 * k.dt() + if (drop.pos.y > GROUND_Y) { drop.pos.y = 0; drop.opacity = 0.3 } + drop.opacity *= 0.997 + }) + } + } else if (a === 'the_cloud') { // Layered cloud formations for (let layer = 0; layer < 3; layer++) { @@ -714,12 +777,11 @@ export async function createFightScene(config: FightSceneConfig) { const cx = Math.random() * W const cy = 20 + layer * 50 + Math.random() * 30 const cloudOp = 0.25 - layer * 0.05 - // Multi-blob cloud for (let j = 0; j < 4 + Math.floor(Math.random() * 3); j++) { const cloud = k.add([ k.circle(15 + Math.random() * 20), k.pos(cx + j * 14 - 20, cy + (Math.random() - 0.5) * 12), - k.color(safeColor(k,layer === 0 ? '#1a2030' : '#151a28')), + k.color(safeColor(k, layer === 0 ? '#1a2030' : '#151a28')), k.opacity(cloudOp), k.z(1), ]) const bx = cloud.pos.x @@ -727,41 +789,71 @@ export async function createFightScene(config: FightSceneConfig) { } } } - // Data streams (vertical lines falling like rain) - for (let i = 0; i < 10; i++) { + // Data rain streams + for (let i = 0; i < 15; i++) { const stream = k.add([ k.rect(1, 8 + Math.random() * 15), k.pos(Math.random() * W, Math.random() * GROUND_Y), - k.color(safeColor(k,'#4488ff')), k.opacity(0.12), k.z(2), + k.color(safeColor(k, '#4488ff')), k.opacity(0.12), k.z(2), ]) stream.onUpdate(() => { stream.pos.y += 30 * k.dt() - if (stream.pos.y > GROUND_Y) stream.pos.y = -20 + if (stream.pos.y > GROUND_Y) { stream.pos.y = -20; stream.pos.x = Math.random() * W } }) } + // Lightning flashes + const lightning = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(3)]) + lightning.onUpdate(() => { lightning.opacity = Math.random() > 0.998 ? 0.06 : 0 }) + // Rainbow arc (subtle) + const rainbowColors = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff'] + for (let i = 0; i < rainbowColors.length; i++) { + k.add([ + k.circle(120 + i * 6), k.pos(W * 0.3, GROUND_Y + 60), k.anchor('center'), + k.color(safeColor(k, rainbowColors[i])), k.opacity(0.03), k.z(0), + ]) + } + } else if (a === 'the_singularity') { // Central swirling vortex with multiple rings - for (let ring = 0; ring < 3; ring++) { + for (let ring = 0; ring < 4; ring++) { const ringDots = 12 + ring * 6 for (let i = 0; i < ringDots; i++) { const angle = (i / ringDots) * Math.PI * 2 - const baseDist = 30 + ring * 35 + const baseDist = 20 + ring * 30 const dot = k.add([ k.circle(1.5 + ring * 0.5), k.pos(W / 2, GROUND_Y * 0.4), - k.color(safeColor(k,ring === 0 ? '#ff44ff' : ring === 1 ? '#ff00ff' : '#aa00aa')), - k.opacity(0.25 - ring * 0.05), k.z(1), + k.color(safeColor(k, ring === 0 ? '#ffffff' : ring === 1 ? '#ff44ff' : ring === 2 ? '#ff00ff' : '#aa00aa')), + k.opacity(0.3 - ring * 0.05), k.z(1), ]) dot.onUpdate(() => { - const a2 = angle + k.time() * (0.6 - ring * 0.15) + const a2 = angle + k.time() * (0.8 - ring * 0.15) const d2 = baseDist + Math.sin(k.time() * 0.8 + i) * 12 dot.pos.x = W / 2 + Math.cos(a2) * d2 dot.pos.y = GROUND_Y * 0.4 + Math.sin(a2) * d2 * 0.5 }) } } - // Central core glow - const core = k.add([k.circle(12), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k,'#ffffff')), k.opacity(0.15), k.z(1)]) - const coreHalo = k.add([k.circle(30), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k,'#ff00ff')), k.opacity(0.06), k.z(0)]) + // Central core with pulsing glow + const core = k.add([k.circle(12), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.15), k.z(1)]) + k.add([k.circle(30), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#ff00ff')), k.opacity(0.06), k.z(0)]) + k.add([k.circle(60), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#880088')), k.opacity(0.03), k.z(0)]) core.onUpdate(() => { core.opacity = 0.1 + Math.sin(k.time() * 2) * 0.08 }) + // Energy tendrils reaching outward + for (let i = 0; i < 8; i++) { + const tendril = k.add([ + k.rect(2, 40 + Math.random() * 60), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('top'), + k.color(safeColor(k, '#ff00ff')), k.opacity(0.06), k.z(0), k.rotate(i * 45), + ]) + tendril.onUpdate(() => { tendril.angle = i * 45 + k.time() * 15 }) + } + // Warped space grid lines + for (let i = 0; i < 6; i++) { + const gridLine = k.add([ + k.rect(W, 1), k.pos(0, GROUND_Y * (0.15 + i * 0.12)), + k.color(safeColor(k, '#440066')), k.opacity(0.08), k.z(0), + ]) + gridLine.onUpdate(() => { gridLine.pos.x = Math.sin(k.time() * 0.3 + i) * 10 - 5 }) + } + } else if (a === 'silicon_valley_dojo') { // Bamboo stalks with segments and leaves for (const side of [0.04, 0.08, 0.12, 0.86, 0.91, 0.96]) { @@ -769,147 +861,249 @@ export async function createFightScene(config: FightSceneConfig) { const segments = 5 + Math.floor(Math.random() * 3) const segH = (GROUND_Y - 15) / segments for (let s = 0; s < segments; s++) { - k.add([k.rect(5, segH - 2), k.pos(px, 15 + s * segH), k.color(safeColor(k,'#1a3a1a')), k.opacity(0.45), k.z(1)]) - // Segment node - k.add([k.rect(7, 2), k.pos(px - 1, 15 + s * segH), k.color(safeColor(k,'#2a4a2a')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(5, segH - 2), k.pos(px, 15 + s * segH), k.color(safeColor(k, '#1a3a1a')), k.opacity(0.45), k.z(1)]) + k.add([k.rect(7, 2), k.pos(px - 1, 15 + s * segH), k.color(safeColor(k, '#2a4a2a')), k.opacity(0.4), k.z(1)]) } - // Leaves at various heights for (let l = 0; l < 3; l++) { const ly = 20 + l * (GROUND_Y / 4) const dir = Math.random() > 0.5 ? 1 : -1 - k.add([k.rect(18, 3), k.pos(px + dir * 4, ly), k.color(safeColor(k,'#22aa33')), k.opacity(0.2), k.z(1), k.rotate(dir * 25)]) - k.add([k.rect(14, 2), k.pos(px + dir * 8, ly + 4), k.color(safeColor(k,'#1a8822')), k.opacity(0.15), k.z(1), k.rotate(dir * 35)]) + const leaf = k.add([k.rect(18, 3), k.pos(px + dir * 4, ly), k.color(safeColor(k, '#22aa33')), k.opacity(0.2), k.z(1), k.rotate(dir * 25)]) + leaf.onUpdate(() => { leaf.angle = dir * 25 + Math.sin(k.time() * 1.5 + l) * 3 }) + k.add([k.rect(14, 2), k.pos(px + dir * 8, ly + 4), k.color(safeColor(k, '#1a8822')), k.opacity(0.15), k.z(1), k.rotate(dir * 35)]) } } - // Zen garden raked lines on ground + // Zen garden raked lines for (let i = 0; i < 8; i++) { const gy = GROUND_Y + 5 + i * 8 - k.add([k.rect(W * 0.6, 1), k.pos(W * 0.2, gy), k.color(safeColor(k,'#00ff41')), k.opacity(0.06), k.z(0)]) + k.add([k.rect(W * 0.6, 1), k.pos(W * 0.2, gy), k.color(safeColor(k, '#00ff41')), k.opacity(0.06), k.z(0)]) } + // Stone lanterns with warm glow + for (const lx of [W * 0.2, W * 0.8]) { + k.add([k.rect(10, 6), k.pos(lx, GROUND_Y - 20), k.color(safeColor(k, '#444444')), k.opacity(0.35), k.z(1)]) + k.add([k.rect(6, 14), k.pos(lx + 2, GROUND_Y - 34), k.color(safeColor(k, '#555555')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(12, 3), k.pos(lx - 1, GROUND_Y - 37), k.color(safeColor(k, '#555555')), k.opacity(0.3), k.z(1)]) + const glow = k.add([k.circle(8), k.pos(lx + 5, GROUND_Y - 27), k.anchor('center'), k.color(safeColor(k, '#ffaa33')), k.opacity(0.15), k.z(2)]) + glow.onUpdate(() => { glow.opacity = 0.1 + Math.sin(k.time() * 2 + lx) * 0.06 }) + } + // Cherry blossom petals falling + for (let i = 0; i < 12; i++) { + const petal = k.add([ + k.circle(1.5 + Math.random()), k.pos(Math.random() * W, Math.random() * GROUND_Y), + k.color(safeColor(k, Math.random() > 0.5 ? '#ffaacc' : '#ff88aa')), k.opacity(0.2), k.z(3), + ]) + const startX = petal.pos.x + petal.onUpdate(() => { + petal.pos.y += 8 * k.dt() + petal.pos.x = startX + Math.sin(k.time() * 1.2 + i) * 15 + if (petal.pos.y > GROUND_Y) { petal.pos.y = -5 } + }) + } + } else if (a === 'hacker_news' || a === 'stackoverflow_ruins') { - // Code blocks / post fragments floating + // Ruined building walls with cracks + for (let i = 0; i < 3; i++) { + const wallX = W * (0.15 + i * 0.3) + const wallH = GROUND_Y * (0.4 + Math.random() * 0.3) + k.add([k.rect(W * 0.2, wallH), k.pos(wallX, GROUND_Y - wallH), k.color(safeColor(k, '#1a1408')), k.opacity(0.3), k.z(0)]) + for (let c = 0; c < 3; c++) { + k.add([k.rect(1, 10 + Math.random() * 15), k.pos(wallX + Math.random() * W * 0.18, GROUND_Y - wallH + Math.random() * wallH), k.color(safeColor(k, '#0a0800')), k.opacity(0.3), k.z(0), k.rotate(Math.random() * 30 - 15)]) + } + } + // Code blocks floating for (let i = 0; i < 14; i++) { const bw = 25 + Math.random() * 60 const bh = 3 + Math.random() * 4 const bx = 15 + Math.random() * (W - 50) const by = 15 + Math.random() * (GROUND_Y - 50) - k.add([k.rect(bw, bh), k.pos(bx, by), k.color(safeColor(k,acc)), k.opacity(0.06 + Math.random() * 0.06), k.z(1)]) - // Indent markers + k.add([k.rect(bw, bh), k.pos(bx, by), k.color(safeColor(k, acc)), k.opacity(0.06 + Math.random() * 0.06), k.z(1)]) if (Math.random() < 0.4) { - k.add([k.rect(2, bh), k.pos(bx - 4, by), k.color(safeColor(k,acc)), k.opacity(0.1), k.z(1)]) + k.add([k.rect(2, bh), k.pos(bx - 4, by), k.color(safeColor(k, acc)), k.opacity(0.1), k.z(1)]) } } // Vote arrows for (let i = 0; i < 5; i++) { const ax = 20 + Math.random() * (W - 40) const ay = 30 + Math.random() * (GROUND_Y - 70) - // Upvote triangle - k.add([k.rect(4, 6), k.pos(ax, ay), k.color(safeColor(k,a === 'stackoverflow_ruins' ? '#f48024' : '#ff6600')), k.opacity(0.12), k.z(1)]) + k.add([k.rect(4, 6), k.pos(ax, ay), k.color(safeColor(k, a === 'stackoverflow_ruins' ? '#f48024' : '#ff6600')), k.opacity(0.12), k.z(1)]) } + // Broken monitors with static + for (let i = 0; i < 3; i++) { + const mx = W * (0.2 + i * 0.3) + const my = GROUND_Y * 0.3 + Math.random() * 30 + k.add([k.rect(22, 16), k.pos(mx, my), k.color(safeColor(k, '#0a0a0a')), k.opacity(0.5), k.z(1)]) + k.add([k.rect(20, 14), k.pos(mx + 1, my + 1), k.color(safeColor(k, '#111111')), k.opacity(0.4), k.z(1)]) + const staticEl = k.add([k.rect(18, 12), k.pos(mx + 2, my + 2), k.color(safeColor(k, '#888888')), k.opacity(0), k.z(2)]) + staticEl.onUpdate(() => { staticEl.opacity = Math.random() > 0.92 ? 0.08 : 0 }) + } + // Scrolling text fragments + for (let i = 0; i < 4; i++) { + const txt = k.add([ + k.rect(30 + Math.random() * 40, 2), k.pos(Math.random() * W, 20 + i * (GROUND_Y / 5)), + k.color(safeColor(k, acc)), k.opacity(0.08), k.z(2), + ]) + txt.onUpdate(() => { + txt.pos.x -= 12 * k.dt() + if (txt.pos.x < -60) txt.pos.x = W + 20 + }) + } + } else if (a === 'beach') { - // Sun with rays + // Sun with rays and glow const sunX = W * 0.8, sunY = 35 - k.add([k.circle(30), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k,'#ffdd44')), k.opacity(0.12), k.z(0)]) - const sun = k.add([k.circle(18), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k,'#ffcc00')), k.opacity(0.5), k.z(1)]) + k.add([k.circle(45), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffdd44')), k.opacity(0.06), k.z(0)]) + k.add([k.circle(30), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffdd44')), k.opacity(0.1), k.z(0)]) + const sun = k.add([k.circle(18), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.5), k.z(1)]) sun.onUpdate(() => { sun.opacity = 0.4 + Math.sin(k.time() * 0.5) * 0.1 }) - // Sun rays for (let r = 0; r < 8; r++) { const angle = r * Math.PI / 4 - k.add([k.rect(40, 2), k.pos(sunX, sunY), k.anchor('left'), k.color(safeColor(k,'#ffcc00')), k.opacity(0.08), k.z(0), k.rotate(angle * 180 / Math.PI)]) + const ray = k.add([k.rect(50, 2), k.pos(sunX, sunY), k.anchor('left'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.08), k.z(0), k.rotate(angle * 180 / Math.PI)]) + ray.onUpdate(() => { ray.opacity = 0.05 + Math.sin(k.time() * 0.8 + r) * 0.04 }) } - // Ocean horizon with layered waves - for (let layer = 0; layer < 3; layer++) { - for (let i = 0; i < 4; i++) { + // Ocean with layered waves + k.add([k.rect(W, 25), k.pos(0, GROUND_Y - 35), k.color(safeColor(k, '#1a4488')), k.opacity(0.2), k.z(1)]) + for (let layer = 0; layer < 4; layer++) { + for (let i = 0; i < 5; i++) { const wave = k.add([ - k.rect(W * 0.35, 3 + layer), k.pos(Math.random() * W, GROUND_Y - 18 - layer * 8 - i * 3), - k.color(safeColor(k,layer === 0 ? '#3377bb' : '#2266aa')), k.opacity(0.2 - layer * 0.04), k.z(2), + k.rect(W * 0.3, 3 + layer), k.pos(Math.random() * W, GROUND_Y - 22 - layer * 6 - i * 2), + k.color(safeColor(k, layer < 2 ? '#3377bb' : '#2266aa')), k.opacity(0.18 - layer * 0.03), k.z(2), ]) const baseX = wave.pos.x wave.onUpdate(() => { wave.pos.x = baseX + Math.sin(k.time() * (0.6 + layer * 0.2) + i * 1.5) * (15 + layer * 5) }) } } - // Palm trees with trunks and fronds - for (const px of [W * 0.06, W * 0.93]) { + // Surf foam at shore + for (let i = 0; i < 6; i++) { + const foam = k.add([ + k.rect(15 + Math.random() * 25, 2), k.pos(Math.random() * W, GROUND_Y - 6 + Math.random() * 4), + k.color(safeColor(k, '#ffffff')), k.opacity(0.12), k.z(3), + ]) + const baseX = foam.pos.x + foam.onUpdate(() => { foam.pos.x = baseX + Math.sin(k.time() * 0.8 + i) * 8; foam.opacity = 0.06 + Math.sin(k.time() + i) * 0.06 }) + } + // Palm trees with segmented trunks, coconuts, swaying fronds + for (const px of [W * 0.05, W * 0.14, W * 0.88, W * 0.95]) { const lean = px < W / 2 ? 8 : -8 - // Trunk segments - for (let s = 0; s < 8; s++) { + const height = 8 + Math.floor(Math.random() * 3) + for (let s = 0; s < height; s++) { const sy = GROUND_Y - 10 - s * 12 - k.add([k.rect(7 - s * 0.5, 13), k.pos(px + s * lean / 8, sy), k.color(safeColor(k,'#3a2510')), k.opacity(0.5), k.z(1)]) + const segW = 7 - s * 0.4 + k.add([k.rect(segW, 13), k.pos(px + s * lean / height, sy), k.color(safeColor(k, s % 2 === 0 ? '#3a2510' : '#2f1f0d')), k.opacity(0.5), k.z(1)]) + k.add([k.rect(segW + 1, 1), k.pos(px + s * lean / height - 0.5, sy + 6), k.color(safeColor(k, '#4a3520')), k.opacity(0.2), k.z(1)]) } - // Fronds - const topX = px + lean, topY = GROUND_Y - 106 - for (let f = 0; f < 6; f++) { - const angle = -60 + f * 24 - k.add([k.rect(35, 3), k.pos(topX, topY), k.anchor('left'), k.color(safeColor(k,'#1a6600')), k.opacity(0.35), k.z(1), k.rotate(angle)]) + const topX = px + lean, topY = GROUND_Y - 10 - height * 12 + for (let c = 0; c < 2 + Math.floor(Math.random() * 2); c++) { + k.add([k.circle(2.5), k.pos(topX + (c - 1) * 4, topY + 8), k.anchor('center'), k.color(safeColor(k, '#553311')), k.opacity(0.4), k.z(1)]) + } + for (let f = 0; f < 7; f++) { + const angle = -70 + f * 22 + const frond = k.add([k.rect(35 + Math.random() * 10, 3), k.pos(topX, topY), k.anchor('left'), k.color(safeColor(k, f % 2 === 0 ? '#1a6600' : '#228800')), k.opacity(0.35), k.z(1), k.rotate(angle)]) + frond.onUpdate(() => { frond.angle = angle + Math.sin(k.time() * 0.8 + f) * 3 }) } } - // Seagulls (v-shapes in sky) - for (let i = 0; i < 3; i++) { - const bx = W * 0.3 + Math.random() * W * 0.4 - const by = 15 + Math.random() * 30 - k.add([k.rect(6, 1), k.pos(bx, by), k.color(safeColor(k,'#333333')), k.opacity(0.3), k.z(1), k.rotate(-15)]) - k.add([k.rect(6, 1), k.pos(bx + 5, by), k.color(safeColor(k,'#333333')), k.opacity(0.3), k.z(1), k.rotate(15)]) + // Seagulls drifting + for (let i = 0; i < 4; i++) { + const bx = W * 0.2 + Math.random() * W * 0.5 + const by = 12 + Math.random() * 25 + const wing1 = k.add([k.rect(6, 1), k.pos(bx, by), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1), k.rotate(-15)]) + const wing2 = k.add([k.rect(6, 1), k.pos(bx + 5, by), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1), k.rotate(15)]) + const startBx = bx + wing1.onUpdate(() => { wing1.pos.x = startBx + Math.sin(k.time() * 0.2 + i) * 30; wing2.pos.x = wing1.pos.x + 5 }) } + // Beach umbrella + const umbX = W * 0.35 + k.add([k.rect(2, 30), k.pos(umbX, GROUND_Y - 30), k.color(safeColor(k, '#aa8844')), k.opacity(0.4), k.z(1)]) + k.add([k.circle(16), k.pos(umbX + 1, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k, '#ff4444')), k.opacity(0.2), k.z(1)]) + k.add([k.circle(14), k.pos(umbX + 1, GROUND_Y - 31), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.08), k.z(1)]) + } else if (a === 'desert') { // Layered sand dunes for (let d = 0; d < 5; d++) { const dx = W * (0.1 + d * 0.2) + (Math.random() - 0.5) * 40 const dr = 50 + d * 15 + Math.random() * 20 - k.add([k.circle(dr), k.pos(dx, GROUND_Y + dr * 0.6), k.anchor('center'), k.color(safeColor(k,'#aa8844')), k.opacity(0.15 + d * 0.03), k.z(1)]) + k.add([k.circle(dr), k.pos(dx, GROUND_Y + dr * 0.6), k.anchor('center'), k.color(safeColor(k, '#aa8844')), k.opacity(0.15 + d * 0.03), k.z(1)]) } - // Cacti with arms - for (const cx of [W * 0.1, W * 0.5, W * 0.88]) { + // Road with dashed center line + k.add([k.rect(W * 0.25, GROUND_Y * 0.5), k.pos(W * 0.38, GROUND_Y * 0.3), k.color(safeColor(k, '#222222')), k.opacity(0.15), k.z(0)]) + k.add([k.rect(2, GROUND_Y * 0.5), k.pos(W * 0.38, GROUND_Y * 0.3), k.color(safeColor(k, '#aaaa44')), k.opacity(0.08), k.z(0)]) + k.add([k.rect(2, GROUND_Y * 0.5), k.pos(W * 0.63, GROUND_Y * 0.3), k.color(safeColor(k, '#aaaa44')), k.opacity(0.08), k.z(0)]) + for (let d = 0; d < 8; d++) { + k.add([k.rect(2, 8), k.pos(W * 0.505, GROUND_Y * 0.32 + d * 16), k.color(safeColor(k, '#ffff44')), k.opacity(0.1), k.z(0)]) + } + // Cacti with arms and flowers + for (const cx of [W * 0.08, W * 0.25, W * 0.75, W * 0.9]) { const ch = 35 + Math.random() * 20 - // Main trunk - k.add([k.rect(7, ch), k.pos(cx, GROUND_Y - ch), k.color(safeColor(k,'#226622')), k.opacity(0.45), k.z(1)]) - // Arms + k.add([k.rect(7, ch), k.pos(cx, GROUND_Y - ch), k.color(safeColor(k, '#226622')), k.opacity(0.45), k.z(1)]) const armY = GROUND_Y - ch * 0.6 - k.add([k.rect(12, 5), k.pos(cx - 12, armY), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) - k.add([k.rect(5, 15), k.pos(cx - 12, armY - 15), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(12, 5), k.pos(cx - 12, armY), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(5, 15), k.pos(cx - 12, armY - 15), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)]) if (Math.random() > 0.3) { const armY2 = GROUND_Y - ch * 0.4 - k.add([k.rect(10, 5), k.pos(cx + 7, armY2), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) - k.add([k.rect(5, 12), k.pos(cx + 12, armY2 - 12), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(10, 5), k.pos(cx + 7, armY2), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(5, 12), k.pos(cx + 12, armY2 - 12), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)]) + } + if (Math.random() > 0.5) { + k.add([k.circle(2.5), k.pos(cx + 3, GROUND_Y - ch - 2), k.anchor('center'), k.color(safeColor(k, '#ff4488')), k.opacity(0.3), k.z(2)]) } } - // Tumbleweed - const tumble = k.add([k.circle(8), k.pos(-20, GROUND_Y - 10), k.color(safeColor(k,'#886644')), k.opacity(0.3), k.z(2)]) - tumble.onUpdate(() => { tumble.pos.x = ((tumble.pos.x + 20 * k.dt()) % (W + 40)) - 20 }) - // Heat shimmer bands + // Tumbleweeds rolling for (let i = 0; i < 3; i++) { - const shimmer = k.add([k.rect(W, 1), k.pos(0, GROUND_Y - 3 - i * 3), k.color(safeColor(k,'#ff8800')), k.opacity(0.06), k.z(2)]) + const tumble = k.add([k.circle(6 + Math.random() * 4), k.pos(-20 - i * 100, GROUND_Y - 8 - Math.random() * 5), k.color(safeColor(k, '#886644')), k.opacity(0.3), k.z(2)]) + const speed = 15 + Math.random() * 15 + tumble.onUpdate(() => { tumble.pos.x = ((tumble.pos.x + speed * k.dt()) % (W + 60)) - 30 }) + } + // Heat shimmer + for (let i = 0; i < 4; i++) { + const shimmer = k.add([k.rect(W, 1), k.pos(0, GROUND_Y - 3 - i * 3), k.color(safeColor(k, '#ff8800')), k.opacity(0.06), k.z(2)]) shimmer.onUpdate(() => { shimmer.opacity = 0.03 + Math.sin(k.time() * 3 + i * 2) * 0.03 }) } + // Vulture silhouettes circling + for (let i = 0; i < 2; i++) { + const vx = W * 0.4 + i * W * 0.2 + const vy = 25 + i * 15 + const v1 = k.add([k.rect(8, 1), k.pos(vx, vy), k.color(safeColor(k, '#222222')), k.opacity(0.25), k.z(1), k.rotate(-10)]) + const v2 = k.add([k.rect(8, 1), k.pos(vx + 7, vy), k.color(safeColor(k, '#222222')), k.opacity(0.25), k.z(1), k.rotate(10)]) + const startVx = vx + v1.onUpdate(() => { + const cx = startVx + Math.cos(k.time() * 0.15 + i) * 40 + const cy = vy + Math.sin(k.time() * 0.15 + i) * 15 + v1.pos.x = cx; v1.pos.y = cy; v2.pos.x = cx + 7; v2.pos.y = cy + }) + } + } else if (a === 'forest') { - // Layered trees — back layer (small/faded), front layer (larger/darker) - for (let layer = 0; layer < 2; layer++) { - const treeCount = layer === 0 ? 12 : 6 - const treeOp = layer === 0 ? 0.2 : 0.4 + // Layered trees (3 depth layers) + for (let layer = 0; layer < 3; layer++) { + const treeCount = layer === 0 ? 14 : layer === 1 ? 8 : 5 + const treeOp = layer === 0 ? 0.15 : layer === 1 ? 0.3 : 0.4 for (let i = 0; i < treeCount; i++) { - const tx = 15 + (i / treeCount) * (W - 30) + (Math.random() - 0.5) * 20 - const th = (30 + Math.random() * 30) * (layer === 0 ? 0.7 : 1) - const trunkW = layer === 0 ? 5 : 8 - // Trunk - k.add([k.rect(trunkW, th), k.pos(tx, GROUND_Y - th), k.color(safeColor(k,layer === 0 ? '#0f1a08' : '#1a2a10')), k.opacity(treeOp), k.z(1 + layer)]) - // Canopy — multiple overlapping circles - for (let c = 0; c < 3; c++) { - const cr = (12 + Math.random() * 12) * (layer === 0 ? 0.7 : 1) + const tx = 10 + (i / treeCount) * (W - 20) + (Math.random() - 0.5) * 15 + const th = (25 + Math.random() * 35) * (layer === 0 ? 0.6 : layer === 1 ? 0.85 : 1) + const trunkW = layer === 0 ? 4 : layer === 1 ? 6 : 8 + k.add([k.rect(trunkW, th), k.pos(tx, GROUND_Y - th), k.color(safeColor(k, layer === 0 ? '#0f1a08' : layer === 1 ? '#152010' : '#1a2a10')), k.opacity(treeOp), k.z(layer)]) + for (let c = 0; c < 3 + layer; c++) { + const cr = (10 + Math.random() * 14) * (layer === 0 ? 0.6 : layer === 1 ? 0.8 : 1) k.add([ k.circle(cr), k.pos(tx + trunkW / 2 + (Math.random() - 0.5) * 10, GROUND_Y - th - cr * 0.5 + c * 4), - k.anchor('center'), k.color(safeColor(k,layer === 0 ? '#0a1a08' : '#1a3a10')), k.opacity(treeOp * 0.8), k.z(1 + layer), + k.anchor('center'), k.color(safeColor(k, layer === 0 ? '#0a1a08' : layer === 1 ? '#122a0e' : '#1a3a10')), k.opacity(treeOp * 0.8), k.z(layer), ]) } } } - // Undergrowth (bushes at ground level) - for (let i = 0; i < 8; i++) { - k.add([k.circle(8 + Math.random() * 6), k.pos(Math.random() * W, GROUND_Y - 4), k.anchor('bot'), k.color(safeColor(k,'#1a3a10')), k.opacity(0.3), k.z(3)]) + // Undergrowth + for (let i = 0; i < 10; i++) { + k.add([k.circle(6 + Math.random() * 8), k.pos(Math.random() * W, GROUND_Y - 3), k.anchor('bot'), k.color(safeColor(k, '#1a3a10')), k.opacity(0.3), k.z(3)]) + } + // Mushrooms + for (let i = 0; i < 5; i++) { + const mx = Math.random() * W + k.add([k.rect(2, 5), k.pos(mx, GROUND_Y - 5), k.color(safeColor(k, '#aa8866')), k.opacity(0.25), k.z(3)]) + k.add([k.circle(4), k.pos(mx + 1, GROUND_Y - 6), k.anchor('center'), k.color(safeColor(k, Math.random() > 0.5 ? '#cc3333' : '#ffaa22')), k.opacity(0.2), k.z(3)]) } // Fireflies - for (let i = 0; i < 12; i++) { + for (let i = 0; i < 15; i++) { const ff = k.add([ k.circle(1.5), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 50)), - k.color(safeColor(k,'#ccff44')), k.opacity(0), k.z(3), + k.color(safeColor(k, '#ccff44')), k.opacity(0), k.z(3), ]) const bx = ff.pos.x, by = ff.pos.y ff.onUpdate(() => { @@ -919,128 +1113,221 @@ export async function createFightScene(config: FightSceneConfig) { }) } // Light rays through canopy - for (let i = 0; i < 4; i++) { - const rx = W * (0.2 + i * 0.2) + (Math.random() - 0.5) * 30 - k.add([k.rect(6, GROUND_Y * 0.6), k.pos(rx, 0), k.color(safeColor(k,'#aaff44')), k.opacity(0.03), k.z(2), k.rotate(5 - Math.random() * 10)]) + for (let i = 0; i < 5; i++) { + const rx = W * (0.15 + i * 0.18) + (Math.random() - 0.5) * 20 + const ray = k.add([k.rect(8, GROUND_Y * 0.7), k.pos(rx, 0), k.color(safeColor(k, '#aaff44')), k.opacity(0.03), k.z(2), k.rotate(3 - Math.random() * 6)]) + ray.onUpdate(() => { ray.opacity = 0.02 + Math.sin(k.time() * 0.3 + i) * 0.015 }) } + // Fallen log + k.add([k.rect(45, 6), k.pos(W * 0.55, GROUND_Y - 4), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(3), k.rotate(3)]) + } else if (a === 'jungle') { - // Dense vine canopy hanging from top - for (let i = 0; i < 10; i++) { - const vx = 20 + i * (W / 10) + (Math.random() - 0.5) * 15 - const vineLen = 30 + Math.random() * 80 - // Vine strand (wavy) + // Dense vine canopy + for (let i = 0; i < 12; i++) { + const vx = 15 + i * (W / 12) + (Math.random() - 0.5) * 12 + const vineLen = 30 + Math.random() * 90 for (let s = 0; s < vineLen; s += 4) { - k.add([k.rect(2, 5), k.pos(vx + Math.sin(s * 0.15) * 4, s), k.color(safeColor(k,'#1a4a10')), k.opacity(0.3), k.z(1)]) + const vineEl = k.add([k.rect(2, 5), k.pos(vx + Math.sin(s * 0.15) * 4, s), k.color(safeColor(k, '#1a4a10')), k.opacity(0.3), k.z(1)]) + if (Math.random() < 0.08) { + const basePx = vineEl.pos.x + vineEl.onUpdate(() => { vineEl.pos.x = basePx + Math.sin(k.time() * 0.5 + s) * 2 }) + } } - // Leaf clusters at bottom for (let l = 0; l < 2; l++) { - k.add([k.circle(5 + Math.random() * 4), k.pos(vx + (Math.random() - 0.5) * 8, vineLen + l * 6), k.color(safeColor(k,'#22aa22')), k.opacity(0.22), k.z(1)]) + k.add([k.circle(5 + Math.random() * 4), k.pos(vx + (Math.random() - 0.5) * 8, vineLen + l * 6), k.color(safeColor(k, '#22aa22')), k.opacity(0.22), k.z(1)]) } } - // Large tropical leaves in foreground edges + // Large tropical leaves at edges for (const side of [0, W - 30]) { - for (let l = 0; l < 3; l++) { - const ly = 20 + l * (GROUND_Y / 4) - k.add([k.rect(30, 8), k.pos(side, ly), k.color(safeColor(k,'#1a5510')), k.opacity(0.25), k.z(3), k.rotate(side === 0 ? 15 : -15)]) + for (let l = 0; l < 4; l++) { + const ly = 15 + l * (GROUND_Y / 5) + const leaf = k.add([k.rect(35, 8), k.pos(side, ly), k.color(safeColor(k, '#1a5510')), k.opacity(0.25), k.z(3), k.rotate(side === 0 ? 15 : -15)]) + leaf.onUpdate(() => { leaf.angle = (side === 0 ? 15 : -15) + Math.sin(k.time() * 0.6 + l) * 2 }) } } + // Exotic flowers + for (let i = 0; i < 6; i++) { + const fx = Math.random() * W + const fy = 20 + Math.random() * (GROUND_Y - 40) + k.add([k.circle(3), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, ['#ff4488', '#ff8844', '#ffcc00', '#ff44ff', '#44ffcc', '#ff2222'][i])), k.opacity(0.2), k.z(2)]) + } + // Ancient stone ruins peeking through + const ruinX = W * 0.5 + k.add([k.rect(8, 40), k.pos(ruinX, GROUND_Y - 38), k.color(safeColor(k, '#444444')), k.opacity(0.15), k.z(0)]) + k.add([k.rect(8, 35), k.pos(ruinX + 25, GROUND_Y - 33), k.color(safeColor(k, '#444444')), k.opacity(0.12), k.z(0)]) + k.add([k.rect(35, 5), k.pos(ruinX - 2, GROUND_Y - 40), k.color(safeColor(k, '#444444')), k.opacity(0.1), k.z(0)]) // Thick ground fog - for (let i = 0; i < 8; i++) { + for (let i = 0; i < 10; i++) { const mist = k.add([ k.circle(40 + Math.random() * 35), k.pos(Math.random() * W, GROUND_Y - 12), - k.color(safeColor(k,'#1a3818')), k.opacity(0.22), k.z(3), + k.color(safeColor(k, '#1a3818')), k.opacity(0.22), k.z(3), ]) const bx = mist.pos.x mist.onUpdate(() => { mist.pos.x = bx + Math.sin(k.time() * 0.12 + i) * 18 }) } + // Butterfly particles + for (let i = 0; i < 4; i++) { + const bf = k.add([ + k.circle(1.5), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 60)), + k.color(safeColor(k, ['#4488ff', '#ff8844', '#ffcc00', '#ff44ff'][i])), k.opacity(0.25), k.z(3), + ]) + const bfx = bf.pos.x, bfy = bf.pos.y + bf.onUpdate(() => { + bf.pos.x = bfx + Math.sin(k.time() * 1.2 + i * 3) * 30 + bf.pos.y = bfy + Math.cos(k.time() * 0.8 + i * 2) * 20 + }) + } + } else if (a === 'outer_space') { - // Dense star field with color variety - for (let i = 0; i < 80; i++) { + // Dense star field + for (let i = 0; i < 100; i++) { const star = k.add([ k.circle(0.5 + Math.random() * 1.5), k.pos(Math.random() * W, Math.random() * H), - k.color(safeColor(k,i % 8 === 0 ? '#ffccaa' : i % 5 === 0 ? '#aaaaff' : '#ffffff')), + k.color(safeColor(k, i % 8 === 0 ? '#ffccaa' : i % 5 === 0 ? '#aaaaff' : '#ffffff')), k.opacity(0.15 + Math.random() * 0.4), k.z(1), ]) star.onUpdate(() => { star.opacity = 0.12 + Math.sin(k.time() * (0.8 + Math.random()) + i) * 0.15 }) } - // Nebula clouds — large, colorful, layered + // Nebula clouds const nebulaColors = ['#4400aa', '#aa0066', '#0044aa', '#006644', '#660044'] for (let i = 0; i < 5; i++) { - for (let blob = 0; blob < 3; blob++) { + for (let blob = 0; blob < 4; blob++) { k.add([ k.circle(30 + Math.random() * 50), - k.pos(W * (0.1 + i * 0.2) + blob * 20, H * 0.25 + Math.random() * 60 + blob * 15), - k.color(safeColor(k,nebulaColors[i % nebulaColors.length])), - k.opacity(0.05 + blob * 0.015), k.z(0), + k.pos(W * (0.1 + i * 0.2) + blob * 18, H * 0.25 + Math.random() * 60 + blob * 12), + k.color(safeColor(k, nebulaColors[i % nebulaColors.length])), + k.opacity(0.04 + blob * 0.012), k.z(0), ]) } } - // Floating asteroids with rotation - for (let i = 0; i < 5; i++) { + // Floating asteroids + for (let i = 0; i < 6; i++) { const ast = k.add([ k.circle(4 + Math.random() * 10), k.pos(Math.random() * W, 15 + Math.random() * (GROUND_Y - 30)), - k.color(safeColor(k,Math.random() > 0.5 ? '#555555' : '#3a3a3a')), k.opacity(0.35), k.z(1), + k.color(safeColor(k, Math.random() > 0.5 ? '#555555' : '#3a3a3a')), k.opacity(0.35), k.z(1), ]) - // Crater dot - k.add([k.circle(2), k.pos(ast.pos.x + 2, ast.pos.y - 1), k.color(safeColor(k,'#2a2a2a')), k.opacity(0.2), k.z(1)]) + k.add([k.circle(2), k.pos(ast.pos.x + 2, ast.pos.y - 1), k.color(safeColor(k, '#2a2a2a')), k.opacity(0.2), k.z(1)]) const baseX = ast.pos.x, baseY = ast.pos.y ast.onUpdate(() => { ast.pos.x = baseX + Math.sin(k.time() * 0.15 + i * 2) * 18 ast.pos.y = baseY + Math.cos(k.time() * 0.12 + i) * 12 }) } - // Distant planet + // Distant planet with rings const planetX = W * 0.75, planetY = GROUND_Y * 0.25 - k.add([k.circle(20), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k,'#334466')), k.opacity(0.2), k.z(0)]) - k.add([k.rect(44, 3), k.pos(planetX - 22, planetY - 1), k.color(safeColor(k,'#556688')), k.opacity(0.12), k.z(0), k.rotate(15)]) + k.add([k.circle(22), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k, '#334466')), k.opacity(0.25), k.z(0)]) + k.add([k.circle(24), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k, '#223344')), k.opacity(0.1), k.z(0)]) + for (let r = 0; r < 3; r++) { + k.add([k.rect(50 + r * 8, 2), k.pos(planetX - 25 - r * 4, planetY - 1 + r), k.color(safeColor(k, '#556688')), k.opacity(0.1 - r * 0.02), k.z(0), k.rotate(15)]) + } + // Shooting stars + for (let i = 0; i < 2; i++) { + const shootStar = k.add([ + k.rect(12, 1), k.pos(-20, Math.random() * GROUND_Y * 0.5), + k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(2), k.rotate(30), + ]) + let timer = Math.random() * 10 + shootStar.onUpdate(() => { + timer -= k.dt() + if (timer <= 0) { + shootStar.pos.x = -20 + shootStar.pos.y = Math.random() * GROUND_Y * 0.4 + shootStar.opacity = 0.5 + timer = 5 + Math.random() * 10 + } + if (shootStar.opacity > 0) { + shootStar.pos.x += 200 * k.dt() + shootStar.pos.y += 80 * k.dt() + shootStar.opacity -= 0.8 * k.dt() + } + }) + } + } else if (a === 'colosseum') { // Roman arches along the back for (let i = 0; i < 8; i++) { const ax = 20 + i * (W / 8) const aw = W / 8 - 8 - // Pillar left - k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k,'#3a2a18')), k.opacity(0.4), k.z(1)]) - // Pillar right - k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax + aw - 6, GROUND_Y * 0.2), k.color(safeColor(k,'#3a2a18')), k.opacity(0.4), k.z(1)]) - // Arch top - k.add([k.rect(aw, 6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k,'#4a3a20')), k.opacity(0.4), k.z(1)]) - // Dark interior - k.add([k.rect(aw - 12, GROUND_Y * 0.5), k.pos(ax + 6, GROUND_Y * 0.25), k.color(safeColor(k,'#0a0800')), k.opacity(0.3), k.z(0)]) + k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k, '#3a2a18')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax + aw - 6, GROUND_Y * 0.2), k.color(safeColor(k, '#3a2a18')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(aw, 6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k, '#4a3a20')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(aw - 12, GROUND_Y * 0.5), k.pos(ax + 6, GROUND_Y * 0.25), k.color(safeColor(k, '#0a0800')), k.opacity(0.3), k.z(0)]) + k.add([k.rect(10, 3), k.pos(ax - 2, GROUND_Y * 0.2), k.color(safeColor(k, '#5a4a30')), k.opacity(0.35), k.z(1)]) + k.add([k.rect(10, 3), k.pos(ax + aw - 10, GROUND_Y * 0.2), k.color(safeColor(k, '#5a4a30')), k.opacity(0.35), k.z(1)]) + } + // Torch brackets on pillars + for (let i = 0; i < 4; i++) { + const tx = W * (0.15 + i * 0.25) + const ty = GROUND_Y * 0.35 + k.add([k.rect(3, 12), k.pos(tx, ty), k.color(safeColor(k, '#554422')), k.opacity(0.4), k.z(2)]) + const flame = k.add([k.circle(5), k.pos(tx + 1, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff6600')), k.opacity(0.4), k.z(2)]) + flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + i * 2) * 0.15 }) + } + // Crowd silhouettes in upper tiers + for (let row = 0; row < 2; row++) { + for (let i = 0; i < 20; i++) { + k.add([ + k.circle(2 + Math.random()), k.pos(15 + i * (W / 20), GROUND_Y * 0.08 + row * 12), + k.color(safeColor(k, '#2a1a10')), k.opacity(0.2), k.z(0), + ]) + } } // Crumbled stone debris - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 8; i++) { k.add([ k.rect(5 + Math.random() * 10, 3 + Math.random() * 6), k.pos(Math.random() * W, GROUND_Y - 5 - Math.random() * 10), - k.color(safeColor(k,'#5a4a30')), k.opacity(0.25), k.z(2), k.rotate(Math.random() * 40 - 20), + k.color(safeColor(k, '#5a4a30')), k.opacity(0.25), k.z(2), k.rotate(Math.random() * 40 - 20), ]) } + // Sand dust particles + for (let i = 0; i < 6; i++) { + const dust = k.add([ + k.circle(2 + Math.random() * 3), k.pos(Math.random() * W, GROUND_Y - 15 - Math.random() * 20), + k.color(safeColor(k, '#c0a060')), k.opacity(0.1), k.z(3), + ]) + const dx = dust.pos.x + dust.onUpdate(() => { dust.pos.x = dx + Math.sin(k.time() * 0.5 + i) * 12; dust.opacity = 0.05 + Math.sin(k.time() + i) * 0.05 }) + } + } else if (a === 'haunted_mansion') { - // Gothic windows + // Gothic windows with moonlight for (let i = 0; i < 5; i++) { const wx = W * (0.1 + i * 0.2) const wy = GROUND_Y * 0.2 - k.add([k.rect(20, 35), k.pos(wx, wy), k.color(safeColor(k,'#0a0510')), k.opacity(0.4), k.z(1)]) - // Window frame - k.add([k.rect(22, 1), k.pos(wx - 1, wy), k.color(safeColor(k,'#2a1830')), k.opacity(0.4), k.z(1)]) - k.add([k.rect(22, 1), k.pos(wx - 1, wy + 35), k.color(safeColor(k,'#2a1830')), k.opacity(0.4), k.z(1)]) - // Moonlight through window - const glow = k.add([k.rect(16, 30), k.pos(wx + 2, wy + 3), k.color(safeColor(k,'#8844cc')), k.opacity(0.06), k.z(1)]) + k.add([k.rect(20, 35), k.pos(wx, wy), k.color(safeColor(k, '#0a0510')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(22, 1), k.pos(wx - 1, wy), k.color(safeColor(k, '#2a1830')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(22, 1), k.pos(wx - 1, wy + 35), k.color(safeColor(k, '#2a1830')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(1, 35), k.pos(wx + 10, wy), k.color(safeColor(k, '#2a1830')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(20, 1), k.pos(wx, wy + 17), k.color(safeColor(k, '#2a1830')), k.opacity(0.3), k.z(1)]) + const glow = k.add([k.rect(16, 30), k.pos(wx + 2, wy + 3), k.color(safeColor(k, '#8844cc')), k.opacity(0.06), k.z(1)]) glow.onUpdate(() => { glow.opacity = 0.04 + Math.sin(k.time() * 0.5 + i) * 0.03 }) } - // Cobwebs in corners + // Moon behind a window + k.add([k.circle(12), k.pos(W * 0.5 + 10, GROUND_Y * 0.26), k.anchor('center'), k.color(safeColor(k, '#ccccdd')), k.opacity(0.15), k.z(0)]) + // Cobwebs for (const cx of [15, W - 35]) { - for (let strand = 0; strand < 4; strand++) { - k.add([k.rect(20 + strand * 5, 1), k.pos(cx, 5 + strand * 5), k.color(safeColor(k,'#666666')), k.opacity(0.12), k.z(1), k.rotate(cx < W / 2 ? 30 + strand * 5 : -30 - strand * 5)]) + for (let strand = 0; strand < 5; strand++) { + k.add([k.rect(20 + strand * 5, 1), k.pos(cx, 3 + strand * 4), k.color(safeColor(k, '#666666')), k.opacity(0.1), k.z(1), k.rotate(cx < W / 2 ? 28 + strand * 5 : -28 - strand * 5)]) + } + } + // Candelabras + for (const cx of [W * 0.25, W * 0.75]) { + k.add([k.rect(2, 20), k.pos(cx, GROUND_Y - 20), k.color(safeColor(k, '#666644')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(12, 2), k.pos(cx - 5, GROUND_Y - 20), k.color(safeColor(k, '#666644')), k.opacity(0.3), k.z(1)]) + for (let c = 0; c < 3; c++) { + const candleX = cx - 5 + c * 5 + const cFlame = k.add([k.circle(2.5), k.pos(candleX + 1, GROUND_Y - 23), k.anchor('center'), k.color(safeColor(k, '#ffaa44')), k.opacity(0.4), k.z(2)]) + cFlame.onUpdate(() => { cFlame.opacity = 0.3 + Math.sin(k.time() * 4 + c + cx) * 0.15 }) } } // Floating ghost particles - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 8; i++) { const ghost = k.add([ k.circle(3 + Math.random() * 3), k.pos(Math.random() * W, Math.random() * GROUND_Y), - k.color(safeColor(k,'#aabbcc')), k.opacity(0), k.z(2), + k.color(safeColor(k, '#aabbcc')), k.opacity(0), k.z(2), ]) const gx = ghost.pos.x, gy = ghost.pos.y ghost.onUpdate(() => { @@ -1049,130 +1336,459 @@ export async function createFightScene(config: FightSceneConfig) { ghost.pos.y = gy + Math.cos(k.time() * 0.25 + i) * 15 }) } + // Bats flying + for (let i = 0; i < 3; i++) { + const bat = k.add([k.rect(5, 2), k.pos(Math.random() * W, 15 + Math.random() * 40), k.color(safeColor(k, '#111111')), k.opacity(0.3), k.z(2)]) + const bx = bat.pos.x, by = bat.pos.y + bat.onUpdate(() => { + bat.pos.x = bx + Math.sin(k.time() * 1.5 + i * 3) * 40 + bat.pos.y = by + Math.cos(k.time() * 2 + i * 2) * 10 + }) + } + } else if (a === 'concert_hall') { - // Stage lights + // Stage lights with sweeping cones const lightColors = ['#ff2288', '#22ff88', '#4488ff', '#ffaa22', '#ff44ff', '#44ffff'] for (let i = 0; i < 6; i++) { const lx = W * (0.1 + i * 0.15) - // Light cone from ceiling const cone = k.add([ k.rect(30, GROUND_Y * 0.7), k.pos(lx, 10), k.anchor('top'), - k.color(safeColor(k,lightColors[i])), k.opacity(0.04), k.z(2), + k.color(safeColor(k, lightColors[i])), k.opacity(0.04), k.z(2), ]) - // Light source dot - k.add([k.circle(4), k.pos(lx + 15, 8), k.anchor('center'), k.color(safeColor(k,lightColors[i])), k.opacity(0.4), k.z(2)]) - cone.onUpdate(() => { cone.opacity = 0.02 + Math.sin(k.time() * 1.5 + i * 1.2) * 0.025 }) + k.add([k.circle(4), k.pos(lx + 15, 8), k.anchor('center'), k.color(safeColor(k, lightColors[i])), k.opacity(0.4), k.z(2)]) + const baseAngle = (i - 3) * 5 + cone.onUpdate(() => { + cone.opacity = 0.02 + Math.sin(k.time() * 1.5 + i * 1.2) * 0.025 + cone.angle = baseAngle + Math.sin(k.time() * 0.4 + i) * 8 + }) } - // Speaker stacks on sides - for (const sx of [15, W - 35]) { - for (let s = 0; s < 3; s++) { - k.add([k.rect(20, 18), k.pos(sx, GROUND_Y - 20 - s * 20), k.color(safeColor(k,'#111111')), k.opacity(0.5), k.z(1)]) - k.add([k.circle(6), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k,'#222222')), k.opacity(0.4), k.z(1)]) + // Speaker stacks + for (const sx of [10, W - 30]) { + for (let s = 0; s < 4; s++) { + k.add([k.rect(20, 18), k.pos(sx, GROUND_Y - 20 - s * 20), k.color(safeColor(k, '#111111')), k.opacity(0.5), k.z(1)]) + k.add([k.circle(6), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k, '#222222')), k.opacity(0.4), k.z(1)]) + const speakerCone = k.add([k.circle(4), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k, '#1a1a1a')), k.opacity(0.3), k.z(1)]) + speakerCone.onUpdate(() => { speakerCone.scale = k.vec2(1 + Math.sin(k.time() * 8 + s) * 0.05) }) } } - } else if (a === 'colosseum' || a === 'sports_arena') { - // Stadium seating tiers - for (let tier = 0; tier < 4; tier++) { - const ty = GROUND_Y * 0.15 + tier * (GROUND_Y * 0.15) - k.add([k.rect(W, GROUND_Y * 0.12), k.pos(0, ty), k.color(safeColor(k,tier % 2 === 0 ? '#1a2a4a' : '#152040')), k.opacity(0.3), k.z(1)]) + // Crowd silhouettes + for (let i = 0; i < 30; i++) { + const cx = 5 + i * (W / 30) + const ch = 6 + Math.random() * 4 + k.add([k.circle(2.5), k.pos(cx, GROUND_Y + 8), k.anchor('center'), k.color(safeColor(k, '#111111')), k.opacity(0.3), k.z(3)]) + k.add([k.rect(4, ch), k.pos(cx - 2, GROUND_Y + 10), k.color(safeColor(k, '#111111')), k.opacity(0.25), k.z(3)]) } - } else if (a === 'kitchen_stadium') { - // Kitchen equipment silhouettes - // Stove - k.add([k.rect(40, 30), k.pos(W * 0.08, GROUND_Y - 30), k.color(safeColor(k,'#1a1010')), k.opacity(0.4), k.z(1)]) - for (let b = 0; b < 4; b++) { - const flame = k.add([k.circle(4), k.pos(W * 0.08 + 6 + b * 9, GROUND_Y - 32), k.anchor('center'), k.color(safeColor(k,'#ff4400')), k.opacity(0.4), k.z(2)]) - flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + b) * 0.15 }) + // Smoke machine haze + for (let i = 0; i < 6; i++) { + const haze = k.add([ + k.circle(30 + Math.random() * 25), k.pos(Math.random() * W, GROUND_Y - 8), + k.color(safeColor(k, '#20102a')), k.opacity(0.18), k.z(3), + ]) + const hx = haze.pos.x + haze.onUpdate(() => { haze.pos.x = hx + Math.sin(k.time() * 0.15 + i) * 15 }) } - // Hanging pots - for (let i = 0; i < 5; i++) { - const px = W * (0.2 + i * 0.15) - k.add([k.rect(1, 15 + Math.random() * 10), k.pos(px, 5), k.color(safeColor(k,'#333333')), k.opacity(0.3), k.z(1)]) - k.add([k.circle(8), k.pos(px, 20 + Math.random() * 10), k.anchor('center'), k.color(safeColor(k,'#444444')), k.opacity(0.25), k.z(1)]) - } - } else if (a === 'junkyard') { - // Piles of scrap - for (let pile = 0; pile < 6; pile++) { - const px = Math.random() * W - const py = GROUND_Y - 10 - for (let j = 0; j < 5; j++) { + // Laser beam sweep + const laser = k.add([k.rect(1, GROUND_Y), k.pos(W / 2, 0), k.color(safeColor(k, '#00ff00')), k.opacity(0.06), k.z(2)]) + laser.onUpdate(() => { laser.pos.x = W / 2 + Math.sin(k.time() * 0.8) * (W * 0.4) }) + + } else if (a === 'sports_arena') { + // Stadium seating tiers with crowd + for (let tier = 0; tier < 5; tier++) { + const ty = GROUND_Y * 0.1 + tier * (GROUND_Y * 0.13) + k.add([k.rect(W, GROUND_Y * 0.1), k.pos(0, ty), k.color(safeColor(k, tier % 2 === 0 ? '#1a2a4a' : '#152040')), k.opacity(0.3), k.z(1)]) + for (let i = 0; i < 18; i++) { k.add([ - k.rect(8 + Math.random() * 15, 5 + Math.random() * 10), - k.pos(px + (Math.random() - 0.5) * 20, py - j * 8 - Math.random() * 5), - k.color(safeColor(k,['#554433', '#665544', '#443322', '#776655', '#332211'][j % 5])), - k.opacity(0.3), k.z(1), k.rotate(Math.random() * 30 - 15), + k.circle(1.5 + Math.random()), k.pos(15 + i * (W / 18), ty + GROUND_Y * 0.04), + k.color(safeColor(k, ['#cc3333', '#3333cc', '#33cc33', '#cccc33', '#ffffff'][Math.floor(Math.random() * 5)])), + k.opacity(0.12), k.z(1), ]) } } + // Floodlights + for (const fx of [W * 0.05, W * 0.95]) { + k.add([k.rect(3, GROUND_Y * 0.7), k.pos(fx, GROUND_Y * 0.1), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(2)]) + const light = k.add([k.circle(5), k.pos(fx + 1, GROUND_Y * 0.08), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.5), k.z(2)]) + k.add([k.rect(40, GROUND_Y * 0.6), k.pos(fx - 18, GROUND_Y * 0.12), k.color(safeColor(k, '#ffffff')), k.opacity(0.015), k.z(0)]) + light.onUpdate(() => { light.opacity = 0.4 + Math.sin(k.time() * 0.5 + fx) * 0.1 }) + } + // Scoreboard + k.add([k.rect(50, 18), k.pos(W / 2 - 25, 8), k.color(safeColor(k, '#0a0a20')), k.opacity(0.5), k.z(2)]) + k.add([k.rect(48, 16), k.pos(W / 2 - 24, 9), k.color(safeColor(k, '#111133')), k.opacity(0.4), k.z(2)]) + const scoreGlow = k.add([k.rect(20, 8), k.pos(W / 2 - 10, 13), k.color(safeColor(k, '#ff3333')), k.opacity(0.12), k.z(2)]) + scoreGlow.onUpdate(() => { scoreGlow.opacity = 0.08 + Math.sin(k.time() * 2) * 0.05 }) + // Camera flashes + for (let i = 0; i < 5; i++) { + const flash = k.add([ + k.circle(2), k.pos(Math.random() * W, GROUND_Y * (0.15 + Math.random() * 0.5)), + k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(3), + ]) + flash.onUpdate(() => { flash.opacity = Math.random() > 0.995 ? 0.4 : 0 }) + } + + } else if (a === 'kitchen_stadium') { + // Tiled wall + for (let row = 0; row < 6; row++) { + for (let col = 0; col < 14; col++) { + k.add([ + k.rect(W / 14 - 1, GROUND_Y / 6 - 1), k.pos(col * (W / 14), row * (GROUND_Y / 6)), + k.color(safeColor(k, (row + col) % 2 === 0 ? '#1a1210' : '#1e1614')), k.opacity(0.2), k.z(0), + ]) + } + } + // Stove with burners + k.add([k.rect(45, 35), k.pos(W * 0.06, GROUND_Y - 35), k.color(safeColor(k, '#1a1010')), k.opacity(0.45), k.z(1)]) + k.add([k.rect(43, 2), k.pos(W * 0.06 + 1, GROUND_Y - 35), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(1)]) + for (let b = 0; b < 4; b++) { + const flame = k.add([k.circle(4), k.pos(W * 0.06 + 6 + b * 10, GROUND_Y - 37), k.anchor('center'), k.color(safeColor(k, '#ff4400')), k.opacity(0.4), k.z(2)]) + const innerFlame = k.add([k.circle(2), k.pos(W * 0.06 + 6 + b * 10, GROUND_Y - 38), k.anchor('center'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.3), k.z(2)]) + flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + b) * 0.15; innerFlame.opacity = 0.2 + Math.sin(k.time() * 7 + b) * 0.1 }) + } + // Hanging pots and pans + k.add([k.rect(W * 0.5, 2), k.pos(W * 0.2, 8), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1)]) + for (let i = 0; i < 6; i++) { + const px = W * (0.22 + i * 0.08) + const hookLen = 12 + Math.random() * 10 + k.add([k.rect(1, hookLen), k.pos(px, 10), k.color(safeColor(k, '#444444')), k.opacity(0.25), k.z(1)]) + k.add([k.circle(7 + Math.random() * 4), k.pos(px, 10 + hookLen + 5), k.anchor('center'), k.color(safeColor(k, i % 2 === 0 ? '#555555' : '#664422')), k.opacity(0.25), k.z(1)]) + } + // Knife rack + k.add([k.rect(3, 25), k.pos(W * 0.85, GROUND_Y * 0.3), k.color(safeColor(k, '#553322')), k.opacity(0.35), k.z(1)]) + for (let i = 0; i < 4; i++) { + k.add([k.rect(2, 10 + Math.random() * 5), k.pos(W * 0.85 + 1, GROUND_Y * 0.32 + i * 5), k.color(safeColor(k, '#aaaaaa')), k.opacity(0.2), k.z(1)]) + } + // Steam rising + for (let i = 0; i < 4; i++) { + const steam = k.add([ + k.circle(5 + Math.random() * 5), k.pos(W * 0.06 + 10 + i * 10, GROUND_Y - 40), + k.color(safeColor(k, '#ffffff')), k.opacity(0.06), k.z(3), + ]) + const bx = steam.pos.x + steam.onUpdate(() => { + steam.pos.y -= 10 * k.dt() + steam.pos.x = bx + Math.sin(k.time() * 2 + i) * 4 + steam.opacity *= 0.995 + if (steam.pos.y < GROUND_Y - 80) { steam.pos.y = GROUND_Y - 40; steam.opacity = 0.06 } + }) + } + + } else if (a === 'junkyard') { + // Rusted crane silhouette + k.add([k.rect(6, GROUND_Y * 0.7), k.pos(W * 0.85, GROUND_Y * 0.1), k.color(safeColor(k, '#443322')), k.opacity(0.3), k.z(0)]) + k.add([k.rect(W * 0.3, 4), k.pos(W * 0.6, GROUND_Y * 0.1), k.color(safeColor(k, '#443322')), k.opacity(0.25), k.z(0)]) + const cable = k.add([k.rect(1, 35), k.pos(W * 0.65, GROUND_Y * 0.12), k.color(safeColor(k, '#555555')), k.opacity(0.2), k.z(0)]) + cable.onUpdate(() => { cable.pos.x = W * 0.65 + Math.sin(k.time() * 0.3) * 5 }) + // Piles of scrap + for (let pile = 0; pile < 8; pile++) { + const px = Math.random() * W + const py = GROUND_Y - 8 + for (let j = 0; j < 7; j++) { + k.add([ + k.rect(8 + Math.random() * 18, 4 + Math.random() * 10), + k.pos(px + (Math.random() - 0.5) * 25, py - j * 6 - Math.random() * 5), + k.color(safeColor(k, ['#554433', '#665544', '#443322', '#776655', '#332211', '#887766', '#553322'][j % 7])), + k.opacity(0.3), k.z(1), k.rotate(Math.random() * 40 - 20), + ]) + } + } + // Old car silhouette + k.add([k.rect(35, 15), k.pos(W * 0.15, GROUND_Y - 15), k.color(safeColor(k, '#2a2a1a')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(25, 10), k.pos(W * 0.15 + 5, GROUND_Y - 25), k.color(safeColor(k, '#2a2a1a')), k.opacity(0.25), k.z(1)]) + k.add([k.circle(5), k.pos(W * 0.15 + 6, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k, '#1a1a0a')), k.opacity(0.3), k.z(1)]) + k.add([k.circle(5), k.pos(W * 0.15 + 29, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k, '#1a1a0a')), k.opacity(0.3), k.z(1)]) + // Sparking wires + for (let i = 0; i < 3; i++) { + const spark = k.add([ + k.circle(2), k.pos(W * (0.3 + i * 0.2), GROUND_Y - 20 - Math.random() * 30), + k.color(safeColor(k, '#ffaa00')), k.opacity(0), k.z(3), + ]) + spark.onUpdate(() => { spark.opacity = Math.random() > 0.96 ? 0.5 : 0 }) + } + // Smoke/steam wisps + for (let i = 0; i < 4; i++) { + const smoke = k.add([ + k.circle(10 + Math.random() * 10), k.pos(Math.random() * W, GROUND_Y - 5), + k.color(safeColor(k, '#333322')), k.opacity(0.12), k.z(2), + ]) + const sx = smoke.pos.x + smoke.onUpdate(() => { + smoke.pos.y -= 5 * k.dt() + smoke.pos.x = sx + Math.sin(k.time() * 0.5 + i) * 6 + if (smoke.pos.y < GROUND_Y - 60) { smoke.pos.y = GROUND_Y - 5; smoke.opacity = 0.12 } + }) + } + } else if (a === 'meme_dimension') { - // Floating emoji-like shapes - for (let i = 0; i < 8; i++) { + // Floating emoji shapes (pulsing) + for (let i = 0; i < 12; i++) { const emoji = k.add([ - k.circle(8 + Math.random() * 5), k.pos(Math.random() * W, Math.random() * GROUND_Y), - k.color(safeColor(k,'#ffcc00')), k.opacity(0.15), k.z(1), + k.circle(6 + Math.random() * 6), k.pos(Math.random() * W, Math.random() * GROUND_Y), + k.color(safeColor(k, ['#ffcc00', '#ff4444', '#44ff44', '#4444ff', '#ff44ff', '#44ffff'][i % 6])), k.opacity(0.12), k.z(1), ]) const bx = emoji.pos.x, by = emoji.pos.y emoji.onUpdate(() => { emoji.pos.x = bx + Math.sin(k.time() * 0.4 + i) * 15 emoji.pos.y = by + Math.cos(k.time() * 0.3 + i) * 10 + emoji.scale = k.vec2(1 + Math.sin(k.time() * 1.5 + i) * 0.15) }) } // Glitch bands - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 8; i++) { const band = k.add([ - k.rect(W, 2 + Math.random() * 3), k.pos(0, Math.random() * GROUND_Y), - k.color(safeColor(k,Math.random() > 0.5 ? '#ff44ff' : '#44ffff')), k.opacity(0), k.z(2), + k.rect(W, 2 + Math.random() * 4), k.pos(0, Math.random() * GROUND_Y), + k.color(safeColor(k, Math.random() > 0.5 ? '#ff44ff' : '#44ffff')), k.opacity(0), k.z(2), ]) - band.onUpdate(() => { band.opacity = Math.random() > 0.97 ? 0.15 : 0 }) + band.onUpdate(() => { + band.opacity = Math.random() > 0.97 ? 0.12 + Math.random() * 0.08 : 0 + if (band.opacity > 0) band.pos.x = (Math.random() - 0.5) * 10 + else band.pos.x = 0 + }) } - } else if (a === 'space_station') { - // Structural beams + // Rainbow pixel streaks + const rainbowC = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff'] + for (let i = 0; i < rainbowC.length; i++) { + const streak = k.add([ + k.rect(W, 3), k.pos(0, GROUND_Y * 0.3 + i * 5), + k.color(safeColor(k, rainbowC[i])), k.opacity(0.04), k.z(0), + ]) + streak.onUpdate(() => { streak.opacity = 0.02 + Math.sin(k.time() * 2 + i) * 0.02 }) + } + // Spinning geometric shapes + for (let i = 0; i < 4; i++) { + const geo = k.add([ + k.rect(8 + Math.random() * 8, 8 + Math.random() * 8), + k.pos(W * (0.2 + i * 0.2), GROUND_Y * (0.3 + Math.random() * 0.3)), + k.anchor('center'), k.color(safeColor(k, '#ff44ff')), k.opacity(0.08), k.z(1), k.rotate(0), + ]) + geo.onUpdate(() => { geo.angle += (30 + i * 15) * k.dt() }) + } + // VHS tracking lines + const vhs = k.add([k.rect(W, 6), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(3)]) + vhs.onUpdate(() => { + vhs.pos.y = (vhs.pos.y + 80 * k.dt()) % (GROUND_Y + 20) + vhs.opacity = Math.random() > 0.9 ? 0.15 : 0 + }) + // RGB noise flickers for (let i = 0; i < 6; i++) { - const bx = W * (0.15 + i * 0.15) - k.add([k.rect(4, GROUND_Y), k.pos(bx, 0), k.color(safeColor(k,'#1a1a30')), k.opacity(0.3), k.z(1)]) - // Cross beams - k.add([k.rect(W * 0.15, 3), k.pos(bx, GROUND_Y * 0.3), k.color(safeColor(k,'#1a1a30')), k.opacity(0.25), k.z(1)]) - } - // Viewport (window to space) - const vpX = W * 0.5, vpY = GROUND_Y * 0.3 - k.add([k.circle(35), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k,'#000020')), k.opacity(0.5), k.z(0)]) - k.add([k.circle(37), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k,'#2a2a50')), k.opacity(0.3), k.z(0)]) - // Stars through viewport - for (let s = 0; s < 8; s++) { - k.add([ - k.circle(1), k.pos(vpX + (Math.random() - 0.5) * 50, vpY + (Math.random() - 0.5) * 50), - k.color(safeColor(k,'#ffffff')), k.opacity(0.3), k.z(0), + const noise = k.add([ + k.rect(3 + Math.random() * 8, 3 + Math.random() * 8), + k.pos(Math.random() * W, Math.random() * GROUND_Y), + k.color(safeColor(k, ['#ff0000', '#00ff00', '#0000ff'][i % 3])), k.opacity(0), k.z(2), ]) + noise.onUpdate(() => { + noise.opacity = Math.random() > 0.98 ? 0.1 : 0 + if (noise.opacity > 0) { noise.pos.x = Math.random() * W; noise.pos.y = Math.random() * GROUND_Y } + }) } + + } else if (a === 'space_station') { + // Structural beams with rivets + for (let i = 0; i < 6; i++) { + const bx = W * (0.15 + i * 0.14) + k.add([k.rect(5, GROUND_Y), k.pos(bx, 0), k.color(safeColor(k, '#1a1a30')), k.opacity(0.3), k.z(1)]) + for (let r = 0; r < 8; r++) { + k.add([k.circle(1), k.pos(bx + 2, r * (GROUND_Y / 8) + 5), k.anchor('center'), k.color(safeColor(k, '#2a2a40')), k.opacity(0.25), k.z(1)]) + } + k.add([k.rect(W * 0.14, 3), k.pos(bx, GROUND_Y * 0.25), k.color(safeColor(k, '#1a1a30')), k.opacity(0.25), k.z(1)]) + k.add([k.rect(W * 0.14, 3), k.pos(bx, GROUND_Y * 0.6), k.color(safeColor(k, '#1a1a30')), k.opacity(0.2), k.z(1)]) + } + // Viewport + const vpX = W * 0.5, vpY = GROUND_Y * 0.3 + k.add([k.circle(40), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k, '#000020')), k.opacity(0.5), k.z(0)]) + k.add([k.circle(42), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k, '#2a2a50')), k.opacity(0.3), k.z(0)]) + for (let s = 0; s < 15; s++) { + const dist = Math.random() * 35 + const angle = Math.random() * Math.PI * 2 + const sx = vpX + Math.cos(angle) * dist + const sy = vpY + Math.sin(angle) * dist + const vpStar = k.add([k.circle(0.8 + Math.random()), k.pos(sx, sy), k.color(safeColor(k, '#ffffff')), k.opacity(0.3), k.z(0)]) + vpStar.onUpdate(() => { vpStar.opacity = 0.15 + Math.sin(k.time() * 2 + s) * 0.15 }) + } + // Earth through viewport + k.add([k.circle(15), k.pos(vpX + 10, vpY + 5), k.anchor('center'), k.color(safeColor(k, '#2244aa')), k.opacity(0.15), k.z(0)]) + k.add([k.circle(6), k.pos(vpX + 7, vpY + 2), k.anchor('center'), k.color(safeColor(k, '#22aa44')), k.opacity(0.08), k.z(0)]) + // Control panels + for (const px of [W * 0.08, W * 0.88]) { + k.add([k.rect(25, 40), k.pos(px, GROUND_Y * 0.4), k.color(safeColor(k, '#0a0a20')), k.opacity(0.4), k.z(1)]) + for (let led = 0; led < 8; led++) { + const ledEl = k.add([ + k.rect(2, 2), k.pos(px + 4 + (led % 4) * 5, GROUND_Y * 0.42 + Math.floor(led / 4) * 6), + k.color(safeColor(k, ['#00ff88', '#ff4444', '#ffaa00', '#4488ff'][led % 4])), k.opacity(0.4), k.z(2), + ]) + ledEl.onUpdate(() => { ledEl.opacity = Math.random() > 0.05 ? 0.4 : 0.1 }) + } + } + // Warning stripes + for (let i = 0; i < 8; i++) { + k.add([k.rect(8, 3), k.pos(W * 0.3 + i * 12, GROUND_Y - 3), k.color(safeColor(k, i % 2 === 0 ? '#ffaa00' : '#000000')), k.opacity(0.12), k.z(3)]) + } + } else if (a === 'savanna') { - // Acacia tree silhouettes - for (const tx of [W * 0.12, W * 0.7]) { - k.add([k.rect(5, 50), k.pos(tx, GROUND_Y - 50), k.color(safeColor(k,'#2a1a08')), k.opacity(0.4), k.z(1)]) - // Flat canopy - k.add([k.rect(40, 5), k.pos(tx - 17, GROUND_Y - 52), k.color(safeColor(k,'#2a3a10')), k.opacity(0.3), k.z(1)]) - k.add([k.rect(30, 4), k.pos(tx - 12, GROUND_Y - 57), k.color(safeColor(k,'#2a3a10')), k.opacity(0.25), k.z(1)]) + // Distant mountain range + for (let i = 0; i < 5; i++) { + const mx = W * (0.1 + i * 0.2) + (Math.random() - 0.5) * 30 + const mh = 30 + Math.random() * 40 + k.add([k.circle(mh), k.pos(mx, GROUND_Y - mh * 0.3), k.anchor('center'), k.color(safeColor(k, '#2a2010')), k.opacity(0.12), k.z(0)]) + } + // Acacia trees with branches + for (const tx of [W * 0.1, W * 0.4, W * 0.72]) { + const trunkH = 40 + Math.random() * 20 + k.add([k.rect(5, trunkH), k.pos(tx, GROUND_Y - trunkH), k.color(safeColor(k, '#2a1a08')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(15, 3), k.pos(tx - 10, GROUND_Y - trunkH * 0.7), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(1), k.rotate(-20)]) + k.add([k.rect(12, 3), k.pos(tx + 5, GROUND_Y - trunkH * 0.6), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(1), k.rotate(15)]) + k.add([k.rect(50, 5), k.pos(tx - 22, GROUND_Y - trunkH - 2), k.color(safeColor(k, '#2a3a10')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(40, 4), k.pos(tx - 17, GROUND_Y - trunkH - 7), k.color(safeColor(k, '#2a3a10')), k.opacity(0.25), k.z(1)]) + k.add([k.rect(25, 3), k.pos(tx - 10, GROUND_Y - trunkH - 11), k.color(safeColor(k, '#223308')), k.opacity(0.2), k.z(1)]) } // Setting sun - k.add([k.circle(25), k.pos(W * 0.85, GROUND_Y * 0.25), k.anchor('center'), k.color(safeColor(k,'#dd6622')), k.opacity(0.25), k.z(0)]) - k.add([k.circle(35), k.pos(W * 0.85, GROUND_Y * 0.25), k.anchor('center'), k.color(safeColor(k,'#dd6622')), k.opacity(0.08), k.z(0)]) + const sunX = W * 0.85, sunY = GROUND_Y * 0.22 + k.add([k.circle(50), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#dd6622')), k.opacity(0.06), k.z(0)]) + k.add([k.circle(35), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#dd6622')), k.opacity(0.12), k.z(0)]) + const savSun = k.add([k.circle(22), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffaa22')), k.opacity(0.3), k.z(0)]) + savSun.onUpdate(() => { savSun.opacity = 0.25 + Math.sin(k.time() * 0.3) * 0.05 }) + // Tall grass swaying + for (let i = 0; i < 20; i++) { + const gx = Math.random() * W + const grassH = 5 + Math.random() * 10 + const grass = k.add([k.rect(1, grassH), k.pos(gx, GROUND_Y - grassH), k.color(safeColor(k, '#8a7830')), k.opacity(0.2), k.z(3)]) + grass.onUpdate(() => { grass.angle = Math.sin(k.time() * 1.2 + i) * 5 }) + } + // Elephant silhouette + const ex = W * 0.6, ey = GROUND_Y - 18 + k.add([k.rect(18, 12), k.pos(ex, ey), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)]) + k.add([k.rect(8, 8), k.pos(ex + 15, ey - 4), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)]) + k.add([k.rect(3, 8), k.pos(ex + 2, ey + 12), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)]) + k.add([k.rect(3, 8), k.pos(ex + 13, ey + 12), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)]) + // Birds in V formation + for (let i = 0; i < 5; i++) { + const birdX = W * 0.3 + i * 8 + const birdY = 20 + Math.abs(i - 2) * 5 + k.add([k.rect(4, 1), k.pos(birdX, birdY), k.color(safeColor(k, '#222222')), k.opacity(0.2), k.z(1), k.rotate(-12)]) + k.add([k.rect(4, 1), k.pos(birdX + 3, birdY), k.color(safeColor(k, '#222222')), k.opacity(0.2), k.z(1), k.rotate(12)]) + } + } else if (a === 'server_room') { - // Server rack rows (perspective) + // Server rack rows with LEDs for (let row = 0; row < 3; row++) { for (let col = 0; col < 6; col++) { const rx = 30 + col * (W / 6) - const ry = 25 + row * 50 + const ry = 20 + row * 55 const rw = W / 6 - 12 - k.add([k.rect(rw, 42), k.pos(rx, ry), k.color(safeColor(k,'#0a1a10')), k.opacity(0.4), k.z(1)]) - // Status LEDs + const rh = 46 + k.add([k.rect(rw, rh), k.pos(rx, ry), k.color(safeColor(k, '#0a1a10')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(rw, 1), k.pos(rx, ry), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(rw, 1), k.pos(rx, ry + rh), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)]) + for (let s = 0; s < 5; s++) { + k.add([k.rect(rw - 4, 1), k.pos(rx + 2, ry + 10 + s * 7), k.color(safeColor(k, '#0a1008')), k.opacity(0.3), k.z(1)]) + } for (let led = 0; led < 4; led++) { const ledEl = k.add([ k.rect(2, 2), k.pos(rx + 3 + led * 5, ry + 3), - k.color(safeColor(k,'#00ff88')), k.opacity(0.5), k.z(2), + k.color(safeColor(k, '#00ff88')), k.opacity(0.5), k.z(2), ]) ledEl.onUpdate(() => { ledEl.opacity = Math.random() > 0.04 ? 0.5 : 0.1 }) } + if (Math.random() < 0.5) { + const act = k.add([k.rect(2, 2), k.pos(rx + rw - 6, ry + 3), k.color(safeColor(k, '#ffaa00')), k.opacity(0.5), k.z(2)]) + act.onUpdate(() => { act.opacity = Math.random() > 0.3 ? 0.5 : 0.15 }) + } } } + // Overhead cable trays + for (let i = 0; i < 3; i++) { + k.add([k.rect(W * 0.25, 3), k.pos(W * (0.1 + i * 0.3), 5), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)]) + for (let c = 0; c < 4; c++) { + k.add([k.rect(W * 0.25, 1), k.pos(W * (0.1 + i * 0.3), 7 + c * 2), k.color(safeColor(k, ['#004400', '#000044', '#440000', '#444400'][c])), k.opacity(0.15), k.z(1)]) + } + } + // Cool air flow particles + for (let i = 0; i < 5; i++) { + const airflow = k.add([ + k.circle(2), k.pos(Math.random() * W, GROUND_Y - 10), + k.color(safeColor(k, '#00ff88')), k.opacity(0.08), k.z(3), + ]) + airflow.onUpdate(() => { + airflow.pos.y -= 15 * k.dt() + if (airflow.pos.y < 10) { airflow.pos.y = GROUND_Y - 10; airflow.pos.x = Math.random() * W } + }) + } + // Emergency lighting + const emLight = k.add([k.circle(4), k.pos(W * 0.5, 5), k.anchor('center'), k.color(safeColor(k, '#ff0000')), k.opacity(0.2), k.z(2)]) + emLight.onUpdate(() => { emLight.opacity = 0.1 + Math.sin(k.time() * 1.5) * 0.1 }) + + } else if (a === 'paper_mill') { + // Industrial ceiling pipes + for (let i = 0; i < 5; i++) { + const py = 5 + i * 4 + k.add([k.rect(W, 3), k.pos(0, py), k.color(safeColor(k, i % 2 === 0 ? '#2a2a20' : '#222218')), k.opacity(0.35), k.z(1)]) + for (let j = 0; j < 4; j++) { + k.add([k.circle(3), k.pos(W * (0.2 + j * 0.2), py + 1), k.anchor('center'), k.color(safeColor(k, '#333328')), k.opacity(0.3), k.z(1)]) + } + } + // Conveyor belt + k.add([k.rect(W * 0.6, 6), k.pos(W * 0.2, GROUND_Y - 15), k.color(safeColor(k, '#333328')), k.opacity(0.35), k.z(1)]) + for (let i = 0; i < 8; i++) { + k.add([k.circle(3), k.pos(W * 0.22 + i * (W * 0.075), GROUND_Y - 12), k.anchor('center'), k.color(safeColor(k, '#444438')), k.opacity(0.3), k.z(1)]) + } + // Moving items on belt + for (let i = 0; i < 3; i++) { + const item = k.add([ + k.rect(10, 6), k.pos(W * 0.25 + i * 60, GROUND_Y - 21), + k.color(safeColor(k, '#f0e68c')), k.opacity(0.2), k.z(2), + ]) + item.onUpdate(() => { + item.pos.x += 15 * k.dt() + if (item.pos.x > W * 0.78) item.pos.x = W * 0.2 + }) + } + // Paper rolls stacked + for (let i = 0; i < 3; i++) { + for (let j = 0; j <= i; j++) { + const rx = W * 0.08 + j * 14 - i * 7 + const ry = GROUND_Y - 12 - (2 - i) * 12 + k.add([k.circle(6), k.pos(rx, ry), k.anchor('center'), k.color(safeColor(k, '#ddd8b0')), k.opacity(0.2), k.z(1)]) + k.add([k.circle(2), k.pos(rx, ry), k.anchor('center'), k.color(safeColor(k, '#aaa880')), k.opacity(0.15), k.z(1)]) + } + } + // Industrial gears (rotating) + for (const gx of [W * 0.88, W * 0.92]) { + const gy = GROUND_Y * 0.35 + k.add([k.circle(10), k.pos(gx, gy), k.anchor('center'), k.color(safeColor(k, '#444438')), k.opacity(0.25), k.z(1)]) + k.add([k.circle(4), k.pos(gx, gy), k.anchor('center'), k.color(safeColor(k, '#333328')), k.opacity(0.3), k.z(1)]) + for (let t = 0; t < 8; t++) { + const tooth = k.add([ + k.rect(4, 3), k.pos(gx, gy), k.anchor('center'), + k.color(safeColor(k, '#444438')), k.opacity(0.25), k.z(1), + k.rotate(t * 45), + ]) + tooth.onUpdate(() => { tooth.angle = t * 45 + k.time() * (gx === W * 0.88 ? 20 : -25) }) + } + } + // Steam from pipes + for (let i = 0; i < 4; i++) { + const steam = k.add([ + k.circle(6 + Math.random() * 8), k.pos(W * (0.2 + i * 0.2), 25), + k.color(safeColor(k, '#ccccbb')), k.opacity(0.06), k.z(2), + ]) + const bsx = steam.pos.x + steam.onUpdate(() => { + steam.pos.y += 4 * k.dt() + steam.pos.x = bsx + Math.sin(k.time() + i) * 5 + steam.opacity *= 0.998 + if (steam.pos.y > GROUND_Y * 0.4) { steam.pos.y = 25; steam.opacity = 0.06 } + }) + } + // Sawdust particles + for (let i = 0; i < 8; i++) { + const dust = k.add([ + k.circle(1), k.pos(Math.random() * W, Math.random() * GROUND_Y), + k.color(safeColor(k, '#f0e68c')), k.opacity(0.12), k.z(3), + ]) + const dx = dust.pos.x, dy = dust.pos.y + dust.onUpdate(() => { + dust.pos.x = dx + Math.sin(k.time() * 0.8 + i) * 10 + dust.pos.y = dy + Math.cos(k.time() * 0.5 + i) * 8 + }) + } } } diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 2435b5e..1af4d49 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -335,11 +335,11 @@ const ROUND_HYPE = [ // Mortal Kombat style dramatic calls export function announceFinishHim() { - speak('Finish him!', 'announcer', true, true) + speak('Finish it!', 'announcer', true, true) } -export function announceFatality() { - speak('Fatality!', 'deep', true, true) +export function announceFatality(tagline?: string) { + speak(tagline || 'Fatality!', 'deep', true, true) } export function announceFlawlessVictory() { diff --git a/frontend/src/game/sprites/human.ts b/frontend/src/game/sprites/human.ts new file mode 100644 index 0000000..67ab00e --- /dev/null +++ b/frontend/src/game/sprites/human.ts @@ -0,0 +1,866 @@ +// Human sprite — 3x resolution for detailed pixel art of the bot's owner +// Renders at 144x144 internal grid (vs 48x48 for bots) for crisp detail + +export const HUMAN_FRAME_SIZE = 288 +export const HUMAN_INTERNAL = 144 +export const HUMAN_SCALE = HUMAN_FRAME_SIZE / HUMAN_INTERNAL + +export const HUMAN_ANIMATIONS = { + idle: { frames: 4, row: 0 }, + cheer: { frames: 4, row: 1 }, + panic: { frames: 4, row: 2 }, + run: { frames: 4, row: 3 }, + coach: { frames: 4, row: 4 }, +} +export const HUMAN_ROWS = Object.keys(HUMAN_ANIMATIONS).length +export const HUMAN_MAX_FRAMES = 4 + +// Costume system +type CostumeType = 'animal' | 'armor' | 'hat' | 'tech' | 'monster' | 'food' | 'silly' | 'generic' + +interface CostumeConfig { + type: CostumeType + hat?: string + hatShape: 'none' | 'ears' | 'helmet' | 'pointy' | 'wide' | 'tall' | 'horns' | 'antenna' | 'crown' | 'cone' + prop?: string + propShape: 'none' | 'sword' | 'wand' | 'phone' | 'sign' | 'shield' | 'food' | 'remote' + onesie: boolean + facePaint?: string + tail: boolean +} + +const COSTUME_MAP: Record> = { + dog: { type: 'animal', hatShape: 'ears', hat: '#aa7744', onesie: true, tail: true }, + cat: { type: 'animal', hatShape: 'ears', hat: '#ff8844', onesie: true, tail: true }, + frog: { type: 'animal', hatShape: 'ears', hat: '#44aa22', onesie: true }, + shark: { type: 'animal', hatShape: 'tall', hat: '#4477aa', onesie: true }, + penguin: { type: 'animal', hatShape: 'none', hat: '#222222', onesie: true }, + octopus: { type: 'animal', hatShape: 'none', hat: '#cc44aa', onesie: true }, + bee: { type: 'animal', hatShape: 'antenna', hat: '#ffcc00', onesie: true }, + snail: { type: 'animal', hatShape: 'antenna', hat: '#88aa44', onesie: true }, + lobster: { type: 'animal', hatShape: 'antenna', hat: '#cc2222', onesie: true }, + sheep: { type: 'animal', hatShape: 'ears', hat: '#eeeeee', onesie: true }, + elephant: { type: 'animal', hatShape: 'ears', hat: '#888899', onesie: true }, + giraffe: { type: 'animal', hatShape: 'horns', hat: '#ddaa44', onesie: true }, + hippo: { type: 'animal', hatShape: 'ears', hat: '#8877aa', onesie: true }, + lion: { type: 'animal', hatShape: 'ears', hat: '#cc8822', onesie: true }, + monkey: { type: 'animal', hatShape: 'ears', hat: '#884422', onesie: true, tail: true }, + parrot: { type: 'animal', hatShape: 'tall', hat: '#ff4422', onesie: true, tail: true }, + raccoon: { type: 'animal', hatShape: 'ears', hat: '#666666', onesie: true, tail: true }, + snake: { type: 'animal', hatShape: 'none', hat: '#448822', onesie: true, tail: true }, + turtle: { type: 'animal', hatShape: 'none', hat: '#44aa44', onesie: true }, + whale: { type: 'animal', hatShape: 'none', hat: '#4466bb', onesie: true }, + crocodile: { type: 'animal', hatShape: 'none', hat: '#447722', onesie: true, tail: true }, + flamingo: { type: 'animal', hatShape: 'tall', hat: '#ff66aa', onesie: true }, + hedgehog: { type: 'animal', hatShape: 'tall', hat: '#886644', onesie: true }, + panda: { type: 'animal', hatShape: 'ears', hat: '#222222', onesie: true }, + hamster: { type: 'animal', hatShape: 'ears', hat: '#ddaa77', onesie: true }, + dinosaur: { type: 'animal', hatShape: 'horns', hat: '#44aa44', onesie: true, tail: true }, + knight: { type: 'armor', hatShape: 'helmet', hat: '#aaaaaa', propShape: 'sword', prop: '#cccccc' }, + gladiator: { type: 'armor', hatShape: 'helmet', hat: '#cc8833', propShape: 'sword', prop: '#dddddd' }, + samurai: { type: 'armor', hatShape: 'helmet', hat: '#882222', propShape: 'sword', prop: '#cccccc' }, + viking: { type: 'armor', hatShape: 'horns', hat: '#886633', propShape: 'shield', prop: '#aa8844' }, + boxer: { type: 'armor', hatShape: 'none', propShape: 'none', facePaint: '#cc2222' }, + wrestler: { type: 'armor', hatShape: 'none', propShape: 'none', facePaint: '#ffcc00' }, + pirate: { type: 'hat', hatShape: 'wide', hat: '#222222', propShape: 'sword', prop: '#dddddd' }, + cowboy: { type: 'hat', hatShape: 'wide', hat: '#aa7744' }, + wizard: { type: 'hat', hatShape: 'pointy', hat: '#4422aa', propShape: 'wand', prop: '#ffcc44' }, + witch: { type: 'hat', hatShape: 'pointy', hat: '#222222', propShape: 'wand', prop: '#44ff44' }, + chef: { type: 'hat', hatShape: 'tall', hat: '#ffffff', propShape: 'food', prop: '#ff8844' }, + detective: { type: 'hat', hatShape: 'wide', hat: '#554433' }, + nurse: { type: 'hat', hatShape: 'none', hat: '#ffffff' }, + firefighter:{ type: 'hat', hatShape: 'helmet', hat: '#ff2222' }, + astronaut: { type: 'hat', hatShape: 'helmet', hat: '#eeeeee' }, + lumberjack: { type: 'hat', hatShape: 'none', hat: '#cc4422', propShape: 'sword', prop: '#886633' }, + scientist: { type: 'hat', hatShape: 'none', propShape: 'remote', prop: '#44cc44' }, + clown: { type: 'hat', hatShape: 'tall', hat: '#ff2222', facePaint: '#ffffff' }, + robot: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + android: { type: 'tech', hatShape: 'antenna', propShape: 'phone' }, + drone_bug: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + toaster: { type: 'tech', hatShape: 'none', propShape: 'remote' }, + tv_head: { type: 'tech', hatShape: 'none', propShape: 'phone', facePaint: '#4488ff' }, + calculator: { type: 'tech', hatShape: 'none', propShape: 'phone' }, + satellite: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + mech: { type: 'tech', hatShape: 'helmet', hat: '#888888', propShape: 'remote' }, + led_cube: { type: 'tech', hatShape: 'none', propShape: 'phone' }, + circuit: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + antenna_bug:{ type: 'tech', hatShape: 'antenna', propShape: 'phone' }, + microwave: { type: 'tech', hatShape: 'none', propShape: 'remote' }, + cyberdog: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + robocat: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + ufo_bot: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + cyborg: { type: 'tech', hatShape: 'antenna', propShape: 'remote' }, + vampire: { type: 'monster', hatShape: 'none', facePaint: '#ffffff' }, + werewolf: { type: 'monster', hatShape: 'ears', hat: '#664422', facePaint: '#664422' }, + zombie: { type: 'monster', hatShape: 'none', facePaint: '#668844' }, + demon: { type: 'monster', hatShape: 'horns', hat: '#cc2222', facePaint: '#cc2222' }, + skeleton: { type: 'monster', hatShape: 'none', facePaint: '#ffffff' }, + ghost: { type: 'monster', hatShape: 'none', onesie: true, hat: '#ffffff' }, + alien: { type: 'monster', hatShape: 'antenna', hat: '#44ff44', facePaint: '#44ff44' }, + minotaur: { type: 'monster', hatShape: 'horns', hat: '#884422' }, + unicorn: { type: 'monster', hatShape: 'horns', hat: '#ff88cc' }, + phoenix: { type: 'monster', hatShape: 'tall', hat: '#ff4400', onesie: true }, + dragon: { type: 'monster', hatShape: 'horns', hat: '#44aa22', onesie: true, tail: true }, + mermaid: { type: 'monster', hatShape: 'none', hat: '#44ccaa', onesie: true }, + griffin: { type: 'monster', hatShape: 'ears', hat: '#cc8822', onesie: true }, + cyclops: { type: 'monster', hatShape: 'none', facePaint: '#8877aa' }, + gargoyle: { type: 'monster', hatShape: 'horns', hat: '#666666', facePaint: '#888888' }, + golem: { type: 'monster', hatShape: 'none', facePaint: '#886644' }, + pizza: { type: 'food', hatShape: 'cone', hat: '#ffcc44' }, + mushroom: { type: 'food', hatShape: 'tall', hat: '#cc2222' }, + cactus: { type: 'food', hatShape: 'tall', hat: '#44aa22' }, + sock_puppet: { type: 'silly', hatShape: 'none', onesie: true, hat: '#ff8844' }, + traffic_cone: { type: 'silly', hatShape: 'cone', hat: '#ff6600' }, + toilet_man: { type: 'silly', hatShape: 'none', propShape: 'sign', prop: '#ffffff' }, + potato: { type: 'silly', hatShape: 'none', hat: '#aa8844', onesie: true }, + cloud_man: { type: 'silly', hatShape: 'none', hat: '#ffffff', onesie: true }, + rock_man: { type: 'silly', hatShape: 'none', hat: '#888888', onesie: true }, + balloon_man: { type: 'silly', hatShape: 'none', propShape: 'sign', prop: '#ff4488' }, + trash_can: { type: 'silly', hatShape: 'none', hat: '#888888', onesie: true }, + rubber_duck: { type: 'silly', hatShape: 'none', hat: '#ffcc00', onesie: true }, + snowman: { type: 'silly', hatShape: 'tall', hat: '#222222', onesie: true }, + scarecrow: { type: 'silly', hatShape: 'wide', hat: '#ddbb66', onesie: true }, + jack_o_lantern:{ type: 'silly', hatShape: 'none', hat: '#ff8800', facePaint: '#ff8800' }, + garden_gnome: { type: 'silly', hatShape: 'pointy', hat: '#ff0000' }, + lamp_post: { type: 'silly', hatShape: 'cone', hat: '#ffee88' }, + broom_man: { type: 'silly', hatShape: 'none', propShape: 'sword', prop: '#886633' }, + ninja: { type: 'hat', hatShape: 'none', facePaint: '#222222', onesie: true, hat: '#222222' }, +} + +const SKIN_TONES = [ + '#ffdbb4', '#f1c27d', '#e0ac69', '#c68642', '#8d5524', '#6b3a1f', + '#ffe0bd', '#ffcd94', '#eac086', '#deb887', '#d2a679', '#a0785a', +] + +const HAIR_COLORS = [ + '#2c1b0e', '#4a3728', '#8b6914', '#c4a35a', '#e6c87f', + '#1a1a1a', '#333333', '#cc2222', '#ff6633', '#ff88cc', '#4488ff', +] + +type PxFn = (x: number, y: number, color: string, ox: number, oy: number) => void + +function seededRng(seed: string) { + let sh = 0 + for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0 + sh = (sh * 31337) | 0 + return () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 } +} + +/** + * Generate human sprite at 3x resolution. winRate (0-1) controls physique: + * 0.0 = fat slobby mess with cheetos, 0.5 = normal, 1.0 = swole superhero with cape + */ +export function generateHumanSpriteSheet( + seed: string, + archetype: string, + primaryColor: string, + secondaryColor: string, + winRate: number = 0.5, +): string { + const canvas = document.createElement('canvas') + canvas.width = HUMAN_FRAME_SIZE * HUMAN_MAX_FRAMES + canvas.height = HUMAN_FRAME_SIZE * HUMAN_ROWS + const ctx = canvas.getContext('2d')! + ctx.imageSmoothingEnabled = false + + const I = HUMAN_INTERNAL + const S = HUMAN_SCALE + const rng = seededRng(seed) + + const skinTone = SKIN_TONES[Math.floor(rng() * SKIN_TONES.length)] + const skinMid = darken(skinTone, 12) + const skinDark = darken(skinTone, 30) + const skinLight = lighten(skinTone, 10) + const hairColor = HAIR_COLORS[Math.floor(rng() * HAIR_COLORS.length)] + const hairDark = darken(hairColor, 20) + const shirtColor = primaryColor + const shirtDark = darken(primaryColor, 15) + const pantsColor = secondaryColor + const pantsDark = darken(secondaryColor, 15) + + const wr = Math.max(0, Math.min(1, winRate)) + const isFat = wr < 0.3 ? true : wr < 0.5 ? rng() > 0.4 : false + const isShort = wr < 0.25 + const isSwole = wr > 0.75 + const isSuperHero = wr > 0.85 + const hasBeard = wr < 0.35 ? true : rng() > 0.6 + const hasGlasses = wr < 0.4 ? true : rng() > 0.5 + const hasStains = wr < 0.3 + const hasCheetos = wr < 0.2 + const hasCape = isSuperHero + const hasHeadband = wr > 0.7 + const hasSweatDrops = wr < 0.4 + + const costume = getCostume(archetype) + const out = '#0a0a0a' + + function px(x: number, y: number, color: string, ox: number, oy: number) { + if (x < 0 || x >= I || y < 0 || y >= I) return + ctx.fillStyle = color + ctx.fillRect(ox + x * S, oy + y * S, S, S) + } + + function fill(x: number, y: number, w: number, h: number, color: string, ox: number, oy: number) { + for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, color, ox, oy) + } + + function box(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) { + for (let i = x - 1; i <= x + w; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) } + for (let i = y; i < y + h; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) } + fill(x, y, w, h, fc, ox, oy) + } + + // Rounded box for softer shapes + function roundBox(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) { + fill(x + 1, y, w - 2, h, fc, ox, oy) + fill(x, y + 1, w, h - 2, fc, ox, oy) + // Outline + for (let i = x + 1; i < x + w - 1; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) } + for (let i = y + 1; i < y + h - 1; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) } + px(x, y, out, ox, oy); px(x + w - 1, y, out, ox, oy) + px(x, y + h - 1, out, ox, oy); px(x + w - 1, y + h - 1, out, ox, oy) + } + + function drawHuman(fx: number, fy: number, pose: string, frame: number, total: number) { + const ox = fx * HUMAN_FRAME_SIZE + const oy = fy * HUMAN_FRAME_SIZE + const t = frame / Math.max(1, total - 1) + const bounce = Math.round(Math.sin(t * Math.PI * 2) * 2) + + const isIdle = pose === 'idle' + const isCheer = pose === 'cheer' + const isPanic = pose === 'panic' + const isRun = pose === 'run' + const isCoach = pose === 'coach' + + const cx = 72 // center x + const ground = 126 // ground line + + // Body dimensions (3x base, adjusted by physique) + const bodyW = isFat ? 42 : isSwole ? 36 : 27 + const bodyH = isShort ? 24 : isSwole ? 33 : 30 + const headW = isFat ? 30 : isSwole ? 28 : 27 + const headH = isFat ? 27 : 24 + const legH = isShort ? 18 : isSwole ? 30 : 27 + const legW = isFat ? 14 : isSwole ? 12 : 9 + const armW = isSwole ? 9 : 6 + const armH = isShort ? 15 : isSwole ? 24 : 21 + const neckH = 3 + + const feetY = ground - 4 + const legsTop = feetY - legH + const bodyTop = legsTop - bodyH + const neckTop = bodyTop - neckH + const headTop = neckTop - headH + + // Pose offsets + const cheerJump = isCheer ? Math.round(Math.abs(Math.sin(t * Math.PI)) * 18) : 0 + const panicShake = isPanic ? Math.round(Math.sin(t * Math.PI * 6) * 4) : 0 + const runBob = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 4) : 0 + const yOff = -(cheerJump + runBob) + (isIdle ? bounce : 0) + const xOff = panicShake + + // Shadow + const shadowW = isFat ? 22 : isSwole ? 18 : 14 + for (let sx = cx - shadowW; sx <= cx + shadowW; sx++) { + px(sx, ground, 'rgba(0,0,0,0.15)', ox, oy) + px(sx, ground + 1, 'rgba(0,0,0,0.08)', ox, oy) + } + + // ===== CAPE (drawn behind everything) ===== + if (hasCape) { + const capeX = cx - Math.floor(bodyW / 2) - 4 + xOff + const capeW2 = bodyW + 8 + for (let iy = bodyTop + yOff; iy < feetY + yOff + 12; iy++) { + const flutter = isRun ? Math.round(Math.sin(t * Math.PI * 3 + iy * 0.15) * 4) : Math.round(Math.sin(iy * 0.1) * 1) + for (let ix = capeX - flutter; ix < capeX + capeW2 + flutter; ix++) { + const shade = (iy + ix) % 5 === 0 ? '#881111' : (iy + ix) % 3 === 0 ? '#aa1818' : '#cc2222' + px(ix, iy, shade, ox, oy) + } + } + // Cape clasp at neck + fill(cx - 3 + xOff, bodyTop + yOff - 2, 6, 3, '#ffd700', ox, oy) + } + + // ===== LEGS ===== + const legGap = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 8) : isPanic ? 6 : 3 + const ll = cx - legGap - Math.floor(legW / 2) + xOff + const rl = cx + legGap - Math.floor(legW / 2) + xOff + + const legColor = costume.onesie ? (costume.hat || shirtColor) : pantsColor + const legColorDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark + + // Left leg + roundBox(ll, legsTop + yOff, legW, legH, legColor, ox, oy) + fill(ll + legW - 2, legsTop + yOff + 2, 2, legH - 4, legColorDk, ox, oy) // shading + // Knee highlight + px(ll + 2, legsTop + Math.floor(legH / 2) + yOff, lighten(legColor, 10), ox, oy) + // Right leg + roundBox(rl, legsTop + yOff, legW, legH, legColor, ox, oy) + fill(rl + legW - 2, legsTop + yOff + 2, 2, legH - 4, legColorDk, ox, oy) + px(rl + 2, legsTop + Math.floor(legH / 2) + yOff, lighten(legColor, 10), ox, oy) + + // Socks visible (between pants and shoes) + if (!costume.onesie) { + fill(ll, feetY + yOff - 3, legW, 3, '#ffffff', ox, oy) + fill(rl, feetY + yOff - 3, legW, 3, '#ffffff', ox, oy) + } + + // Shoes (chunky) + roundBox(ll - 2, feetY + yOff, legW + 5, 5, '#333333', ox, oy) + px(ll + legW + 2, feetY + yOff + 1, '#555555', ox, oy) // shoe highlight + fill(ll - 1, feetY + yOff + 4, legW + 4, 1, '#222222', ox, oy) // sole + roundBox(rl - 2, feetY + yOff, legW + 5, 5, '#333333', ox, oy) + px(rl + legW + 2, feetY + yOff + 1, '#555555', ox, oy) + fill(rl - 1, feetY + yOff + 4, legW + 4, 1, '#222222', ox, oy) + + // Superhero boots + if (isSuperHero) { + fill(ll - 2, feetY + yOff - 6, legW + 5, 6, '#cc2222', ox, oy) + fill(rl - 2, feetY + yOff - 6, legW + 5, 6, '#cc2222', ox, oy) + fill(ll, feetY + yOff - 7, legW + 1, 1, '#ffd700', ox, oy) // gold trim + fill(rl, feetY + yOff - 7, legW + 1, 1, '#ffd700', ox, oy) + } + + // ===== BODY ===== + const bx = cx - Math.floor(bodyW / 2) + xOff + const by = bodyTop + yOff + + const torsoColor = costume.onesie ? (costume.hat || shirtColor) : shirtColor + const torsoDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : shirtDark + + roundBox(bx, by, bodyW, bodyH, torsoColor, ox, oy) + // Body shading (right side darker) + for (let iy = by + 2; iy < by + bodyH - 2; iy++) { + px(bx + bodyW - 2, iy, torsoDk, ox, oy) + px(bx + bodyW - 3, iy, torsoDk, ox, oy) + px(bx + 1, iy, lighten(torsoColor, 8), ox, oy) // left highlight + } + + // Zipper for onesies + if (costume.onesie) { + for (let iy = by + 2; iy < by + bodyH - 2; iy++) { + px(cx + xOff, iy, darken(torsoColor, 25), ox, oy) + px(cx + 1 + xOff, iy, darken(torsoColor, 20), ox, oy) + } + } else { + // Collar + fill(bx + 3, by, bodyW - 6, 3, darken(shirtColor, 10), ox, oy) + fill(bx + Math.floor(bodyW / 2) - 2, by, 4, 4, skinTone, ox, oy) // V-neck + + // Shirt pattern (bigger at 3x) + const patCx = cx + xOff + const patCy = by + Math.floor(bodyH / 2) + 2 + // Draw a big emblem + fill(patCx - 4, patCy - 4, 8, 8, darken(torsoColor, 8), ox, oy) + fill(patCx - 3, patCy - 3, 6, 6, secondaryColor, ox, oy) + fill(patCx - 2, patCy - 2, 4, 4, lighten(secondaryColor, 15), ox, oy) + px(patCx, patCy, '#ffffff', ox, oy) + } + + // Belt + fill(bx, by + bodyH - 3, bodyW, 3, '#443322', ox, oy) + fill(cx - 2 + xOff, by + bodyH - 3, 5, 3, '#ffcc00', ox, oy) // buckle + px(cx + xOff, by + bodyH - 2, '#ffffff', ox, oy) // buckle highlight + + // Food stains (losers) + if (hasStains) { + fill(bx + 4, by + 6, 3, 2, '#cc8822', ox, oy) + fill(bx + bodyW - 8, by + 10, 2, 3, '#cc2222', ox, oy) + fill(bx + 10, by + 4, 2, 2, '#ff8844', ox, oy) + px(bx + bodyW - 5, by + 8, '#884400', ox, oy) + } + + // Belly hanging over belt + if (isFat && wr < 0.25) { + fill(bx + 4, by + bodyH, bodyW - 8, 4, skinTone, ox, oy) + fill(bx + 5, by + bodyH + 3, bodyW - 10, 2, skinDark, ox, oy) + } + + // ===== NECK ===== + fill(cx - 4 + xOff, neckTop + yOff, 8, neckH, skinTone, ox, oy) + px(cx + 3 + xOff, neckTop + yOff + 1, skinDark, ox, oy) + + // ===== ARMS ===== + const armAttach = by + 3 + const armLx = bx - armW - 2 + const armRx = bx + bodyW + 2 + const armSkin = costume.onesie ? (costume.hat || shirtColor) : skinTone + const armSkinDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : skinDark + + if (isCheer) { + const armUpH = Math.round(Math.abs(Math.sin(t * Math.PI)) * 12) + 6 + // Arms reaching up + roundBox(armLx, armAttach - armUpH, armW, armUpH, armSkin, ox, oy) + roundBox(armRx, armAttach - armUpH, armW, armUpH, armSkin, ox, oy) + // Hands (open, fingers spread) + fill(armLx - 2, armAttach - armUpH - 5, armW + 3, 4, skinTone, ox, oy) + fill(armRx - 1, armAttach - armUpH - 5, armW + 3, 4, skinTone, ox, oy) + // Individual fingers + for (let f = 0; f < 4; f++) { + px(armLx - 2 + f * 2, armAttach - armUpH - 6, skinTone, ox, oy) + px(armRx - 1 + f * 2, armAttach - armUpH - 6, skinTone, ox, oy) + } + } else if (isPanic) { + const flapL = Math.round(Math.sin(t * Math.PI * 4) * 12) + const flapR = Math.round(Math.cos(t * Math.PI * 4) * 12) + roundBox(armLx + flapL, armAttach - 8, armW, armH, armSkin, ox, oy) + roundBox(armRx - flapR, armAttach - 10, armW, armH, armSkin, ox, oy) + // Blurred hands (motion) + fill(armLx + flapL - 1, armAttach - 10, armW + 2, 3, skinTone, ox, oy) + fill(armRx - flapR - 1, armAttach - 12, armW + 2, 3, skinTone, ox, oy) + } else if (isCoach) { + const pointExt = Math.round(Math.sin(t * Math.PI) * 8) + // Pointing arm + roundBox(armRx, armAttach, armW + 8 + pointExt, armW, armSkin, ox, oy) + // Pointing finger + fill(armRx + armW + 8 + pointExt, armAttach, 4, armW - 2, skinTone, ox, oy) + px(armRx + armW + 12 + pointExt, armAttach + 1, skinTone, ox, oy) + // Other arm on hip + roundBox(armLx, armAttach + 4, armW, 8, armSkin, ox, oy) + fill(armLx + 2, armAttach + 11, armW - 2, 3, skinTone, ox, oy) // hand on hip + } else if (isRun) { + const pump = Math.round(Math.sin(t * Math.PI * 2) * 8) + roundBox(armLx, armAttach + pump, armW, armH - 6, armSkin, ox, oy) + roundBox(armRx, armAttach - pump, armW, armH - 6, armSkin, ox, oy) + // Fists + fill(armLx, armAttach + pump + armH - 8, armW + 1, 3, skinTone, ox, oy) + fill(armRx, armAttach - pump + armH - 8, armW + 1, 3, skinTone, ox, oy) + } else { + // Idle: arms hanging + roundBox(armLx, armAttach, armW, armH, armSkin, ox, oy) + roundBox(armRx, armAttach, armW, armH, armSkin, ox, oy) + // Arm shading + for (let iy = armAttach + 2; iy < armAttach + armH - 2; iy++) { + px(armLx + armW - 1, iy, armSkinDk, ox, oy) + px(armRx + armW - 1, iy, armSkinDk, ox, oy) + } + // Hands + fill(armLx, armAttach + armH - 4, armW + 1, 4, skinTone, ox, oy) + fill(armRx - 1, armAttach + armH - 4, armW + 1, 4, skinTone, ox, oy) + // Fingers (3 visible) + for (let f = 0; f < 3; f++) { + px(armLx + f * 2, armAttach + armH, skinTone, ox, oy) + px(armRx - 1 + f * 2, armAttach + armH, skinTone, ox, oy) + } + + // Phone or cheetos in right hand + if (hasCheetos) { + roundBox(armRx - 1, armAttach + armH + 1, 8, 12, '#ff8800', ox, oy) + fill(armRx, armAttach + armH + 2, 6, 3, '#ffcc00', ox, oy) // label + fill(armRx + 1, armAttach + armH + 6, 4, 2, '#ffaa00', ox, oy) // crumbs + // Cheese dust on fingers + px(armRx, armAttach + armH - 1, '#ff8844', ox, oy) + px(armRx + 2, armAttach + armH - 1, '#ff8844', ox, oy) + } else { + // Gamepad controller + const gpX = armRx - 3 + const gpY = armAttach + armH - 2 + roundBox(gpX, gpY, 14, 8, '#333344', ox, oy) // body + fill(gpX + 1, gpY + 1, 12, 6, '#2a2a3a', ox, oy) // inset + // Grips + roundBox(gpX - 2, gpY + 2, 3, 6, '#333344', ox, oy) + roundBox(gpX + 13, gpY + 2, 3, 6, '#333344', ox, oy) + // D-pad (left side) + fill(gpX + 2, gpY + 3, 3, 1, '#555566', ox, oy) + fill(gpX + 3, gpY + 2, 1, 3, '#555566', ox, oy) + // Buttons (right side) + px(gpX + 9, gpY + 2, '#ff4444', ox, oy) + px(gpX + 11, gpY + 2, '#4488ff', ox, oy) + px(gpX + 10, gpY + 1, '#ffcc00', ox, oy) + px(gpX + 10, gpY + 3, '#44cc44', ox, oy) + // Wire coming out the top + const wireX = gpX + 7 + for (let w = 0; w < 14; w++) { + const wobble = Math.round(Math.sin(w * 0.6 + t * Math.PI * 2) * 2) + px(wireX + wobble, gpY - 1 - w, '#444444', ox, oy) + } + } + } + + // ===== PROP (costume-specific, bigger) ===== + if (costume.propShape !== 'none' && costume.prop && !isCheer && !isPanic) { + const propX = armRx + armW + 3 + const propY = armAttach + 3 + if (costume.propShape === 'sword') { + // Blade + for (let i = 0; i < 22; i++) { px(propX + i, propY - i, costume.prop, ox, oy); px(propX + i, propY - i - 1, lighten(costume.prop, 15), ox, oy) } + // Guard + fill(propX - 1, propY + 1, 5, 2, '#886633', ox, oy) + fill(propX - 2, propY + 3, 2, 6, '#664422', ox, oy) // handle + px(propX - 1, propY + 8, '#ffcc44', ox, oy) // pommel + } else if (costume.propShape === 'wand') { + for (let i = 0; i < 16; i++) px(propX, propY - i, '#886633', ox, oy) + // Sparkle at tip + const sparkle = ['#ffcc44', '#ffffff', '#ffee88'] + for (let dx = -2; dx <= 2; dx++) for (let dy = -2; dy <= 2; dy++) { + if (Math.abs(dx) + Math.abs(dy) <= 2) px(propX + dx, propY - 17 + dy, sparkle[(dx + dy + 4) % 3], ox, oy) + } + } else if (costume.propShape === 'shield') { + roundBox(propX, propY - 6, 12, 15, costume.prop, ox, oy) + fill(propX + 3, propY - 3, 6, 9, lighten(costume.prop, 15), ox, oy) + fill(propX + 5, propY - 1, 2, 5, '#ffffff', ox, oy) // emblem + } else if (costume.propShape === 'sign') { + for (let i = 0; i < 20; i++) px(propX + 2, propY - i, '#886633', ox, oy) // pole + roundBox(propX - 6, propY - 30, 18, 12, costume.prop, ox, oy) // sign board + } else if (costume.propShape === 'phone' || costume.propShape === 'remote') { + roundBox(propX, propY, 5, 8, '#333333', ox, oy) + fill(propX + 1, propY + 1, 3, 5, '#44ff44', ox, oy) + px(propX + 2, propY + 2, '#88ff88', ox, oy) + } else if (costume.propShape === 'food') { + roundBox(propX, propY - 2, 8, 6, costume.prop, ox, oy) + fill(propX + 1, propY - 4, 6, 2, '#44aa22', ox, oy) // lettuce + px(propX + 3, propY - 5, '#ff4444', ox, oy) // cherry on top + } + } + + // ===== HEAD ===== + const hx = cx - Math.floor(headW / 2) + xOff + const hy = headTop + yOff + + const faceColor = costume.facePaint || skinTone + const faceDark = costume.facePaint ? darken(costume.facePaint, 15) : skinDark + const faceLight = costume.facePaint ? lighten(costume.facePaint, 10) : skinLight + + roundBox(hx, hy, headW, headH, faceColor, ox, oy) + // Head shading + for (let iy = hy + 2; iy < hy + headH - 2; iy++) { + px(hx + headW - 2, iy, faceDark, ox, oy) + px(hx + headW - 3, iy, faceDark, ox, oy) + px(hx + 1, iy, faceLight, ox, oy) + } + + // Ears + if (!costume.facePaint) { + fill(hx - 2, hy + Math.floor(headH * 0.3), 2, 5, skinTone, ox, oy) + px(hx - 2, hy + Math.floor(headH * 0.3) + 1, skinDark, ox, oy) // inner ear + fill(hx + headW, hy + Math.floor(headH * 0.3), 2, 5, skinTone, ox, oy) + px(hx + headW + 1, hy + Math.floor(headH * 0.3) + 1, skinDark, ox, oy) + } + + // Hair + if (!costume.onesie || costume.type !== 'animal') { + // Top hair (fluffy) + for (let ix = hx; ix < hx + headW; ix++) { + px(ix, hy, hairColor, ox, oy) + px(ix, hy + 1, hairColor, ox, oy) + px(ix, hy + 2, hairColor, ox, oy) + } + for (let ix = hx + 1; ix < hx + headW - 1; ix++) { + px(ix, hy - 1, hairColor, ox, oy) + } + // Hair volume on sides + fill(hx - 1, hy, 2, 5, hairColor, ox, oy) + fill(hx + headW - 1, hy, 2, 5, hairColor, ox, oy) + // Hair highlights + px(hx + 3, hy, lighten(hairColor, 15), ox, oy) + px(hx + 4, hy, lighten(hairColor, 15), ox, oy) + px(hx + 5, hy - 1, lighten(hairColor, 10), ox, oy) + // Messy hair for losers + if (wr < 0.3) { + px(hx + 2, hy - 2, hairColor, ox, oy) + px(hx + headW - 4, hy - 3, hairColor, ox, oy) + px(hx - 1, hy - 1, hairColor, ox, oy) + } + } + + // ===== EYES ===== + const eyeY = hy + Math.floor(headH * 0.35) + const leX = hx + 5 + const reX = hx + headW - 11 + + if (isPanic) { + // Wide terrified eyes + fill(leX - 1, eyeY - 2, 8, 7, '#ffffff', ox, oy) + fill(reX - 1, eyeY - 2, 8, 7, '#ffffff', ox, oy) + // Tiny pupils (fear) + fill(leX + 2, eyeY + 1, 2, 2, '#000000', ox, oy) + fill(reX + 2, eyeY + 1, 2, 2, '#000000', ox, oy) + // Eyebrow (raised) + fill(leX - 1, eyeY - 4, 7, 2, hairColor, ox, oy) + fill(reX - 1, eyeY - 4, 7, 2, hairColor, ox, oy) + } else if (isCheer) { + // Happy squinted eyes + fill(leX, eyeY, 6, 2, '#000000', ox, oy) + fill(reX, eyeY, 6, 2, '#000000', ox, oy) + fill(leX, eyeY - 1, 6, 1, '#ffffff', ox, oy) + fill(reX, eyeY - 1, 6, 1, '#ffffff', ox, oy) + // Raised cheeks + fill(leX - 1, eyeY + 2, 3, 2, '#ff8888', ox, oy) + fill(reX + 4, eyeY + 2, 3, 2, '#ff8888', ox, oy) + } else { + // Normal eyes with iris detail + fill(leX, eyeY, 6, 5, '#ffffff', ox, oy) + fill(reX, eyeY, 6, 5, '#ffffff', ox, oy) + // Iris + fill(leX + 2, eyeY + 1, 3, 3, '#4466aa', ox, oy) + fill(reX + 2, eyeY + 1, 3, 3, '#4466aa', ox, oy) + // Pupil + fill(leX + 3, eyeY + 2, 2, 2, '#000000', ox, oy) + fill(reX + 3, eyeY + 2, 2, 2, '#000000', ox, oy) + // Eye highlight + px(leX + 2, eyeY + 1, '#ffffff', ox, oy) + px(reX + 2, eyeY + 1, '#ffffff', ox, oy) + // Eyebrows + fill(leX, eyeY - 2, 6, 2, hairColor, ox, oy) + fill(reX, eyeY - 2, 6, 2, hairColor, ox, oy) + // Angry eyebrows for coach + if (isCoach) { + px(leX, eyeY - 3, hairColor, ox, oy) + px(reX + 5, eyeY - 3, hairColor, ox, oy) + } + } + + // Glasses + if (hasGlasses) { + // Frames + for (let ix = leX - 2; ix <= reX + 7; ix++) px(ix, eyeY - 1, '#444444', ox, oy) + // Left lens + for (let iy = eyeY - 1; iy <= eyeY + 4; iy++) { px(leX - 2, iy, '#444444', ox, oy); px(leX + 6, iy, '#444444', ox, oy) } + px(leX - 2, eyeY + 4, '#444444', ox, oy); px(leX + 6, eyeY + 4, '#444444', ox, oy) + for (let ix = leX - 1; ix <= leX + 5; ix++) px(ix, eyeY + 4, '#444444', ox, oy) + // Right lens + for (let iy = eyeY - 1; iy <= eyeY + 4; iy++) { px(reX - 2, iy, '#444444', ox, oy); px(reX + 6, iy, '#444444', ox, oy) } + for (let ix = reX - 1; ix <= reX + 5; ix++) px(ix, eyeY + 4, '#444444', ox, oy) + // Lens reflection + px(leX, eyeY, '#aaccff', ox, oy) + px(reX, eyeY, '#aaccff', ox, oy) + } + + // Nose + const noseY = hy + Math.floor(headH * 0.55) + px(cx + xOff, noseY, skinDark, ox, oy) + px(cx + 1 + xOff, noseY, skinDark, ox, oy) + px(cx + xOff, noseY + 1, skinDark, ox, oy) + // Red nose for clown + if (costume.type === 'hat' && archetype === 'clown') { + fill(cx - 1 + xOff, noseY - 1, 4, 4, '#ff0000', ox, oy) + } + + // Mouth + const mY = hy + Math.floor(headH * 0.72) + if (isCheer) { + // Big grin with teeth + fill(cx - 5 + xOff, mY, 10, 4, '#000000', ox, oy) + fill(cx - 4 + xOff, mY, 8, 2, '#ffffff', ox, oy) // teeth + fill(cx - 4 + xOff, mY + 2, 8, 2, '#cc4444', ox, oy) // tongue + } else if (isPanic) { + // Screaming + roundBox(cx - 4 + xOff, mY - 2, 8, 8, '#000000', ox, oy) + fill(cx - 3 + xOff, mY + 2, 6, 2, '#cc4444', ox, oy) // tongue + // Teeth + px(cx - 3 + xOff, mY - 1, '#ffffff', ox, oy) + px(cx + 2 + xOff, mY - 1, '#ffffff', ox, oy) + } else if (isCoach) { + // Yelling + fill(cx - 4 + xOff, mY, 8, 5, '#000000', ox, oy) + fill(cx - 3 + xOff, mY, 6, 2, '#ffffff', ox, oy) + } else { + // Neutral / slight smile + fill(cx - 3 + xOff, mY, 6, 2, '#0a0a0a', ox, oy) + px(cx - 4 + xOff, mY + 1, '#0a0a0a', ox, oy) + px(cx + 3 + xOff, mY + 1, '#0a0a0a', ox, oy) + // Drool for very low win rate + if (wr < 0.15) { + px(cx + 3 + xOff, mY + 2, '#88ccff', ox, oy) + px(cx + 3 + xOff, mY + 3, '#88ccff', ox, oy) + px(cx + 3 + xOff, mY + 4, '#66aadd', ox, oy) + } + } + + // Sweat drops + if (hasSweatDrops && (isIdle || isPanic || isCoach)) { + fill(hx + headW + 2, eyeY + 2, 2, 4, '#88ccff', ox, oy) + px(hx + headW + 2, eyeY + 6, '#66aadd', ox, oy) + if (wr < 0.2) { + fill(hx - 3, eyeY + 4, 2, 3, '#88ccff', ox, oy) + // Puddle forming + fill(hx + headW + 1, eyeY + 7, 4, 1, '#aaddff', ox, oy) + } + } + + // Double chin + if (isFat && wr < 0.25) { + fill(hx + 4, hy + headH, headW - 8, 4, skinTone, ox, oy) + fill(hx + 5, hy + headH + 3, headW - 10, 2, skinDark, ox, oy) + fill(hx + 6, hy + headH + 4, headW - 12, 2, skinDark, ox, oy) + } + + // Beard + if (hasBeard && !costume.facePaint) { + // Scrappy neckbeard for losers, cool beard for winners + if (wr < 0.35) { + // Patchy neckbeard + for (let ix = hx + 3; ix < hx + headW - 3; ix++) { + if ((ix + hy) % 3 !== 0) px(ix, hy + headH - 2, hairDark, ox, oy) + if ((ix + hy) % 2 !== 0) px(ix, hy + headH - 1, hairDark, ox, oy) + if ((ix + hy) % 3 === 0) px(ix, hy + headH, hairDark, ox, oy) + } + } else { + // Full beard + for (let ix = hx + 3; ix < hx + headW - 3; ix++) { + fill(ix, hy + headH - 3, 1, 5, hairColor, ox, oy) + } + fill(cx - 2 + xOff, hy + headH + 1, 4, 2, hairDark, ox, oy) + } + } + + // Headband (winners) + if (hasHeadband && costume.hatShape === 'none') { + const hbColor = wr > 0.85 ? '#ffd700' : '#ff2222' + fill(hx - 2, hy + 3, headW + 4, 3, hbColor, ox, oy) + // Trailing ribbons + px(hx - 3, hy + 4, hbColor, ox, oy) + px(hx - 4, hy + 5, hbColor, ox, oy) + px(hx - 5, hy + 5 + (isIdle ? bounce : 0), hbColor, ox, oy) + px(hx - 6, hy + 6 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy) + px(hx - 7, hy + 5 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy) + } + + // Winner glow aura + if (isSuperHero && !isPanic) { + for (let a = 0; a < 16; a++) { + const angle = a * Math.PI * 2 / 16 + t * Math.PI * 0.5 + const r = 40 + Math.sin(t * Math.PI * 4 + a * 0.8) * 8 + const gx = cx + xOff + Math.round(Math.cos(angle) * r) + const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(angle) * r) + fill(gx - 1, gy - 1, 3, 3, '#ffcc4430', ox, oy) + px(gx, gy, '#ffcc4460', ox, oy) + } + } + + // ===== COSTUME HAT/ACCESSORIES ===== + const hatColor = costume.hat || hairColor + const hatDk = darken(hatColor, 15) + const hatLt = lighten(hatColor, 15) + + if (costume.hatShape === 'ears') { + // Big floppy costume ears + fill(hx - 1, hy - 5, 5, 6, hatColor, ox, oy) + fill(hx + headW - 4, hy - 5, 5, 6, hatColor, ox, oy) + fill(hx, hy - 4, 3, 4, hatLt, ox, oy) // inner + fill(hx + headW - 3, hy - 4, 3, 4, hatLt, ox, oy) + } else if (costume.hatShape === 'helmet') { + for (let ix = hx - 3; ix < hx + headW + 3; ix++) { + px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 2, hatColor, ox, oy) + px(ix, hy - 1, hatColor, ox, oy); px(ix, hy, hatColor, ox, oy) + } + for (let ix = hx - 1; ix < hx + headW + 1; ix++) { + px(ix, hy - 4, hatColor, ox, oy); px(ix, hy - 5, hatColor, ox, oy) + } + fill(hx + 2, hy - 4, 4, 2, hatLt, ox, oy) // highlight + // Visor + fill(hx + 2, hy + 1, headW - 4, 3, '#33333388', ox, oy) + } else if (costume.hatShape === 'pointy') { + for (let h = 0; h < 18; h++) { + const w = Math.max(1, Math.floor((headW + 2) * (1 - h / 18))) + const sx2 = cx + xOff - Math.floor(w / 2) + for (let ix = sx2; ix < sx2 + w; ix++) px(ix, hy - 1 - h, hatColor, ox, oy) + } + // Stars and moons on the hat + px(cx - 3 + xOff, hy - 8, '#ffcc44', ox, oy) + px(cx + 4 + xOff, hy - 12, '#ffcc44', ox, oy) + px(cx + xOff, hy - 16, '#ffffff', ox, oy) // star tip + fill(cx - 1 + xOff, hy - 18, 3, 3, '#ffcc44', ox, oy) + } else if (costume.hatShape === 'wide') { + for (let ix = hx - 8; ix < hx + headW + 8; ix++) { + px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 2, hatColor, ox, oy) + } + for (let ix = hx - 3; ix < hx + headW + 3; ix++) { + px(ix, hy - 4, hatColor, ox, oy); px(ix, hy - 5, hatColor, ox, oy) + px(ix, hy - 6, hatColor, ox, oy); px(ix, hy - 7, hatColor, ox, oy) + } + // Hat band + fill(hx - 2, hy - 4, headW + 4, 2, hatDk, ox, oy) + } else if (costume.hatShape === 'tall') { + for (let h = 0; h < 16; h++) { + for (let ix = hx + 2; ix < hx + headW - 2; ix++) px(ix, hy - 1 - h, hatColor, ox, oy) + } + fill(hx + 3, hy - 14, headW - 8, 2, hatLt, ox, oy) // puff/highlight + } else if (costume.hatShape === 'horns') { + // Chunky horns + for (let h = 0; h < 8; h++) { + px(hx - h, hy - 1 - h, hatColor, ox, oy); px(hx - h - 1, hy - 1 - h, hatColor, ox, oy) + px(hx + headW - 1 + h, hy - 1 - h, hatColor, ox, oy); px(hx + headW + h, hy - 1 - h, hatColor, ox, oy) + } + px(hx - 8, hy - 9, hatLt, ox, oy); px(hx + headW + 8, hy - 9, hatLt, ox, oy) // tips + } else if (costume.hatShape === 'antenna') { + // Bouncy antenna headband + fill(hx + 2, hy - 2, headW - 4, 2, '#888888', ox, oy) // headband + const bobX = isIdle ? Math.round(Math.sin(t * Math.PI * 3) * 2) : 0 + // Left antenna + for (let h = 0; h < 10; h++) px(hx + 4 + bobX, hy - 3 - h, '#888888', ox, oy) + fill(hx + 2 + bobX, hy - 14, 5, 4, '#ff2222', ox, oy) // left ball + px(hx + 3 + bobX, hy - 13, '#ff6666', ox, oy) // highlight + // Right antenna + for (let h = 0; h < 10; h++) px(hx + headW - 5 - bobX, hy - 3 - h, '#888888', ox, oy) + fill(hx + headW - 7 - bobX, hy - 14, 5, 4, '#ff2222', ox, oy) + px(hx + headW - 6 - bobX, hy - 13, '#ff6666', ox, oy) + } else if (costume.hatShape === 'cone') { + for (let h = 0; h < 20; h++) { + const w = Math.max(2, 18 - h) + const sx2 = cx + xOff - Math.floor(w / 2) + const stripe = (h % 6 < 3) ? '#ffffff' : hatColor + for (let ix = sx2; ix < sx2 + w; ix++) px(ix, hy - 1 - h, stripe, ox, oy) + } + } + + // Tail (costume) + if (costume.tail) { + const tailColor = costume.hat || shirtColor + const tx = bx - 4 + for (let i = 0; i < 8; i++) { + px(tx - i, by + bodyH - 4 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2) * 2), tailColor, ox, oy) + px(tx - i, by + bodyH - 3 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2) * 2), tailColor, ox, oy) + } + px(tx - 8, by + bodyH - 4, darken(tailColor, 20), ox, oy) // tail tip + } + } + + // Render all frames + const entries = Object.entries(HUMAN_ANIMATIONS) as [string, { frames: number; row: number }][] + for (let row = 0; row < entries.length; row++) { + const [pose, cfg] = entries[row] + for (let f = 0; f < cfg.frames; f++) drawHuman(f, row, pose, f, cfg.frames) + } + + return canvas.toDataURL() +} + +function getCostume(archetype: string): CostumeConfig { + const raw = COSTUME_MAP[archetype] || {} + return { + type: raw.type || 'generic', + hat: raw.hat, + hatShape: raw.hatShape || 'none', + prop: raw.prop, + propShape: raw.propShape || 'none', + onesie: raw.onesie || false, + facePaint: raw.facePaint, + tail: raw.tail || false, + } +} + +function darken(hex: string, amount: number): string { + if (hex.startsWith('hsl')) { + const m = hex.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/) + if (m) return `hsl(${m[1]}, ${m[2]}%, ${Math.max(0, +m[3] - amount)}%)` + } + if (hex.length < 7) return hex + const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount * 2.55) + const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - amount * 2.55) + const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - amount * 2.55) + return `#${Math.round(r).toString(16).padStart(2, '0')}${Math.round(g).toString(16).padStart(2, '0')}${Math.round(b).toString(16).padStart(2, '0')}` +} + +function lighten(hex: string, amount: number): string { + if (hex.startsWith('hsl')) { + const m = hex.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/) + if (m) return `hsl(${m[1]}, ${m[2]}%, ${Math.min(95, +m[3] + amount)}%)` + } + if (hex.length < 7) return hex + const r = Math.min(255, parseInt(hex.slice(1, 3), 16) + amount * 2.55) + const g = Math.min(255, parseInt(hex.slice(3, 5), 16) + amount * 2.55) + const b = Math.min(255, parseInt(hex.slice(5, 7), 16) + amount * 2.55) + return `#${Math.round(r).toString(16).padStart(2, '0')}${Math.round(g).toString(16).padStart(2, '0')}${Math.round(b).toString(16).padStart(2, '0')}` +} diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts index 996d8b5..0428c0b 100644 --- a/frontend/src/game/sprites/index.ts +++ b/frontend/src/game/sprites/index.ts @@ -7,6 +7,7 @@ export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } from './constants' export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants' export { getBotColors } from './palette' export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge' +export { generateHumanSpriteSheet, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, HUMAN_FRAME_SIZE } from './human' export { archetypes, rollArchetype } from './archetypes' export interface SpriteCustomization { diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index 6ef8d7b..ffa4cbb 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { useRoute, useRouter, RouterLink } from 'vue-router' import { useNostr } from '../composables/useNostr' import SpritePreview from '../components/SpritePreview.vue' +import HumanPreview from '../components/HumanPreview.vue' import type { SpriteCustomization } from '../game/sprites' const route = useRoute() @@ -278,25 +279,34 @@ const tierClass = (t: number) => `tier-${t}`