Files
botfights/frontend/src/game/FightScene.ts
T

3482 lines
161 KiB
TypeScript
Raw Normal View History

import kaplay from 'kaplay'
import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp, LoadSpriteOpt } from 'kaplay'
import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, generateHumanFighterSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites'
import type { Fighter, FightSceneConfig, RoundEvent, FightContext, ChoreoContext } from './fight/types'
2026-03-08 22:16:50 +00:00
export type { FightSceneConfig, RoundEvent } from './fight/types'
import { ARENA_THEMES, spriteAnims, CHALLENGE_THEMED, TIER_ULTIMATES, CREATOR_MOVES, CREATOR_ULTIMATES } from './fight/constants'
import {
CREATOR_ULTIMATE_CHANCE, CREATOR_THEMED_MOVE_RATIO,
CRIT_THEMED_RATIO, CHOREOGRAPHY_THEMED_RATIO, CHOREOGRAPHY_GENERIC_CUTOFF,
TIER_ULTIMATE_CHANCES, isPerfMode, perfParticles, isReducedMotion, haptic,
} from './fight/config'
import { spawnSparks as _spawnSparks, spawnBulletHoles as _spawnBulletHoles, spawnExhaust as _spawnExhaust } from './fight/particles'
import { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking as _vhsTracking, dimensionalShift as _dimensionalShift } from './fight/effects'
import { spawnProjectile as _spawnProjectile } from './fight/projectiles'
import { createChoreographyMap } from './fight/choreography'
import { createFactories } from './fight/choreography/factories'
import { createMorphSystem } from './fight/morphs'
import { createEntranceSystem } from './fight/entrances'
import { createRoundSystem } from './fight/rounds'
import { createFinisherSystem } from './fight/finishers'
import {
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
2026-03-08 23:46:30 +00:00
sfxJetpack, sfxExplosion, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly,
sfxBoing, sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh,
sfxRapidPunch, sfxPowerUp, sfxCoin, sfxRandomComedy,
startMusic, stopMusic, announceCool,
} from './audio'
export function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string {
// THE CREATOR: special move selection
if (attackerArchetype === 'the_creator') {
const ultimateChance = isCritical ? 1.0 : CREATOR_ULTIMATE_CHANCE
if (Math.random() < ultimateChance) {
// Creator gets ALL ultimates + their 4 exclusive ones
const all: string[] = [...CREATOR_ULTIMATES]
for (let t = 2; t <= 5; t++) {
if (TIER_ULTIMATES[t]) all.push(...TIER_ULTIMATES[t])
}
return all[Math.floor(Math.random() * all.length)]
}
if (Math.random() < CREATOR_THEMED_MOVE_RATIO) {
return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)]
}
// Fall through to normal selection
}
// Tier-gated ultimates: higher tier = higher chance of spectacular ultimate moves
if (attackerTier >= 2) {
const ultimateChance = TIER_ULTIMATE_CHANCES[attackerTier] ?? TIER_ULTIMATE_CHANCES[5]
// On crits, double the chance
const roll = isCritical ? ultimateChance * 2 : ultimateChance
if (Math.random() < roll) {
// Collect all ultimates available at this tier (cumulative — higher tiers get lower tier moves too)
const available: string[] = []
for (let t = 2; t <= attackerTier; t++) {
if (TIER_ULTIMATES[t]) available.push(...TIER_ULTIMATES[t])
}
if (available.length > 0) {
return available[Math.floor(Math.random() * available.length)]
}
}
}
// Critical hits always get BIG moves (but can still be themed)
if (isCritical) {
const entry = CHALLENGE_THEMED[challengeType]
if (entry && Math.random() < CRIT_THEMED_RATIO) {
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
}
const critMoves = [
'groundPound', 'jetpackDive', 'gunBurst', 'fullScreenDash', 'multiHit', 'fireBreath',
'lightningRush', 'zoomRush', 'rapidFlurry', 'rocketLauncher', 'laserBeam', 'katanaCombo',
'hammerSmash', 'sniperShot', 'minigunSpray', 'chainsawRev', 'carSmash', 'motorbikeCharge',
'anvilDrop', 'pocketCannon', 'bodySlam', 'suplex', 'pinballCombo', 'grappleFlurry',
// New epic crits
'kamehameha', 'nukeStrike', 'tankRoll', 'helicopterStrike', 'chokeslam', 'tombstone',
'rko', 'piledriver', 'powerbomb', 'meteorStrike', 'spiritBomb', 'blackHole',
'alienAbduction', 'tsunamiWave', 'earthquakeStrike', 'dragonBreath', 'pianoDrop',
'fridgeDrop', 'volcanoErupt', 'warpDrive', 'dubstepCannon', 'tornadoSpin',
'trampolineBounce', 'watermelonBomb', 'nftRugPull', 'blueScreen', 'yeetThrow',
'bulletTimeAttack',
]
return critMoves[Math.floor(Math.random() * critMoves.length)]
}
const entry = CHALLENGE_THEMED[challengeType]
if (!entry) {
const fallback = ['dashPunch', 'flyingKick', 'aerialSlam', 'backflipKick', 'fingerGuns', 'rubberChicken', 'pizzaSlam', 'fryingPan', 'snowballFight']
return fallback[Math.floor(Math.random() * fallback.length)]
}
const roll = Math.random()
if (roll < CHOREOGRAPHY_THEMED_RATIO) {
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
} else if (roll < CHOREOGRAPHY_GENERIC_CUTOFF) {
return entry.generic[Math.floor(Math.random() * entry.generic.length)]
} else {
const wild = [
'jetpackDive', 'gunBurst', 'groundPound', 'teleportStrike', 'fullScreenDash', 'fireBreath',
'golfClubSmash', 'zoomRush', 'rapidFlurry', 'swordSlash', 'hammerSmash', 'laserBeam',
'rocketLauncher', 'bombThrow', 'minigunSpray', 'sniperShot', 'whipCrack', 'katanaCombo',
'chainsawRev', 'motorbikeCharge', 'carSmash', 'boatCannon', 'anvilDrop', 'pocketCannon',
'grappleFlurry', 'bodySlam', 'pinballCombo', 'suplex',
// New wild cards — food
'pizzaSlam', 'bananaFling', 'pieSmash', 'burgerToss', 'tomatoBarrage', 'fishSlap',
// New wild cards — weapons
'fryingPan', 'baseballBat', 'guitarSmash', 'umbrellaWhack', 'plungerSlam', 'wrenchSmash',
// New wild cards — drops
'pianoDrop', 'toiletDrop', 'tvDrop', 'chandelierDrop', 'micDrop',
// New wild cards — vehicles
'shoppingCart', 'zamboniCrush', 'unicycleRun', 'tankRoll',
// New wild cards — spins/flips
'tornadoSpin', 'backflipKick', 'corkscrewDive', 'barrelRoll', 'skateTrick',
// New wild cards — beams
'kamehameha', 'freezeRay', 'heatVision', 'rainbowBeam', 'glitchBeam',
// New wild cards — silly
'rubberChicken', 'selfieStrike', 'fingerGuns', 'yeetThrow', 'emojiBarrage', 'touchGrass',
// New wild cards — wrestling
'clothesline', 'rko', 'stunner', 'piledriver',
// New wild cards — magic
'fireball', 'portalPunch', 'crystalShards', 'spiritBomb',
// New wild cards — tech
'stackOverflow', 'blueScreen', 'aiUprising', 'popupSpam',
// New wild cards — music
'bassDrop', 'dubstepCannon', 'vinylScratch',
// New wild cards — nature
'avalanche', 'lavaSplash', 'tornadoFling',
]
return wild[Math.floor(Math.random() * wild.length)]
}
}
// Convert any CSS color string to a kaplay Color object safely
function safeColor(k: ReturnType<typeof kaplay>, color: string) {
if (color.startsWith('#')) {
try { return k.Color.fromHex(color) } catch { /* fall through */ }
}
// HSL / rgb / named color — render to a 1px canvas to extract RGB
const cv = document.createElement('canvas')
cv.width = 1; cv.height = 1
const cx = cv.getContext('2d')!
cx.fillStyle = color
cx.fillRect(0, 0, 1, 1)
const [r, g, b] = cx.getImageData(0, 0, 1, 1).data
return k.Color.fromArray([r, g, b])
}
export async function createFightScene(config: FightSceneConfig) {
const { canvas, botA, botB, arena } = config
const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost
const k = kaplay({
canvas,
width: canvas.width || 800,
height: canvas.height || 500,
background: theme.bg,
global: false,
scale: 1,
crisp: true,
texFilter: 'nearest',
})
// Wrap k.shake to support reduced motion + haptic feedback
const _origShake = k.shake.bind(k)
k.shake = ((intensity: number) => {
if (intensity >= 3) haptic(Math.min(100, intensity * 8))
if (!isReducedMotion()) _origShake(intensity)
}) as typeof k.shake
const colorsA = getBotColors(botA.seed)
const colorsB = getBotColors(botB.seed)
let sheetA: string
let sheetB: string
// Human fighters use their profile human character, not a bot sprite
const winRateA = (botA.wins || 0) / Math.max(1, (botA.wins || 0) + (botA.losses || 0))
const winRateB = (botB.wins || 0) / Math.max(1, (botB.wins || 0) + (botB.losses || 0))
try {
sheetA = botA.archetype === 'human'
? generateHumanFighterSpriteSheet(botA.seed, botA.archetype, colorsA.primary, colorsA.secondary, winRateA)
: generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization)
} catch (err) {
console.error('[FightScene] Failed to generate sprite for botA:', err)
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
}
try {
sheetB = botB.archetype === 'human'
? generateHumanFighterSpriteSheet(botB.seed, botB.archetype, colorsB.primary, colorsB.secondary, winRateB)
: generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization)
} catch (err) {
console.error('[FightScene] Failed to generate sprite for botB:', err)
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
}
const isHumanA = botA.archetype === 'human'
const isHumanB = botB.archetype === 'human'
// Helper: load sprite with a 5s timeout so mobile never hangs forever
async function loadSpriteWithTimeout(name: string, src: string, opts: LoadSpriteOpt): Promise<void> {
try {
await Promise.race([
k.loadSprite(name, src, opts),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
])
} catch (err) {
console.warn(`[FightScene] Sprite '${name}' failed/timed out:`, err)
}
}
// Await sprite loading — timeout prevents mobile hangs
await Promise.all([
loadSpriteWithTimeout('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
loadSpriteWithTimeout('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
])
// Preload bot sprites for human fighters (used when humans morph into bots)
if (isHumanA) {
const botSheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
loadSpriteWithTimeout('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
}
if (isHumanB) {
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
loadSpriteWithTimeout('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
}
// Judge sprite (red lobster referee)
const judgeSheet = generateJudgeSpriteSheet()
const judgeAnims = {
idle: { from: 0, to: JUDGE_ANIMATIONS.idle.frames - 1, loop: true, speed: 4 },
call_left: { from: JUDGE_MAX_FRAMES, to: JUDGE_MAX_FRAMES + JUDGE_ANIMATIONS.call_left.frames - 1, loop: false, speed: 8 },
call_right: { from: JUDGE_MAX_FRAMES * 2, to: JUDGE_MAX_FRAMES * 2 + JUDGE_ANIMATIONS.call_right.frames - 1, loop: false, speed: 8 },
shocked: { from: JUDGE_MAX_FRAMES * 3, to: JUDGE_MAX_FRAMES * 3 + JUDGE_ANIMATIONS.shocked.frames - 1, loop: false, speed: 10 },
}
await loadSpriteWithTimeout('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
// Human sprites (the bot owner's silly human avatar for crowd)
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
const humanSheetB = generateHumanSpriteSheet(botB.seed, botB.archetype || 'standard', colorsB.primary, colorsB.secondary, winRateB)
const humanAnims: Record<string, { from: number; to: number; loop: boolean; speed: number }> = {}
for (const [name, cfg] of Object.entries(HUMAN_ANIMATIONS)) {
humanAnims[name] = { from: cfg.row * HUMAN_MAX_FRAMES, to: cfg.row * HUMAN_MAX_FRAMES + cfg.frames - 1, loop: true, speed: 6 }
}
await Promise.all([
loadSpriteWithTimeout('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
loadSpriteWithTimeout('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
])
const W = k.width()
const H = k.height()
// Track all intervals/timeouts for cleanup on scene destroy
const cleanupTimers = new Set<ReturnType<typeof setTimeout>>()
function trackedTimeout(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
const id = setTimeout(() => { cleanupTimers.delete(id); fn() }, ms)
cleanupTimers.add(id)
return id
}
function trackedInterval(fn: () => void, ms: number): ReturnType<typeof setInterval> {
const id = setInterval(fn, ms)
cleanupTimers.add(id)
return id
}
function clearTracked(id: ReturnType<typeof setTimeout>) {
clearInterval(id)
clearTimeout(id)
cleanupTimers.delete(id)
}
// Scale factor so sprites shrink on small canvases (reference: 500px tall)
// Fight context for extracted modules
const fightCtx: FightContext = {
k, W, H, theme,
trackedTimeout, trackedInterval, clearTracked,
safeColor: (color: string) => safeColor(k, color),
}
// Wrappers for extracted particle/effect/projectile functions
const spawnSparks = (x: number, y: number, count: number, color: string) => _spawnSparks(fightCtx, x, y, count, color)
const spawnBulletHoles = (x: number, y: number, count: number) => _spawnBulletHoles(fightCtx, x, y, count)
const spawnExhaust = (x: number, y: number, duration: number) => _spawnExhaust(fightCtx, x, y, duration)
const spawnProjectile = (fromX: number, fromY: number, toX: number, toY: number, color: string, size: number = 8) => _spawnProjectile(fightCtx, fromX, fromY, toX, toY, color, size)
const glitchRGB = (duration: number = 0.2) => _glitchRGB(fightCtx, duration)
const scanlineGlitch = (duration: number = 0.3) => _scanlineGlitch(fightCtx, duration)
const vhsTracking = (duration: number = 0.4) => _vhsTracking(fightCtx, duration)
const dimensionalShift = (duration: number = 0.6) => _dimensionalShift(fightCtx, duration)
const SF = Math.min(1, H / 500)
const GROUND_Y = H * 0.78
const HOME_A = W * 0.28
const HOME_B = W * 0.72
function spawnBullet(fromX: number, fromY: number, toX: number, toY: number): Promise<void> {
return new Promise(resolve => {
const bullet = k.add([
k.rect(6, 2),
k.pos(fromX, fromY),
k.color(safeColor(k,'#ffee00')),
k.opacity(1),
k.z(15),
k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI),
])
const dur = 0.08 + Math.random() * 0.04
k.tween(0, 1, dur, (t) => {
bullet.pos.x = fromX + (toX - fromX) * t
bullet.pos.y = fromY + (toY - fromY) * t + (Math.random() - 0.5) * 4
}, k.easings.linear).then(() => {
bullet.destroy()
// Muzzle flash at impact
spawnSparks(toX, toY, 3, '#ffcc00')
resolve()
})
})
}
function spawnShockwave(x: number, y: number, color: string) {
const wave = k.add([
k.circle(5),
k.pos(x, y),
k.color(safeColor(k,color)),
k.opacity(0.7),
k.z(5),
k.scale(1),
])
k.tween(1, 15, 0.4, (v) => { wave.scaleTo(v, v * 0.3) }, k.easings.easeOutQuad)
k.tween(0.7, 0, 0.4, (v) => { wave.opacity = v }, k.easings.easeOutQuad).then(() => wave.destroy())
}
// === VISUAL CHAOS EFFECTS ===
// Schizo cut — rapid zoom/position jitter simulating jump cuts
async function schizoCut() {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const origAX = fA.pos.x, origBX = fB.pos.x
const origAY = fA.pos.y, origBY = fB.pos.y
// 3-5 rapid cuts
const cuts = 3 + Math.floor(Math.random() * 3)
for (let i = 0; i < cuts; i++) {
// Random offset both fighters
fA.pos.x = origAX + (Math.random() - 0.5) * 30
fA.pos.y = origAY + (Math.random() - 0.5) * 15
fB.pos.x = origBX + (Math.random() - 0.5) * 30
fB.pos.y = origBY + (Math.random() - 0.5) * 15
screenFlash(Math.random() > 0.5 ? '#000000' : '#ffffff', 0.03)
await k.wait(0.04 + Math.random() * 0.03)
}
fA.pos.x = origAX; fA.pos.y = origAY
fB.pos.x = origBX; fB.pos.y = origBY
}
// Hyperspeed lines — converging toward a point
function hyperSpeedLines(targetX: number, targetY: number, duration: number = 0.3) {
2026-03-09 00:00:48 +00:00
const lineCount = isPerfMode() ? 6 : 16
const lines: GameObj[] = []
2026-03-09 00:00:48 +00:00
for (let i = 0; i < lineCount; i++) {
const angle = (i / lineCount) * Math.PI * 2
const dist = 300 + Math.random() * 200
const sx = targetX + Math.cos(angle) * dist
const sy = targetY + Math.sin(angle) * dist
const len = 30 + Math.random() * 60
const line = k.add([
k.rect(len, 1.5), k.pos(sx, sy),
k.color(safeColor(k,'#ffffff')), k.opacity(0.3), k.z(52),
k.rotate(angle * 180 / Math.PI + 180),
])
line.onUpdate(() => {
const dx = targetX - line.pos.x
const dy = targetY - line.pos.y
const d = Math.sqrt(dx * dx + dy * dy)
if (d > 10) {
line.pos.x += (dx / d) * 600 * k.dt()
line.pos.y += (dy / d) * 600 * k.dt()
}
line.opacity -= 0.8 * k.dt()
if (line.opacity <= 0 && line.exists()) line.destroy()
})
lines.push(line)
}
2026-03-08 23:55:58 +00:00
trackedTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000)
}
// Flash the whole screen
function screenFlash(color: string, duration: number = 0.1) {
const flash = k.add([
k.rect(W, H),
k.pos(0, 0),
k.color(safeColor(k,color)),
k.opacity(0.4),
k.z(50),
])
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
}
// Impact freeze-frame: brief pause with white flash on devastating crits
async function impactFreeze(duration: number = 0.08) {
const overlay = k.add([
k.rect(W, H), k.pos(0, 0),
k.color(safeColor(k, '#ffffff')), k.opacity(0.5), k.z(55),
])
// Kaplay has no built-in pause, so we just hold with a wait + overlay
await k.wait(duration)
k.tween(0.5, 0, 0.06, (v) => { overlay.opacity = v }).then(() => overlay.destroy())
}
// Spawn afterimages: trailing ghost sprites behind a moving fighter
function spawnAfterimages(fighter: Fighter, count: number = 3) {
if (!fighter?.exists()) return
2026-03-09 00:00:48 +00:00
const n = isPerfMode() ? Math.max(1, Math.round(count * 0.5)) : count
for (let i = 0; i < n; i++) {
const ghost = k.add([
k.rect(FRAME_SIZE * Math.abs(fighter.scale.x), FRAME_SIZE * Math.abs(fighter.scale.y)),
k.pos(fighter.pos.x - i * 8 * Math.sign(fighter.scale.x), fighter.pos.y),
k.color(safeColor(k, theme.accent)),
k.opacity(0.3 - i * 0.08),
k.z(fighter.z - 1),
k.anchor('bot'),
])
k.tween(ghost.opacity, 0, 0.15 + i * 0.05, (v) => { ghost.opacity = v }).then(() => ghost.destroy())
}
}
// Camera zoom: scale fighters toward/away from a focal point for impact emphasis
async function cameraZoom(
fighters: Fighter[],
savedScales: { x: number; y: number }[],
zoomFactor: number,
duration: number,
easing: (t: number) => number = k.easings.easeOutQuad,
) {
await Promise.all(fighters.map((f, i) => {
if (!f?.exists()) return Promise.resolve()
const sx = savedScales[i].x
const sy = savedScales[i].y
return k.tween(Math.abs(f.scale.y), Math.abs(sy) * zoomFactor, duration, (v) => {
f.scale.x = sx > 0 ? v : -v
f.scale.y = v
}, easing)
}))
}
// Grotesque close-up overlays removed — noops retained for call-site compatibility
function spawnGrotesqueDetails(_fighter: Fighter, _scaleFactor: number) {}
function destroyGrotesqueDetails() {}
// Arena-specific background decoration
function drawArenaDecor() {
const a = arena
const acc = theme.accent
// Parallax stars/particles in the sky
2026-03-09 00:00:48 +00:00
const starCount = isPerfMode() ? 15 : 40
for (let i = 0; i < starCount; i++) {
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.opacity(0.08 + Math.random() * 0.2),
k.z(1),
])
const speed = 0.5 + Math.random() * 1.5
const baseOp = star.opacity
star.onUpdate(() => {
star.opacity = baseOp + Math.sin(k.time() * speed + i) * 0.08
})
}
if (a === 'datacenter' || a === 'localhost') {
2026-03-09 00:00:48 +00:00
const perf = isPerfMode()
// City skyline silhouette behind the racks
2026-03-09 00:00:48 +00:00
const buildingHeights = perf
? [0.55, 0.65, 0.7, 0.6, 0.72, 0.58]
: [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)])
2026-03-09 00:00:48 +00:00
const winStep = perf ? 14 : 7
for (let wy = 0; wy < bh - 10; wy += 8) {
2026-03-09 00:00:48 +00:00
for (let wx = 4; wx < bw - 6; wx += winStep) {
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),
])
2026-03-09 00:00:48 +00:00
if (!perf && 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
2026-03-09 00:00:48 +00:00
const rackRows = perf ? 2 : 4
const rackCols = perf ? 5 : 10
for (let row = 0; row < rackRows; row++) {
for (let col = 0; col < rackCols; col++) {
const lx = 15 + col * (W / rackCols)
const ly = 15 + row * 55
2026-03-09 00:00:48 +00:00
const rackW = W / rackCols - 8
const rackH = 48
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)])
}
2026-03-09 00:00:48 +00:00
if (!perf) {
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.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 })
}
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),
])
actLed.onUpdate(() => { actLed.opacity = Math.random() > 0.5 ? 0.7 : 0.2 })
}
}
}
}
// 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
2026-03-09 00:00:48 +00:00
const chipCount = isPerfMode() ? 6 : 16
for (let i = 0; i < chipCount; i++) {
const cx = Math.random() * W
const cy = 20 + Math.random() * (GROUND_Y - 60)
const cw = 15 + Math.random() * 25
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.opacity(0.35), k.z(1), k.rotate(Math.random() * 45 - 22),
])
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.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 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)])
for (let b = 0; b < 4; b++) {
const ba = b * Math.PI / 2
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 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)])
}
}
// 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.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 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)])
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),
])
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++) {
for (let i = 0; i < 4; i++) {
const cx = Math.random() * W
const cy = 20 + layer * 50 + Math.random() * 30
const cloudOp = 0.25 - layer * 0.05
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.opacity(cloudOp), k.z(1),
])
const bx = cloud.pos.x
cloud.onUpdate(() => { cloud.pos.x = bx + Math.sin(k.time() * (0.08 + layer * 0.04) + i) * (8 + layer * 4) })
}
}
}
// 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),
])
stream.onUpdate(() => {
stream.pos.y += 30 * k.dt()
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 < 4; ring++) {
const ringDots = 12 + ring * 6
for (let i = 0; i < ringDots; i++) {
const angle = (i / ringDots) * Math.PI * 2
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 ? '#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.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 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]) {
const px = W * side
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)])
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)])
}
for (let l = 0; l < 3; l++) {
const ly = 20 + l * (GROUND_Y / 4)
const dir = Math.random() > 0.5 ? 1 : -1
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
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)])
}
// 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') {
// 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)])
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)])
}
}
// Vote arrows
for (let i = 0; i < 5; i++) {
const ax = 20 + Math.random() * (W - 40)
const ay = 30 + Math.random() * (GROUND_Y - 70)
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 and glow
const sunX = W * 0.8, sunY = 35
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 })
for (let r = 0; r < 8; r++) {
const angle = r * Math.PI / 4
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 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.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) })
}
}
// 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
const height = 8 + Math.floor(Math.random() * 3)
for (let s = 0; s < height; s++) {
const sy = GROUND_Y - 10 - s * 12
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)])
}
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 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)])
}
// 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
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)])
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)])
}
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)])
}
}
// Tumbleweeds rolling
for (let i = 0; i < 3; i++) {
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 (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 = 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' : layer === 1 ? '#122a0e' : '#1a3a10')), k.opacity(treeOp * 0.8), k.z(layer),
])
}
}
}
// 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 < 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),
])
const bx = ff.pos.x, by = ff.pos.y
ff.onUpdate(() => {
ff.opacity = Math.max(0, Math.sin(k.time() * 1.5 + i * 1.8) * 0.5)
ff.pos.x = bx + Math.sin(k.time() * 0.5 + i) * 15
ff.pos.y = by + Math.cos(k.time() * 0.4 + i) * 10
})
}
// Light rays through canopy
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
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) {
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 })
}
}
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)])
}
}
// Large tropical leaves at edges
for (const side of [0, W - 30]) {
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 < 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),
])
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
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.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
const nebulaColors = ['#4400aa', '#aa0066', '#0044aa', '#006644', '#660044']
for (let i = 0; i < 5; i++) {
for (let blob = 0; blob < 4; blob++) {
k.add([
k.circle(30 + Math.random() * 50),
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
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.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 with rings
const planetX = W * 0.75, planetY = GROUND_Y * 0.25
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
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 < 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),
])
}
// 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 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)])
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 })
}
// 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 < 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 < 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),
])
const gx = ghost.pos.x, gy = ghost.pos.y
ghost.onUpdate(() => {
ghost.opacity = Math.max(0, Math.sin(k.time() * 0.6 + i * 2) * 0.15)
ghost.pos.x = gx + Math.sin(k.time() * 0.3 + i) * 20
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 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)
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.rotate(0),
])
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
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), k.scale(1)])
speakerCone.onUpdate(() => { speakerCone.scale = k.vec2(1 + Math.sin(k.time() * 8 + s) * 0.05) })
}
}
// 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)])
}
// 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 })
}
// 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.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 shapes (pulsing)
for (let i = 0; i < 12; i++) {
const emoji = k.add([
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), k.scale(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 < 8; i++) {
const band = k.add([
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.12 + Math.random() * 0.08 : 0
if (band.opacity > 0) band.pos.x = (Math.random() - 0.5) * 10
else band.pos.x = 0
})
}
// 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 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') {
// 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
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), k.rotate(0)])
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 with LEDs
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 6; col++) {
const rx = 30 + col * (W / 6)
const ry = 20 + row * 55
const rw = W / 6 - 12
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),
])
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
})
}
}
}
k.scene('fight', () => {
// === SKY / BACKGROUND ATMOSPHERE ===
// Vertical gradient backdrop — 6 bands from dark ceiling to ground horizon
const bgBase = theme.bg
for (let band = 0; band < 6; band++) {
const bandH = GROUND_Y / 6
const brighten = band * 3
k.add([
k.rect(W, bandH + 1), k.pos(0, band * bandH),
k.color(safeColor(k,bgBase)),
k.opacity(1 - band * 0.04),
])
}
// Subtle horizon glow — wide soft band just above ground
k.add([
k.rect(W, 20), k.pos(0, GROUND_Y - 20),
k.color(safeColor(k,theme.accent)), k.opacity(0.06),
])
// === GROUND ===
// Main ground fill
k.add([k.rect(W, H - GROUND_Y + 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.ground))])
// Bright edge line at the surface
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.accent)), k.opacity(0.7)])
// Secondary highlight
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3)])
// Ground gradient — fades deeper
for (let i = 1; i <= 12; i++) {
k.add([k.rect(W, 2), k.pos(0, GROUND_Y + 3 + i * 6), k.color(safeColor(k,theme.accent)), k.opacity(0.1 - i * 0.007)])
}
// Perspective grid on the floor — converging lines for 3D depth
const vanishX = W / 2
const vanishY = GROUND_Y * 0.6
for (let i = 0; i <= 24; i++) {
const x = i * (W / 24)
// Lines from floor edge pointing toward vanishing point
const dx = x - vanishX
const topX = vanishX + dx * 0.3
k.add([
k.rect(1, GROUND_Y * 0.4 + 10), k.pos(x, GROUND_Y),
k.color(safeColor(k,theme.accent)), k.opacity(0.05),
])
}
// Horizontal depth lines on ground
for (let i = 0; i < 6; i++) {
const gy = GROUND_Y + 8 + i * i * 3
if (gy < H) {
k.add([k.rect(W, 1), k.pos(0, gy), k.color(safeColor(k,theme.accent)), k.opacity(0.08 - i * 0.012)])
}
}
// === SIDE PILLARS / FRAME ===
// Subtle dark pillars on edges for framing
k.add([k.rect(8, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
k.add([k.rect(8, GROUND_Y), k.pos(W - 8, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
// Accent trim on pillars
k.add([k.rect(1, GROUND_Y), k.pos(8, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
k.add([k.rect(1, GROUND_Y), k.pos(W - 9, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
// === AMBIENT PARTICLES ===
// Floating dust/embers in the air (subtle, always present)
for (let i = 0; i < 18; i++) {
const particle = k.add([
k.circle(0.5 + Math.random() * 1.5),
k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k,theme.accent)),
k.opacity(0.08 + Math.random() * 0.12),
k.z(2),
])
const baseX = particle.pos.x
const baseY = particle.pos.y
const driftSpeed = 0.1 + Math.random() * 0.3
const driftAmt = 8 + Math.random() * 15
particle.onUpdate(() => {
particle.pos.x = baseX + Math.sin(k.time() * driftSpeed + i * 1.3) * driftAmt
particle.pos.y = baseY + Math.cos(k.time() * driftSpeed * 0.7 + i) * (driftAmt * 0.5)
particle.opacity = 0.06 + Math.sin(k.time() * 0.8 + i * 0.9) * 0.06
})
}
// Arena-specific decorations
drawArenaDecor()
// Umpire chair (center-back, behind fighters)
const CHAIR_X = W / 2
const CHAIR_SEAT_Y = GROUND_Y * (0.38 + (1 - SF) * 0.2)
const cs = SF // chair scale
k.add([k.rect(4 * cs, GROUND_Y - CHAIR_SEAT_Y + 15 * cs), k.pos(CHAIR_X - 22 * cs, CHAIR_SEAT_Y - 8 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
k.add([k.rect(4 * cs, GROUND_Y - CHAIR_SEAT_Y + 15 * cs), k.pos(CHAIR_X + 18 * cs, CHAIR_SEAT_Y - 8 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
for (let i = 0; i < 6; i++) {
const rungY = CHAIR_SEAT_Y + 15 * cs + i * ((GROUND_Y - CHAIR_SEAT_Y - 15 * cs) / 6)
k.add([k.rect(44 * cs, 3), k.pos(CHAIR_X - 22 * cs, rungY), k.color(safeColor(k,'#443322')), k.opacity(0.6), k.z(3)])
}
k.add([k.rect(54 * cs, 5), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 2), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)])
k.add([k.rect(54 * cs, 1), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3), k.z(3)])
k.add([k.rect(48 * cs, 4), k.pos(CHAIR_X - 24 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)])
k.add([k.rect(3, 12 * cs), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
k.add([k.rect(3, 12 * cs), k.pos(CHAIR_X + 24 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
k.add([
k.sprite('judge', { anim: 'idle' }),
k.pos(CHAIR_X, CHAIR_SEAT_Y),
k.anchor('bot'),
k.scale(1.3 * SF),
k.z(4),
k.opacity(0.9),
'judge',
])
const scaleA = (1.8 + botA.tier * 0.4) * SF
k.add([k.sprite('botA', { anim: 'idle' }), k.pos(-100, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), k.opacity(0), 'fighterA'])
const scaleB = (1.8 + botB.tier * 0.4) * SF
k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W + 100, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), k.opacity(0), 'fighterB'])
config.onReady?.()
})
k.go('fight')
let comboA = 0
let comboB = 0
// === CHOREOGRAPHY SYSTEM (extracted to fight/choreography/) ===
const choreoCtx: ChoreoContext = {
k, W, H, GROUND_Y, FRAME_SIZE, theme,
safeColor: (color: string) => safeColor(k, color),
2026-03-08 23:55:58 +00:00
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxExplosion,
sfxBoing, sfxBonk, sfxBulletHit, sfxClash, sfxCoin,
sfxGunshot, sfxJetpack, sfxRapidPunch, sfxSlideDown,
sfxZap, sfxZoomWhoosh, sfxBlock,
spawnBullet, spawnGrotesqueDetails, destroyGrotesqueDetails,
}
const choreographyMap = createChoreographyMap(choreoCtx)
const { spawnWeaponProp, destroyProp, spawnProp, moveProp } = createFactories(choreoCtx)
async function playBlock(side: 'a' | 'b') {
const defender = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!defender) return
const dir = side === 'a' ? 1 : -1
// Spawn shield in front
const shieldObjs = spawnWeaponProp('shield', defender.pos.x + dir * 20, defender.pos.y - 25, side === 'b', 19)
sfxBlock()
// Flash shield
shieldObjs.forEach(o => { o.opacity = 0.9 })
await k.wait(0.15)
// Impact sparks
spawnSparks(defender.pos.x + dir * 25, defender.pos.y - 25, 6, '#4488ff')
k.shake(3)
await k.wait(0.3)
// Shield fades
shieldObjs.forEach(o => {
k.tween(o.opacity, 0, 0.2, (v: number) => { o.opacity = v }).then(() => { if (o.exists()) o.destroy() })
})
await k.wait(0.1)
}
// === MORPH SYSTEM (extracted to fight/morphs.ts) ===
const morphSystem = createMorphSystem(choreoCtx, {
botA, botB, colorsA, colorsB, isHumanA, isHumanB, loadSpriteWithTimeout,
})
const { destroyMorphOverlays, playMorph, applyCreatorOmniMorph, getMorphOrder } = morphSystem
// === EMOTION & SPORTSMANSHIP SYSTEM ===
const heartfeltLines = [
'That bot fights like it\'s got student loans to pay!',
'I\'m not crying, you\'re crying!',
'Someone get this bot a movie deal!',
'This is better than anything on Netflix right now!',
'That bot has more heart than most politicians!',
'I haven\'t been this moved since my last Windows update!',
'The crowd is ugly crying and I respect that!',
'This bot has main character energy!',
'We\'re witnessing greatness and I don\'t say that lightly!',
'Even the judge is getting emotional!',
'Someone call their developer, they\'d be so proud!',
'This is the kind of fight you tell your grandkids about!',
'That\'s not a bot, that\'s a WARRIOR!',
'More drama than a Congressional hearing!',
'They left it all in the ring! And by all, I mean EVERYTHING!',
'The crowd is standing! The ones who can still stand, anyway!',
'Somebody film this before it gets taken down!',
'This fight has more plot twists than a Netflix original!',
'That bot fights like rent is due tomorrow!',
'My heart! My poor little heart!',
]
const crowdSympathyLines = [
'Pour one out for the fallen!',
'That\'s rough, buddy...',
'Somebody hug that bot!',
'They tried their best, which is... concerning!',
'At least they have a great personality!',
'Their developer still loves them! Probably!',
'It\'s a learning experience! A very painful one!',
'Better luck next patch!',
'Participation trophy incoming!',
'The exit is to your left!',
'Have you tried turning it off and on again?',
'Even their antivirus felt that!',
'They\'ll be in therapy for epochs after this!',
'At least they looked good losing!',
'That bot just became a cautionary tale!',
'Someone start a GoFundMe for their repairs!',
'Their training data did NOT prepare them for this!',
'That bot needs a hug and a firmware update!',
]
const respectLines = [
'Good fight.', 'Respect.', 'GG no re.',
'Same time next week?',
'You\'re built different. Not better, but different.',
'We should start a podcast.',
'That was actually fun. Don\'t tell anyone.',
'Your developer should be proud. Probably.',
'Ten out of ten, would fight again.',
'We\'re not so different, you and I.',
'I\'d swipe right.', 'You single?',
'Tell your GPU I said hi.',
'No hard feelings. Just hard hits.',
'You fight like someone who reads documentation.',
]
// Sanitize text for Kaplay (treats [ ] as styled text tags)
function safeText(t: string): string {
return t
.replace(/[\[\]{}<>`\\|~^]/g, '') // Kaplay styled-text tags and problematic chars
.replace(/[\x00-\x1f\x7f]/g, ' ') // Control characters → space
.replace(/[^\x20-\x7e]/g, '') // Strip non-ASCII (emoji, unicode) — Kaplay can't render them
.replace(/\s+/g, ' ') // Collapse whitespace
.trim()
}
// Speech bubble above a fighter — colourful with tail
// Active speech bubbles — destroy previous before showing new
let activeBubbleA: GameObj[] = []
let activeBubbleB: GameObj[] = []
function destroyBubble(els: GameObj[]) {
els.forEach(el => { if (el.exists()) el.destroy() })
els.length = 0
}
function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = 3) {
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!fighter || !text) return
// Kill previous bubble on this side
if (side === 'a') destroyBubble(activeBubbleA)
else destroyBubble(activeBubbleB)
const allEls: GameObj[] = []
// Sanitize and truncate
const clean = safeText(text)
if (!clean) return
const maxLen = 80
const display = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '...' : clean
const lines = wrapBubbleText(display, 18)
const fontSize = 12
const lineH = fontSize + 4
const padX = 10, padY = 8
const textW = Math.max(60, Math.min(lines.reduce((m, l) => Math.max(m, l.length * (fontSize * 0.6)), 0) + 8, W * 0.38))
const bubbleW = textW + padX * 2
const bubbleH = lines.length * lineH + padY * 2
const tailSize = 10
// Position — push bubble to the outside of the bot so it doesn't overlap the sprite
const offsetX = side === 'a' ? -bubbleW * 0.7 : bubbleW * 0.7
const bx = Math.max(bubbleW / 2 + 4, Math.min(W - bubbleW / 2 - 4, fighter.pos.x + offsetX))
const by = Math.max(8, fighter.pos.y - 75 - bubbleH)
const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
// Single border rect (no glow/accent layers — keeps rendering clean)
const border = k.add([
k.rect(bubbleW + 4, bubbleH + 4),
k.pos(bx - bubbleW / 2 - 2, by - 2),
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
])
allEls.push(border)
// Bubble body
const bubble = k.add([
k.rect(bubbleW, bubbleH),
k.pos(bx - bubbleW / 2, by),
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
])
allEls.push(bubble)
// Tail — pixel arrow pointing down to fighter
const tailX = bx + (side === 'a' ? bubbleW * 0.15 : -bubbleW * 0.15)
const tailBaseY = by + bubbleH
for (let row = 0; row < tailSize; row++) {
const tw = tailSize - row
// Border pixel row
const tailBorder = k.add([
k.rect(tw + 2, 1),
k.pos(tailX - (tw + 2) / 2, tailBaseY + row),
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
])
allEls.push(tailBorder)
// Inner pixel row
if (tw > 2) {
const tailInner = k.add([
k.rect(tw - 1, 1),
k.pos(tailX - (tw - 1) / 2, tailBaseY + row),
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
])
allEls.push(tailInner)
}
}
// Render text to an offscreen canvas, then display as a sprite
// This bypasses kaplay's text rendering which has color issues
const textCanvas = document.createElement('canvas')
textCanvas.width = bubbleW
textCanvas.height = bubbleH
const tc = textCanvas.getContext('2d')!
tc.fillStyle = '#ffffff'
tc.font = `${fontSize}px monospace`
tc.textBaseline = 'top'
for (let i = 0; i < lines.length; i++) {
tc.fillText(lines[i], padX, padY + i * lineH + 2)
}
const spriteKey = `bubble_${side}_${Date.now()}`
k.loadSprite(spriteKey, textCanvas.toDataURL()).then(() => {
const tEl = k.add([
k.sprite(spriteKey),
k.pos(bx - bubbleW / 2, by),
k.opacity(1), k.z(57),
])
allEls.push(tEl)
})
// Border glow pulse
border.onUpdate(() => {
border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15
})
// Pop-in animation
const anchorX = bx, anchorY = by + bubbleH
allEls.forEach(el => {
const origX = el.pos.x, origY = el.pos.y
const origOpacity = el.opacity
el.pos.x = anchorX + (origX - anchorX) * 0.1
el.pos.y = anchorY + (origY - anchorY) * 0.1
el.opacity = 0
k.tween(0, 1, 0.25, (t) => {
el.pos.x = anchorX + (origX - anchorX) * t
el.pos.y = anchorY + (origY - anchorY) * t
el.opacity = origOpacity * t
}, k.easings.easeOutBack)
})
// Store reference
if (side === 'a') activeBubbleA = allEls
else activeBubbleB = allEls
// Fade out after duration
2026-03-08 23:55:58 +00:00
trackedTimeout(() => {
allEls.forEach(el => {
if (!el.exists()) return
k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => {
if (el.exists()) el.destroy()
})
})
}, duration * 1000)
}
function wrapBubbleText(text: string, maxChars: number): string[] {
const words = text.split(' ')
const lines: string[] = []
let line = ''
for (const word of words) {
if (line.length + word.length + 1 > maxChars && line.length > 0) {
lines.push(line)
line = word
} else {
line = line ? line + ' ' + word : word
}
}
if (line) lines.push(line)
// Max 5 lines, last line truncated
if (lines.length > 5) {
const truncated = [...lines.slice(0, 4), lines.slice(4).join(' ').slice(0, maxChars - 3) + '...']
return truncated
}
return lines
}
// Talking mouth animation — a small rectangle that opens/closes near the fighter's face
const talkingAnims: Record<string, { objs: GameObj[]; timer: ReturnType<typeof setInterval> }> = {}
function startTalking(side: 'a' | 'b') {
stopTalking(side) // cleanup any existing
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!fighter?.exists()) return
const mouthColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
// Mouth position: slightly below center of the sprite
const mouthOffsetY = -28
const mouthW = 8
let open = false
const mouth = k.add([
k.rect(mouthW, 2),
k.pos(fighter.pos.x, fighter.pos.y + mouthOffsetY),
k.color(safeColor(k, mouthColor)),
k.opacity(0.9),
k.z(12),
k.anchor('center'),
])
// Toggle open/close every 120ms for a fast chatter effect
const timer = trackedInterval(() => {
if (!mouth.exists() || !fighter.exists()) { stopTalking(side); return }
open = !open
mouth.pos.x = fighter.pos.x
mouth.pos.y = fighter.pos.y + mouthOffsetY
mouth.height = open ? 5 : 2
}, 120)
talkingAnims[side] = { objs: [mouth], timer }
}
function stopTalking(side: 'a' | 'b') {
const anim = talkingAnims[side]
if (!anim) return
clearTracked(anim.timer)
anim.objs.forEach(o => { if (o.exists()) o.destroy() })
delete talkingAnims[side]
}
// Spawn floating text above a position
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
const label = k.add([
k.text(safeText(text), { size: 10 }),
k.pos(x, y - 10),
k.color(safeColor(k,color)),
k.opacity(1),
k.z(60),
k.anchor('center'),
])
k.tween(label.pos.y, y - 50, duration, (v) => { label.pos.y = v }, k.easings.easeOutQuad)
k.tween(1, 0, duration, (v) => { label.opacity = v }, k.easings.easeInQuad).then(() => {
if (label.exists()) label.destroy()
})
}
// Spawn a heart particle
function spawnHeart(x: number, y: number) {
const colors = ['#ff4466', '#ff6688', '#ff2244', '#ff88aa', '#cc2244']
const heart = k.add([
k.text('\u2665', { size: 12 + Math.random() * 8 }),
k.pos(x + (Math.random() - 0.5) * 40, y),
k.color(safeColor(k,colors[Math.floor(Math.random() * colors.length)])),
k.opacity(0.8),
k.z(58),
k.anchor('center'),
])
const driftX = (Math.random() - 0.5) * 30
k.tween(heart.pos.y, y - 60 - Math.random() * 40, 1.5, (v) => {
heart.pos.y = v
heart.pos.x += driftX * 0.01
}, k.easings.easeOutQuad)
k.tween(0.8, 0, 1.5, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
}
// Crowd signs: pop up from bottom with text, bob, then fade out
function spawnCrowdSigns(count: number, color: string, text?: string) {
const labels = text ? [text] : ['GO!', 'WOW', 'KO!', 'LOL', 'GG', 'OMG']
for (let i = 0; i < count; i++) {
const x = W * 0.15 + Math.random() * W * 0.7
const startY = H + 20
const endY = GROUND_Y + 10 + Math.random() * 20
const label = labels[Math.floor(Math.random() * labels.length)]
// Sign stick
const stick = k.add([
k.rect(3, 25),
k.pos(x, startY),
k.color(safeColor(k, '#886644')),
k.opacity(0.8),
k.z(8),
k.anchor('bot'),
])
// Sign face
const sign = k.add([
k.rect(Math.max(30, label.length * 10), 18),
k.pos(x, startY - 25),
k.color(safeColor(k, color)),
k.opacity(0.85),
k.z(8),
k.anchor('center'),
])
// Sign text
const txt = k.add([
k.text(label, { size: 10 }),
k.pos(x, startY - 25),
k.color(safeColor(k, '#ffffff')),
k.opacity(0.9),
k.z(9),
k.anchor('center'),
])
const delay = i * 0.08
// Rise up
k.wait(delay, () => {
k.tween(startY, endY, 0.3, (v) => { stick.pos.y = v; sign.pos.y = v - 25; txt.pos.y = v - 25 }, k.easings.easeOutBack)
})
// Bob + fade out after 1.5s
k.wait(delay + 1.5, () => {
k.tween(0.85, 0, 0.5, (v) => { stick.opacity = v; sign.opacity = v; txt.opacity = v }).then(() => {
if (stick.exists()) stick.destroy()
if (sign.exists()) sign.destroy()
if (txt.exists()) txt.destroy()
})
})
}
}
// === HUMAN OWNER APPEARANCES ===
// The silly human behind each bot occasionally shows up
/** Spawn a human as a "coach" standing behind the fighter, cheering/panicking */
async function spawnHumanCoach(side: 'a' | 'b', mood: 'cheer' | 'panic' | 'coach') {
const spriteId = side === 'a' ? 'humanA' : 'humanB'
const homeX = side === 'a' ? HOME_A - 60 : HOME_B + 60
const tag = `human_${side}`
// Don't spawn if already visible
if (k.get(tag).length > 0) return
const human = k.add([
k.sprite(spriteId, { anim: mood }),
k.pos(homeX, GROUND_Y - 4),
k.anchor('bot'),
k.scale(side === 'a' ? 1.2 : -1.2, 1.2),
k.z(5),
k.opacity(0),
tag,
])
// Fade in
await k.tween(0, 0.85, 0.3, (v) => { human.opacity = v }, k.easings.easeOutQuad)
await k.wait(2.0)
// Fade out
await k.tween(0.85, 0, 0.5, (v) => { human.opacity = v }, k.easings.easeInQuad)
if (human.exists()) human.destroy()
}
/** Human runs across the screen (comedic moment) */
async function humanRunAcross(side: 'a' | 'b') {
const spriteId = side === 'a' ? 'humanA' : 'humanB'
const fromLeft = side === 'a'
const startX = fromLeft ? -40 : W + 40
const endX = fromLeft ? W + 40 : -40
const human = k.add([
k.sprite(spriteId, { anim: 'run' }),
k.pos(startX, GROUND_Y - 4),
k.anchor('bot'),
k.scale(fromLeft ? 1.3 : -1.3, 1.3),
k.z(8),
k.opacity(0.9),
])
// Run across screen
await k.tween(startX, endX, 1.5, (v) => { human.pos.x = v }, k.easings.linear)
if (human.exists()) human.destroy()
}
/** Human briefly replaces the bot sprite (jumps in to fight, fails hilariously) */
async function humanJumpsIn(side: 'a' | 'b') {
const fighterTag = side === 'a' ? 'fighterA' : 'fighterB'
const spriteId = side === 'a' ? 'humanA' : 'humanB'
const fighter = k.get(fighterTag)[0] as Fighter
if (!fighter) return
// Spawn human at fighter position
const human = k.add([
k.sprite(spriteId, { anim: 'panic' }),
k.pos(fighter.pos.x, fighter.pos.y),
k.anchor('bot'),
k.scale(fighter.scale.x / 3, fighter.scale.y / 3),
k.z(fighter.z + 1),
k.opacity(0),
])
// Hide bot, show human
const origOpacity = fighter.opacity
fighter.opacity = 0
await k.tween(0, 1, 0.15, (v) => { human.opacity = v })
// Human flails around for a moment
human.play('panic')
await k.wait(0.4)
human.play('cheer')
sfxRandomSilly()
await k.wait(0.3)
human.play('panic')
await k.wait(0.3)
// Run away scared
const escapeX = side === 'a' ? -80 : W + 80
human.play('run')
await k.tween(human.pos.x, escapeX, 0.5, (v) => { human.pos.x = v }, k.easings.easeInQuad)
// Restore bot
fighter.opacity = origOpacity
if (human.exists()) human.destroy()
}
/** Random chance to show a human moment — called between rounds */
async function maybeShowHuman() {
const roll = Math.random()
if (roll > 0.15) return // 15% chance per round
const side: 'a' | 'b' = Math.random() < 0.5 ? 'a' : 'b'
const action = Math.random()
if (action < 0.4) {
// Coach appearance (most common)
const mood = Math.random() < 0.5 ? 'cheer' : 'panic'
await spawnHumanCoach(side, mood)
} else if (action < 0.7) {
// Run across screen
await humanRunAcross(side)
} else {
// Jump in and replace bot briefly (rarest, funniest)
await humanJumpsIn(side)
}
}
// Respectful bow animation
async function playBow(fighter: Fighter) {
const origScaleY = fighter.scale.y
await k.tween(fighter.scale.y, origScaleY * 0.85, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad)
await k.wait(0.3)
await k.tween(fighter.scale.y, origScaleY, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad)
}
// Fist bump between two fighters
async function playFistBump(fighterA: Fighter, fighterB: Fighter) {
const midX = (fighterA.pos.x + fighterB.pos.x) / 2
const origAX = fighterA.pos.x
const origBX = fighterB.pos.x
await Promise.all([
k.tween(fighterA.pos.x, midX - 15, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad),
k.tween(fighterB.pos.x, midX + 15, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad),
])
sfxBlock()
spawnSparks(midX, fighterA.pos.y - 25, 6, '#ffe14d')
spawnEmoteText(midX, fighterA.pos.y - 40, 'GG!', '#ffe14d')
k.shake(2)
await k.wait(0.5)
await Promise.all([
k.tween(fighterA.pos.x, origAX, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad),
k.tween(fighterB.pos.x, origBX, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad),
])
}
// Winner helps loser back up
async function playHelpUp(winner: Fighter, loser: Fighter, winningSide: 'a' | 'b') {
const dir = winningSide === 'a' ? 1 : -1
const origWX = winner.pos.x
await k.tween(winner.pos.x, loser.pos.x - dir * 30, 0.4, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
winner.play('idle')
await k.wait(0.2)
loser.play('idle')
spawnEmoteText(loser.pos.x, loser.pos.y - 40, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
for (let i = 0; i < 3; i++) spawnHeart((winner.pos.x + loser.pos.x) / 2, winner.pos.y - 30)
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
await k.wait(0.6)
await k.tween(winner.pos.x, origWX, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
}
// === REFEREE LOBSTER FUNNY MOMENTS ===
async function judgeDoSomethingFunny() {
const judge = k.get('judge')[0]
if (!judge) return
const judgeOrigY = judge.pos.y
const funnyAction = Math.floor(Math.random() * 8)
if (funnyAction === 0) {
// Falls asleep, snaps awake
for (let z = 0; z < 3; z++) {
2026-03-08 23:55:58 +00:00
trackedTimeout(() => {
spawnEmoteText(judge.pos.x + 15, judge.pos.y - 20, 'Z', '#8888ff', 1.0)
}, z * 400)
}
await k.wait(1.2)
judge.play('shocked')
k.shake(2)
sfxBoing()
spawnEmoteText(judge.pos.x, judge.pos.y - 30, '!?', '#ff4444')
await k.tween(judge.pos.y, judgeOrigY - 20, 0.1, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
await k.tween(judge.pos.y, judgeOrigY, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
await k.wait(0.3)
judge.play('idle')
} else if (funnyAction === 1) {
// Little dance on the chair
for (let d = 0; d < 4; d++) {
await k.tween(judge.pos.y, judgeOrigY - 8, 0.08, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
await k.tween(judge.pos.y, judgeOrigY, 0.08, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
judge.play(d % 2 === 0 ? 'call_left' : 'call_right')
}
sfxRandomComedy()
judge.play('idle')
} else if (funnyAction === 2) {
// Holds up a 10 score card
judge.play('shocked')
const card = k.add([
k.rect(20, 14), k.pos(judge.pos.x + 20, judge.pos.y - 25),
k.color(safeColor(k,'#ffffff')), k.opacity(0.9), k.z(15), k.anchor('center'),
])
const score = k.add([
k.text('10', { size: 10 }), k.pos(judge.pos.x + 20, judge.pos.y - 25),
k.color(safeColor(k,'#000000')), k.opacity(1), k.z(16), k.anchor('center'),
])
sfxBoing()
await k.wait(1.0)
card.destroy(); score.destroy()
judge.play('idle')
} else if (funnyAction === 3) {
// Gets scared and hides behind the chair
judge.play('shocked')
sfxDodge()
await k.tween(judge.pos.y, judgeOrigY + 20, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
judge.opacity = 0.3
await k.wait(0.8)
await k.tween(judge.pos.y, judgeOrigY - 5, 0.2, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
judge.opacity = 0.9
await k.tween(judge.pos.y, judgeOrigY, 0.1, (v) => { judge.pos.y = v }, k.easings.easeInOutQuad)
judge.play('idle')
} else if (funnyAction === 4) {
// Eats a tiny sandwich
const sandwich = k.add([
k.rect(10, 8), k.pos(judge.pos.x - 15, judge.pos.y - 15),
k.color(safeColor(k,'#ddaa44')), k.opacity(0.9), k.z(15), k.anchor('center'),
])
const lettuce = k.add([
k.rect(12, 2), k.pos(judge.pos.x - 15, judge.pos.y - 15),
k.color(safeColor(k,'#44cc22')), k.opacity(0.8), k.z(16), k.anchor('center'),
])
await k.wait(0.4)
await k.tween(sandwich.pos.x, judge.pos.x, 0.2, (v) => { sandwich.pos.x = v; lettuce.pos.x = v }, k.easings.easeInQuad)
sandwich.destroy(); lettuce.destroy()
spawnEmoteText(judge.pos.x, judge.pos.y - 30, 'nom nom', '#ffcc44')
sfxBonk()
await k.wait(0.6)
} else if (funnyAction === 5) {
// Takes a selfie
judge.play('call_right')
const phone = k.add([
k.rect(6, 10), k.pos(judge.pos.x + 20, judge.pos.y - 20),
k.color(safeColor(k,'#333344')), k.opacity(0.9), k.z(15), k.anchor('center'),
])
await k.wait(0.3)
screenFlash('#ffffff', 0.05)
sfxZap()
spawnEmoteText(judge.pos.x, judge.pos.y - 35, '#selfie', '#ff88cc')
await k.wait(0.5)
phone.destroy()
judge.play('idle')
} else if (funnyAction === 6) {
// Waves a tiny flag
const flagColors = ['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00', '#ff6600', '#b83dff']
const flagColor = flagColors[Math.floor(Math.random() * flagColors.length)]
const flag = k.add([
k.rect(14, 9), k.pos(judge.pos.x + 18, judge.pos.y - 30),
k.color(safeColor(k,flagColor)), k.opacity(0.85), k.z(15), k.anchor('center'),
])
const pole = k.add([
k.rect(2, 18), k.pos(judge.pos.x + 11, judge.pos.y - 22),
k.color(safeColor(k,'#aa8855')), k.opacity(0.8), k.z(14), k.anchor('center'),
])
flag.onUpdate(() => {
flag.pos.y = judge.pos.y - 30 + Math.sin(k.time() * 10) * 3
})
sfxRandomComedy()
await k.wait(1.5)
flag.destroy(); pole.destroy()
} else {
// Falls off the chair, climbs back up
judge.play('shocked')
sfxBoing()
await k.tween(judge.pos.y, GROUND_Y - 6, 0.3, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
sfxBonk()
k.shake(4)
spawnEmoteText(judge.pos.x, GROUND_Y - 20, 'oof!', '#ff6644')
await k.wait(0.5)
await k.tween(judge.pos.y, judgeOrigY, 0.5, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
judge.play('idle')
spawnEmoteText(judge.pos.x, judge.pos.y - 25, '*ahem*', '#aaaaaa')
await k.wait(0.3)
}
}
// === SHOWBOATING / TAUNTING SYSTEM ===
// Fighters perform these during question/answer phases instead of boring idle bobbing
const _showboatActive: Record<string, boolean> = { a: false, b: false }
type ShowboatFn = (fighter: Fighter, homeX: number, homeY: number, sx: number, sy: number, dir: number) => Promise<void>
// dir: 1 = facing right (bot A), -1 = facing left (bot B)
const showboats: ShowboatFn[] = [
// 0: Pace back and forth — walk a wide arc restlessly
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
await k.tween(f.pos.x, hx + dir * 100, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('idle')
await k.wait(0.1)
f.play('attack')
await k.tween(f.pos.x, hx - dir * 60, 0.4, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('idle')
await k.wait(0.1)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 1: Bouncing advance — hop toward opponent then back
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 3; i++) {
const targetX = hx + dir * (40 + i * 30)
f.play('kick')
await Promise.all([
k.tween(f.pos.x, targetX, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy - 35, 0.06, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBoing()
}
f.play('win'); await k.wait(0.15)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 2: Shadow box sprint — dash forward shadow boxing, dash back
async (f, hx, hy, _sx, _sy, dir) => {
const far = hx + dir * 120
f.play('attack'); sfxZoomWhoosh()
await k.tween(f.pos.x, far, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
for (let i = 0; i < 4; i++) {
f.play(i % 2 === 0 ? 'attack' : 'kick')
sfxDodge(); await k.wait(0.08)
}
f.play('special'); sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 3: Leapfrog across — multiple big jumps across the stage
async (f, hx, hy, _sx, _sy, dir) => {
const spots = [hx + dir * 60, hx + dir * 130, hx + dir * 60, hx]
for (const tx of spots) {
sfxBoing()
await Promise.all([
k.tween(f.pos.x, tx, 0.18, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 70, 0.09, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.09, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
}
},
// 4: Restless jog — jog back and forth with small hops
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
for (let i = 0; i < 5; i++) {
const dx = (i % 2 === 0 ? 1 : -1) * (30 + Math.random() * 40) * dir
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.13, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 12, 0.065, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.065, (v) => { f.pos.y = v })
),
])
}
f.pos.x = hx
},
// 5: Charge and flex — sprint to center, flex, sprint back
async (f, hx, hy, sx, sy, _dir) => {
const center = W / 2
f.play('special'); sfxZoomWhoosh()
await k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
f.play('win')
await k.tween(1, 1.3, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
spawnEmoteText(f.pos.x, f.pos.y - 50, 'COME AT ME!', '#ff4444')
await k.wait(0.3)
await k.tween(1.3, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 6: Triple backflip retreat — three backflips going backwards
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 3; i++) {
sfxBoing()
const targetX = hx - dir * (i + 1) * 30
const a0 = f.angle || 0
await Promise.all([
k.tween(f.pos.x, targetX, 0.2, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 60, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
k.tween(a0, a0 - 360, 0.2, (v) => { f.angle = v }),
])
f.angle = 0
}
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 7: Kangaroo hops — big bouncing jumps across the width
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
const hops = [hx + dir * 80, hx + dir * 150, hx + dir * 80, hx]
for (const tx of hops) {
await Promise.all([
k.tween(f.pos.x, tx, 0.15, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBonk(); k.shake(2)
}
},
// 8: Feint combo rush — charge in, throw combo, dodge back
async (f, hx, hy, _sx, _sy, dir) => {
sfxZoomWhoosh(); f.play('attack')
await k.tween(f.pos.x, hx + dir * 100, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
const anims = ['attack', 'kick', 'attack', 'special'] as const
for (const a of anims) { f.play(a); sfxDodge(); await k.wait(0.06) }
sfxBlock(); spawnSparks(f.pos.x + dir * 15, hy - 30, 5, '#ffcc00')
// Dodge-hop back
await Promise.all([
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy - 40, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
},
// 9: Dizzy spin walk — spin while walking forward, stumble back
async (f, hx, hy, _sx, _sy, dir) => {
const a0 = f.angle || 0
f.play('hit')
await Promise.all([
k.tween(f.pos.x, hx + dir * 70, 0.4, (v) => { f.pos.x = v }),
k.tween(a0, a0 + 720, 0.4, (v) => { f.angle = v }),
])
f.angle = 0
spawnEmoteText(f.pos.x, f.pos.y - 40, '@_@', '#ffcc00')
await k.wait(0.15)
// Stumble back
f.play('idle')
for (let i = 0; i < 3; i++) {
await k.tween(f.pos.x, f.pos.x - dir * 25, 0.1, (v) => { f.pos.x = v })
f.pos.y = hy - 5; await k.wait(0.02); f.pos.y = hy
}
},
// 10: Wall-to-wall dash — full screen sprint and back
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZoomWhoosh()
spawnAfterimages(f, 3)
await k.tween(f.pos.x, dir > 0 ? W - 30 : 30, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
sfxBlock(); k.shake(4)
await k.wait(0.1)
sfxZoomWhoosh(); spawnAfterimages(f, 3)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 11: Earthquake stomp march — stomp forward and back
async (f, hx, hy, sx, sy, dir) => {
f.play('attack')
for (let i = 0; i < 4; i++) {
const step = dir * 25
await k.tween(f.pos.x, f.pos.x + step, 0.08, (v) => { f.pos.x = v })
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
await k.wait(0.05); f.scale.y = sy
}
spawnSparks(f.pos.x, hy, 6, '#ff6600')
await k.wait(0.1)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 12: Taunt walk — strut toward opponent, gesture, moonwalk back
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
await k.tween(f.pos.x, hx + dir * 90, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('special')
spawnEmoteText(f.pos.x, f.pos.y - 50, 'TOO EASY!', '#ff44ff')
await k.wait(0.3)
// Moonwalk back
f.play('idle')
await k.tween(f.pos.x, hx, 0.5, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 13: Victory lap sprint — run around in a loop
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxZoomWhoosh()
await k.tween(f.pos.x, hx + dir * 140, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
// Jump over to other side
sfxBoing()
await Promise.all([
k.tween(f.pos.x, hx + dir * 140, 0.25, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 100, 0.125, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.125, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
f.play('win'); spawnSparks(f.pos.x, hy - 20, 5, theme.accent)
await k.wait(0.15)
sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 14: Nervous pacing — quick short paces with jittery hops
async (f, hx, hy) => {
for (let i = 0; i < 6; i++) {
const dx = (Math.random() - 0.5) * 80
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.1, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 10 - Math.random() * 20, 0.05, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
),
])
}
},
// 15: Ground pound jump — massive vertical jump + slam
async (f, hx, hy) => {
sfxBoing(); f.play('kick')
await k.tween(f.pos.y, hy - 150, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
await k.wait(0.1)
f.play('attack')
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxExplosion(); k.shake(15)
spawnSparks(f.pos.x, hy, 15, '#ff6600')
spawnShockwave(f.pos.x, hy, '#ff4400')
spawnEmoteText(f.pos.x, f.pos.y - 60, 'BOOM!', '#ff2d2d')
await k.wait(0.2)
},
// 16: Zigzag sprint — fast zigzag across the arena
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxZoomWhoosh()
const pts = [
{ x: hx + dir * 50, y: hy - 40 },
{ x: hx + dir * 100, y: hy },
{ x: hx + dir * 150, y: hy - 40 },
{ x: hx + dir * 100, y: hy },
{ x: hx, y: hy },
]
for (const pt of pts) {
await Promise.all([
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
])
}
},
// 17: Double jump kick — jump, kick, jump higher, kick
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxBoing()
const mid = hx + dir * 60
await Promise.all([
k.tween(f.pos.x, mid, 0.15, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 50, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxKick(); spawnSparks(mid, hy - 30, 4, '#00ffcc')
f.play('special'); sfxBoing()
await Promise.all([
k.tween(f.pos.x, mid + dir * 40, 0.15, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxKick(); spawnSparks(f.pos.x, hy - 50, 6, '#ff44ff')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 18: Cocky strut and lean — walk forward, lean back with attitude
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
await k.tween(f.pos.x, hx + dir * 80, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
// Lean back
await k.tween(0, -25, 0.15, (v) => { f.angle = v }, k.easings.easeOutQuad)
await k.wait(0.25)
spawnEmoteText(f.pos.x, f.pos.y - 50, 'EZ', '#ffcc00')
await k.tween(-25, 0, 0.1, (v) => { f.angle = v })
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 19: Windmill sprint — spin while running across
async (f, hx, hy, _sx, _sy, dir) => {
const a0 = f.angle || 0
const anims = ['attack', 'kick', 'special'] as const
let animIdx = 0
const far = hx + dir * 130
f.play(anims[0])
// Sprint while spinning
const dur = 0.4
const startX = f.pos.x
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (far - startX) * t
f.angle = a0 + t * 1080 // 3 full spins
if (Math.floor(t * 12) !== Math.floor((t - 0.01) * 12)) {
f.play(anims[animIdx++ % anims.length])
}
})
f.angle = 0; sfxBlock(); k.shake(3)
await k.wait(0.1)
// Sprint back
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 20: Sumo stomp walk — heavy stomps advancing and retreating
async (f, hx, hy, _sx, sy, dir) => {
f.play('attack')
for (let i = 0; i < 6; i++) {
const step = (i < 3 ? dir : -dir) * 20
await k.tween(f.pos.x, f.pos.x + step, 0.1, (v) => { f.pos.x = v })
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
await k.wait(0.06); f.scale.y = sy
spawnSparks(f.pos.x, hy, 2, '#886644')
}
},
// 21: Flex pose — walk to center, scale up, pose
async (f, hx, hy, sx, sy) => {
const center = W / 2
f.play('win')
await k.tween(f.pos.x, center, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
await k.tween(1, 1.35, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
sfxPowerUp()
spawnEmoteText(f.pos.x, f.pos.y - 60, 'COME ON!', '#ff4444')
await k.wait(0.35)
await k.tween(1.35, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 22: Jump-kick air combo — big jump with mid-air kicks
async (f, hx, hy, _sx, _sy, dir) => {
sfxBoing()
const apex = hy - 120
const far = hx + dir * 100
await k.tween(f.pos.y, apex, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Mid-air combo while moving
f.play('kick'); sfxKick()
await k.tween(f.pos.x, far, 0.1, (v) => { f.pos.x = v })
f.play('attack'); sfxPunch()
await k.tween(f.pos.x, far + dir * 30, 0.08, (v) => { f.pos.x = v })
f.play('special')
spawnSparks(f.pos.x, f.pos.y, 8, '#ff44ff')
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(4)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 23: Air guitar walk — bouncing left and right with music
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxSlideUp()
spawnEmoteText(f.pos.x, f.pos.y - 50, '~♪~', '#ff44ff')
for (let i = 0; i < 6; i++) {
const dx = (i % 2 === 0 ? dir : -dir) * (30 + i * 10)
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.12, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 15, 0.06, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v })
),
])
}
f.pos.x = hx
},
// 24: Crane kick walk — slow martial arts advancing
async (f, hx, hy, _sx, sy, dir) => {
f.play('kick')
await Promise.all([
k.tween(f.pos.x, hx + dir * 60, 0.3, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 30, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
k.tween(1, 1.15, 0.3, (v) => { f.scale.y = sy * v }, k.easings.easeOutQuad),
])
await k.wait(0.3)
f.play('special'); sfxKick()
spawnSparks(f.pos.x + dir * 20, hy - 40, 6, '#00ccff')
await k.wait(0.15)
f.scale.y = sy
await Promise.all([
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 25: Girlfriend kiss — girlfriend walks in, kiss, hearts, she storms off
async (f, hx, hy, _sx, _sy, dir) => {
const startGfX = hx - dir * 120
const gfColor = ['#ff69b4', '#ff1493', '#ff6eb4', '#cc44aa'][Math.floor(Math.random() * 4)]
const cs = SF
const gf = k.add([k.rect(18 * cs, 35 * cs), k.pos(startGfX, hy - 18 * cs), k.anchor('center'), k.color(safeColor(k, gfColor)), k.opacity(0.9), k.z(11)])
const gfHair = k.add([k.rect(22 * cs, 10 * cs), k.pos(startGfX, hy - 35 * cs), k.anchor('center'), k.color(safeColor(k, '#8b4513')), k.opacity(0.9), k.z(12)])
// Walk toward fighter
await Promise.all([
k.tween(gf.pos.x, hx - dir * 15, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInOutQuad),
])
// Kiss!
sfxBoing()
const heart = k.add([k.text('*smooch*', { size: 10 }), k.pos(hx, hy - 55), k.anchor('center'), k.color(safeColor(k, '#ff1493')), k.opacity(1), k.z(30)])
k.tween(heart.pos.y, hy - 90, 0.8, (v) => { heart.pos.y = v })
k.tween(1, 0, 0.8, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
// Spawn floating hearts
for (let i = 0; i < 5; i++) {
const h = k.add([k.text('♥', { size: 8 + Math.random() * 6 }), k.pos(hx + (Math.random() - 0.5) * 30, hy - 30), k.color(safeColor(k, '#ff69b4')), k.opacity(0.8), k.z(25), k.anchor('center')])
k.tween(h.pos.y, hy - 70 - Math.random() * 40, 0.7, (v) => { h.pos.y = v })
k.tween(h.pos.x, h.pos.x + (Math.random() - 0.5) * 40, 0.7, (v) => { h.pos.x = v })
k.tween(0.8, 0, 0.7, (v) => { h.opacity = v }).then(() => { if (h.exists()) h.destroy() })
}
f.play('win')
await k.wait(0.5)
// She storms off
spawnEmoteText(gf.pos.x, gf.pos.y - 30, 'Good luck babe!', '#ff69b4')
await k.tween(gf.pos.x, startGfX, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInQuad)
gf.destroy(); gfHair.destroy()
},
// 26: Push-up walk — walk forward doing push-ups at intervals
async (f, hx, hy, sx, sy, dir) => {
for (let i = 0; i < 3; i++) {
await k.tween(f.pos.x, f.pos.x + dir * 35, 0.15, (v) => { f.pos.x = v })
// Push-up squash
await k.tween(1, 0.55, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeOutQuad)
await k.tween(0.55, 1, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeInQuad)
}
spawnEmoteText(f.pos.x, f.pos.y - 45, `${3 + Math.floor(Math.random() * 97)}!`, '#00ff88')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 27: Matrix dodge walk — dodge bullets walking forward
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 4; i++) {
await k.tween(f.pos.x, f.pos.x + dir * 25, 0.1, (v) => { f.pos.x = v })
// Lean dodge
const lean = (i % 2 === 0 ? -30 : 30)
await k.tween(0, lean, 0.08, (v) => { f.angle = v }, k.easings.easeOutQuad)
await k.tween(lean, 0, 0.08, (v) => { f.angle = v }, k.easings.easeInQuad)
}
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 28: Bouncy chest thump — bounce toward opponent thumping chest
async (f, hx, hy, _sx, _sy, dir) => {
f.play('attack')
for (let i = 0; i < 4; i++) {
await Promise.all([
k.tween(f.pos.x, f.pos.x + dir * 30, 0.1, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 25, 0.05, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
),
])
sfxBlock(); k.shake(2)
}
spawnEmoteText(f.pos.x, f.pos.y - 50, 'LET\'S GO!', '#ff4444')
await k.wait(0.15)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 29: Speed demon — afterimage dash back and forth
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special')
for (let i = 0; i < 3; i++) {
sfxZoomWhoosh(); spawnAfterimages(f, 3)
await k.tween(f.pos.x, hx + dir * (80 + i * 30), 0.08, (v) => { f.pos.x = v }, k.easings.easeInQuad)
spawnAfterimages(f, 2)
await k.tween(f.pos.x, hx - dir * 20, 0.08, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
}
f.pos.x = hx
},
]
// === CREATOR-EXCLUSIVE MAGICAL SHOWBOATS ===
const creatorShowboats: ShowboatFn[] = [
// 0: Levitate across — float up and glide across the screen trailing golden ₿
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxPowerUp()
await k.tween(f.pos.y, hy - 100, 0.25, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Float across dropping ₿ rain
const startX = f.pos.x
const far = hx + dir * 180
const dur = 0.6
let lastDrop = 0
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (far - startX) * t
f.pos.y = hy - 100 + Math.sin(t * Math.PI * 3) * 20
if (t - lastDrop > 0.08) {
lastDrop = t
const d = k.add([k.text('₿', { size: 6 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 10), k.color(safeColor(k, '#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
k.tween(d.pos.y, hy + 10, 0.5, (v) => { d.pos.y = v })
k.tween(0.7, 0, 0.5, (v) => { d.opacity = v }).then(() => { if (d.exists()) d.destroy() })
}
})
screenFlash('#ffd700', 0.06)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 1: Golden portal teleport — vanish, portals, reappear at random spots
async (f, hx, hy) => {
sfxSpecial()
// Vanish with flash
screenFlash('#ffd700', 0.08)
f.opacity = 0
// Teleport to 3 spots with golden portals
const spots = [
{ x: W * 0.2, y: hy - 60 },
{ x: W * 0.8, y: hy - 80 },
{ x: W * 0.5, y: hy - 120 },
]
for (const spot of spots) {
// Portal flash at destination
const portal = k.add([k.circle(20), k.pos(spot.x, spot.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.6), k.z(15), k.anchor('center')])
spawnSparks(spot.x, spot.y, 6, '#ffd700')
f.pos.x = spot.x; f.pos.y = spot.y; f.opacity = 1
f.play('special')
await k.wait(0.15)
f.opacity = 0
k.tween(0.6, 0, 0.2, (v) => { portal.opacity = v }).then(() => { if (portal.exists()) portal.destroy() })
sfxZap()
}
// Return home with big flash
screenFlash('#ffd700', 0.1)
f.pos.x = hx; f.pos.y = hy; f.opacity = 1
f.play('win')
spawnShockwave(hx, hy, '#ffd700')
sfxExplosion()
await k.wait(0.2)
},
// 2: Code rain summon — float up, summon massive golden code rain
async (f, hx, hy) => {
f.play('special'); sfxPowerUp()
await k.tween(f.pos.y, hy - 80, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Massive code rain
const chars = '₿01∞⚡⛏§∂∆≈≠'.split('')
for (let col = 0; col < 20; col++) {
const cx = Math.random() * W
for (let row = 0; row < 3; row++) {
const ch = chars[Math.floor(Math.random() * chars.length)]
const d = k.add([
k.text(ch, { size: 6 + Math.random() * 4 }), k.pos(cx, -10 - row * 20),
k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')),
k.opacity(0.5 + Math.random() * 0.4), k.z(8), k.anchor('center'),
])
k.tween(d.pos.y, hy + 30, 0.5 + Math.random() * 0.4, (v) => { d.pos.y = v; d.opacity = Math.max(0, 1 - (v - hy + 30) / 60) })
.then(() => { if (d.exists()) d.destroy() })
}
}
await k.wait(0.6)
await k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 3: ₿ orbit explosion — fly to center, ₿ symbols orbit then explode outward
async (f, hx, hy, sx, sy) => {
sfxZoomWhoosh(); f.play('special')
const center = W / 2
await Promise.all([
k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad),
k.tween(f.pos.y, hy - 60, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
])
// Spawn orbiting ₿
const orbs: GameObj[] = []
for (let i = 0; i < 8; i++) {
const orb = k.add([k.text('₿', { size: 8 + Math.random() * 6 }), k.pos(center, f.pos.y), k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(15), k.anchor('center')])
orbs.push(orb)
}
// Orbit for a moment
const startTime = k.time()
const orbitCancel = k.onUpdate(() => {
const t = k.time() - startTime
for (let i = 0; i < orbs.length; i++) {
const a = t * 4 + i * (Math.PI * 2 / orbs.length)
const r = 30 + t * 15
orbs[i].pos.x = f.pos.x + Math.cos(a) * r
orbs[i].pos.y = f.pos.y + Math.sin(a) * r * 0.5
}
})
await k.wait(0.5)
orbitCancel.cancel()
// Explode outward
sfxExplosion(); screenFlash('#ffd700', 0.08); k.shake(8)
for (const orb of orbs) {
const angle = Math.random() * Math.PI * 2
k.tween(orb.pos.x, orb.pos.x + Math.cos(angle) * 200, 0.3, (v) => { orb.pos.x = v })
k.tween(orb.pos.y, orb.pos.y + Math.sin(angle) * 200, 0.3, (v) => { orb.pos.y = v })
k.tween(0.8, 0, 0.3, (v) => { orb.opacity = v }).then(() => { if (orb.exists()) orb.destroy() })
}
await k.wait(0.2)
await Promise.all([
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 4: Lightning flight — zigzag across the screen at super speed with lightning
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZoomWhoosh()
f.opacity = 0.8
const pts = [
{ x: hx + dir * 60, y: hy - 80 },
{ x: W / 2, y: hy - 140 },
{ x: hx + dir * 180, y: hy - 60 },
{ x: W / 2, y: hy - 100 },
{ x: hx, y: hy },
]
for (const pt of pts) {
spawnAfterimages(f, 2)
await Promise.all([
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
])
// Lightning bolt at each stop
sfxZap()
const bolt = k.add([k.rect(2, 30 + Math.random() * 40), k.pos(f.pos.x, f.pos.y - 50), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(20), k.anchor('center'), k.rotate(Math.random() * 30 - 15)])
k.tween(0.9, 0, 0.15, (v) => { bolt.opacity = v }).then(() => { if (bolt.exists()) bolt.destroy() })
}
f.opacity = 1
},
// 5: Satoshi meditation — float to center, golden aura pulse, wisdom text
async (f, hx, hy, sx, sy) => {
sfxPowerUp(); f.play('special')
await Promise.all([
k.tween(f.pos.x, W / 2, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 70, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
])
// Golden aura rings
for (let i = 0; i < 3; i++) {
const ring = k.add([k.circle(10), k.pos(f.pos.x, f.pos.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.5), k.z(9), k.anchor('center'), k.scale(1)])
k.tween(1, (60 + i * 20) / 10, 0.4, (v) => { ring.scale.x = v; ring.scale.y = v })
k.tween(0.5, 0, 0.4, (v) => { ring.opacity = v }).then(() => { if (ring.exists()) ring.destroy() })
await k.wait(0.12)
}
// Scale pulse
await k.tween(1, 1.2, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
const sayings = ['PROOF OF WORK', 'VERIFY, DON\'T TRUST', '21 MILLION', 'TICK TOCK NEXT BLOCK', 'NOT YOUR KEYS', 'STAY HUMBLE STACK SATS']
spawnEmoteText(f.pos.x, f.pos.y - 50, sayings[Math.floor(Math.random() * sayings.length)], '#ffd700')
await k.wait(0.4)
await k.tween(1.2, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
await Promise.all([
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 6: Golden tornado — spin rapidly while moving in a circle across the arena
async (f, hx, hy) => {
sfxZoomWhoosh(); f.play('special')
const cx = W / 2, cy = hy - 50, radius = 100
const dur = 0.8
let lastParticle = 0
await k.tween(0, Math.PI * 2, dur, (a) => {
f.pos.x = cx + Math.cos(a) * radius
f.pos.y = cy + Math.sin(a) * radius * 0.4
f.angle = (a / (Math.PI * 2)) * 1440 // 4 full spins
if (a - lastParticle > 0.3) {
lastParticle = a
const p = k.add([k.text('₿', { size: 5 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.6), k.z(9), k.anchor('center')])
k.tween(0.6, 0, 0.4, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
}
})
f.angle = 0
sfxExplosion(); k.shake(6)
spawnShockwave(f.pos.x, f.pos.y, '#ffd700')
f.pos.x = hx; f.pos.y = hy
},
// 7: Laptop hack — walk forward, pull out laptop (rect), type furiously, spawn effects
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special')
await k.tween(f.pos.x, hx + dir * 50, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
// Laptop
const cs = SF
const laptop = k.add([k.rect(24 * cs, 16 * cs), k.pos(f.pos.x + dir * 15, hy - 20 * cs), k.anchor('center'), k.color(safeColor(k, '#333333')), k.opacity(0.9), k.z(12)])
const screen = k.add([k.rect(20 * cs, 12 * cs), k.pos(f.pos.x + dir * 15, hy - 22 * cs), k.anchor('center'), k.color(safeColor(k, '#00ff00')), k.opacity(0.7), k.z(13)])
// Type — spawn code characters
const codeChars = '₿{}();=>0x1f'.split('')
for (let i = 0; i < 8; i++) {
const ch = codeChars[Math.floor(Math.random() * codeChars.length)]
const t = k.add([k.text(ch, { size: 5 }), k.pos(screen.pos.x + (Math.random() - 0.5) * 15, screen.pos.y), k.color(safeColor(k, '#00ff00')), k.opacity(0.8), k.z(14), k.anchor('center')])
k.tween(t.pos.y, t.pos.y - 25 - Math.random() * 20, 0.3, (v) => { t.pos.y = v })
k.tween(0.8, 0, 0.3, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() })
sfxCoin()
await k.wait(0.06)
}
spawnEmoteText(f.pos.x, f.pos.y - 55, 'HACKING...', '#00ff00')
await k.wait(0.2)
laptop.destroy(); screen.destroy()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 8: Bitcoin rain shower — fly across dropping ₿ coins everywhere
async (f, hx, hy, _sx, _sy, dir) => {
sfxPowerUp(); f.play('win')
await k.tween(f.pos.y, hy - 90, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Fly from one side to the other
const startX = dir > 0 ? 30 : W - 30
const endX = dir > 0 ? W - 30 : 30
f.pos.x = startX
const dur = 0.6
let lastCoin = 0
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (endX - startX) * t
f.pos.y = hy - 90 + Math.sin(t * Math.PI * 4) * 15
if (t - lastCoin > 0.04) {
lastCoin = t
const coin = k.add([k.text('₿', { size: 8 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 15), k.color(safeColor(k, Math.random() > 0.5 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(9), k.anchor('center')])
k.tween(coin.pos.y, hy + 10, 0.4, (v) => { coin.pos.y = v })
k.tween(coin.pos.x, coin.pos.x + (Math.random() - 0.5) * 30, 0.4, (v) => { coin.pos.x = v })
k.tween(0.8, 0, 0.4, (v) => { coin.opacity = v }).then(() => { if (coin.exists()) coin.destroy() })
}
})
sfxCoin()
f.pos.x = hx
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 9: Dimension rift — creator tears open a golden rift, steps through, appears elsewhere
async (f, hx, hy) => {
// Rift opens
sfxSpecial()
const riftX = f.pos.x + 30
const rift = k.add([k.rect(4, 50), k.pos(riftX, hy - 25), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(15)])
await k.tween(4, 30, 0.2, (v) => { rift.width = v })
spawnSparks(riftX, hy - 25, 8, '#ffd700')
// Step through — vanish
f.play('special')
await k.tween(f.pos.x, riftX, 0.1, (v) => { f.pos.x = v })
f.opacity = 0; sfxZap()
// Rift at destination
const destX = W - riftX
const rift2 = k.add([k.rect(30, 50), k.pos(destX, hy - 25), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(15)])
spawnSparks(destX, hy - 25, 8, '#ffd700')
await k.wait(0.15)
// Step out
f.pos.x = destX; f.opacity = 1; f.play('win')
sfxSpecial()
await k.wait(0.2)
// Close rifts
k.tween(0.9, 0, 0.2, (v) => { rift.opacity = v; rift2.opacity = v }).then(() => { if (rift.exists()) rift.destroy(); if (rift2.exists()) rift2.destroy() })
// Float home
await Promise.all([
k.tween(f.pos.x, hx, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 40, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
},
// 10: Golden ascension — rise way up, glow, descend with shockwave
async (f, hx, hy, sx, sy) => {
sfxPowerUp(); f.play('special')
await k.tween(f.pos.y, hy - 160, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Glow pulse
await k.tween(1, 1.5, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
screenFlash('#ffd700', 0.06)
// Spawn golden aura particles
for (let i = 0; i < 10; i++) {
const p = k.add([k.text('✦', { size: 4 + Math.random() * 6 }), k.pos(f.pos.x + (Math.random() - 0.5) * 40, f.pos.y + (Math.random() - 0.5) * 30), k.color(safeColor(k, '#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
k.tween(0.7, 0, 0.5, (v) => { p.opacity = v; p.pos.y -= 0.5 }).then(() => { if (p.exists()) p.destroy() })
}
spawnEmoteText(f.pos.x, f.pos.y - 40, 'HODL', '#ffd700')
await k.wait(0.3)
await k.tween(1.5, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
// Slam down
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxExplosion(); k.shake(12)
spawnShockwave(hx, hy, '#ffd700')
spawnSparks(hx, hy, 15, '#ffd700')
await k.wait(0.15)
},
// 11: Phase walk — flicker opacity while walking through the opponent's space
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZap()
const far = dir > 0 ? W - 40 : 40
const dur = 0.5
await k.tween(0, 1, dur, (t) => {
f.pos.x = hx + (far - hx) * t
f.opacity = 0.2 + Math.abs(Math.sin(t * Math.PI * 8)) * 0.7
})
f.opacity = 1; sfxSpecial()
screenFlash('#ffd700', 0.05)
spawnSparks(f.pos.x, hy - 20, 6, '#ffd700')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 12: Bullet time attack — slow-mo dash with afterimages + golden bullet traces
async (f, hx, hy, _sx, _sy, dir) => {
f.play('attack')
// Darken screen for bullet time
const overlay = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(50)])
await k.tween(0, 0.35, 0.08, (v) => { overlay.opacity = v })
// Slow-mo lean back
await k.tween(0, -30, 0.2, (v) => { f.angle = v }, k.easings.easeOutQuad)
// Golden bullet traces fly past
for (let i = 0; i < 6; i++) {
const by = hy - 15 - Math.random() * 50
const bullet = k.add([k.rect(15, 2), k.pos(dir > 0 ? W + 10 : -10, by), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(51), k.anchor('center')])
const bdir = dir > 0 ? -1 : 1
k.tween(bullet.pos.x, bullet.pos.x + bdir * (W + 30), 0.35, (v) => { bullet.pos.x = v }).then(() => { if (bullet.exists()) bullet.destroy() })
sfxZoomWhoosh()
await k.wait(0.04)
}
await k.wait(0.1)
// Snap upright and dash through
await k.tween(-30, 0, 0.06, (v) => { f.angle = v })
f.play('special'); sfxZoomWhoosh()
// Dash with afterimages
const endX = hx + dir * 160
for (let i = 0; i < 6; i++) {
const ghostX = hx + (endX - hx) * (i / 6)
const ghost = k.add([k.rect(16, 30), k.pos(ghostX, hy - 15), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.3 - i * 0.04), k.z(49)])
k.tween(0.3, 0, 0.4, (v) => { ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() })
}
await k.tween(f.pos.x, endX, 0.12, (v) => { f.pos.x = v }, k.easings.easeInQuad)
screenFlash('#ffd700', 0.06); sfxCritical(); k.shake(8)
spawnSparks(f.pos.x, hy - 20, 10, '#ffd700')
await k.wait(0.15)
// Zip back
await k.tween(overlay.opacity, 0, 0.1, (v) => { overlay.opacity = v })
overlay.destroy()
await k.tween(f.pos.x, hx, 0.1, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 13: ₿ throne summon — conjure golden throne, sit imperiously, dismiss
async (f, hx, hy) => {
sfxSpecial()
const cs = SF
// Throne pieces
const seat = k.add([k.rect(40 * cs, 8 * cs), k.pos(hx, hy - 5), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
const back = k.add([k.rect(35 * cs, 30 * cs), k.pos(hx, hy - 25 * cs), k.anchor('center'), k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(8)])
const armL = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx - 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
const armR = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx + 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
const parts = [seat, back, armL, armR]
// Fade in
for (const p of parts) k.tween(0, 0.8, 0.2, (v) => { p.opacity = v })
spawnSparks(hx, hy - 15, 6, '#ffd700')
await k.wait(0.25)
// Sit
f.play('win')
await k.tween(f.pos.y, hy - 12, 0.12, (v) => { f.pos.y = v }, k.easings.easeInQuad)
spawnEmoteText(hx, f.pos.y - 50, 'kneel', '#ffd700')
await k.wait(0.6)
// Stand and dismiss
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v })
for (const p of parts) k.tween(p.opacity, 0, 0.2, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
await k.wait(0.2)
},
// 14: Disco dance — Creator gets down with golden disco moves
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
const colors = ['#ffd700', '#ff8c00', '#ffee88', '#c8a000', '#ffffff', '#ffd700']
spawnEmoteText(f.pos.x, f.pos.y - 55, '~♪ SATOSHI SHUFFLE ♪~', '#ffd700')
for (let i = 0; i < 10; i++) {
const dx = (i % 2 === 0 ? 25 : -25) + (Math.random() - 0.5) * 15
f.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'win')
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.06, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 15, 0.03, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.03, (v) => { f.pos.y = v })),
])
spawnSparks(f.pos.x, hy - 10, 2, colors[i % colors.length])
}
f.pos.x = hx
},
]
// Showboat loop: picks random showboats in sequence while active
async function _showboatLoop(side: 'a' | 'b') {
const tag = side === 'a' ? 'fighterA' : 'fighterB'
const homeX = side === 'a' ? HOME_A : HOME_B
const homeY = GROUND_Y - 6
const dir = side === 'a' ? 1 : -1
const arch = side === 'a' ? botA.archetype : botB.archetype
const isCreator = arch === 'the_creator'
// Creator uses magical showboats 60% of the time, normal 40%
const pool = isCreator ? [...showboats, ...creatorShowboats, ...creatorShowboats] : showboats
while (_showboatActive[side]) {
const f = k.get(tag)[0] as Fighter | undefined
if (!f) break
const sx = f.scale.x
const sy = f.scale.y
const idx = Math.floor(Math.random() * pool.length)
try {
await pool[idx](f, homeX, homeY, sx, sy, dir)
// 40% chance of comedy sound punctuation after each showboat
if (Math.random() < 0.4) sfxRandomComedy()
} catch { break }
// Restore state after each showboat
const ff = k.get(tag)[0]
if (ff) {
ff.pos.x = homeX; ff.pos.y = homeY
ff.scale.x = sx; ff.scale.y = sy
ff.angle = 0; ff.opacity = 1
ff.play('idle')
}
// Brief pause between showboats
if (_showboatActive[side]) await k.wait(0.15 + Math.random() * 0.35)
}
}
// === EXTRACTED SYSTEMS ===
const entranceSystem = createEntranceSystem(choreoCtx, { HOME_A, HOME_B, botA, botB })
const roundSystem = createRoundSystem(choreoCtx, {
HOME_A, HOME_B, botA, botB,
choreographyMap, playBlock,
destroyMorphOverlays, playMorph, getMorphOrder, applyCreatorOmniMorph,
spawnEmoteText, spawnCrowdSigns,
judgeDoSomethingFunny, maybeShowHuman,
cameraZoom, hyperSpeedLines, schizoCut,
showSpeechBubble,
})
const finisherSystem = createFinisherSystem(choreoCtx, {
HOME_A, HOME_B, botA, botB,
spawnEmoteText, spawnCrowdSigns, spawnHeart,
spawnWeaponProp, destroyProp, spawnProp,
playBow, playFistBump, playHelpUp,
cameraZoom, hyperSpeedLines, showSpeechBubble,
spawnHumanCoach,
})
return {
k,
async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {},
showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) {
showSpeechBubble(side, text, duration)
},
startTalking(side: 'a' | 'b') { startTalking(side) },
stopTalking(side: 'a' | 'b') { stopTalking(side) },
startShowboating(side: 'a' | 'b') {
if (_showboatActive[side]) return
_showboatActive[side] = true
_showboatLoop(side)
},
stopShowboating(side: 'a' | 'b') {
_showboatActive[side] = false
},
async playEntrance() {
return entranceSystem.playEntrance()
},
// Physical contact delegates
_spawnDizzyStars(fighter: Fighter, duration: number) { return roundSystem._spawnDizzyStars(fighter, duration) },
_resetPositions() { return roundSystem._resetPositions() },
_brawlRapid(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlRapid(winningSide, intensity) },
_brawlKnockdown(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlKnockdown(winningSide, intensity) },
_brawlWallBounce(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlWallBounce(winningSide, intensity) },
_brawlDizzyStagger(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlDizzyStagger(winningSide, intensity) },
_brawlSuplex(attackerSide: 'a' | 'b', intensity: number) { return roundSystem._brawlSuplex(attackerSide, intensity) },
_brawlPingPong(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlPingPong(winningSide, intensity) },
_brawlGroundPound(attackerSide: 'a' | 'b', intensity: number) { return roundSystem._brawlGroundPound(attackerSide, intensity) },
_brawlHaymaker(attackerSide: 'a' | 'b', _intensity: number) { return roundSystem._brawlHaymaker(attackerSide, _intensity) },
_brawlBodyCheck(winningSide: 'a' | 'b' | null, _intensity: number) { return roundSystem._brawlBodyCheck(winningSide, _intensity) },
_brawlExchange(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlExchange(winningSide, intensity) },
_clinchCombo(attackerSide: 'a' | 'b', intensity: number) { return roundSystem._clinchCombo(attackerSide, intensity) },
_counterAttack(defenderSide: 'a' | 'b') { return roundSystem._counterAttack(defenderSide) },
async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) {
return roundSystem.playAttack(side, choreographyName, isCritical)
},
async playRound(event: RoundEvent) {
return roundSystem.playRound(event)
},
async playTaunt(side: 'a' | 'b') {
return roundSystem.playTaunt(side)
},
async playDodge(side: 'a' | 'b') {
return roundSystem.playDodge(side)
},
async playKO(winningSide: 'a' | 'b', winnerName: string) {
return finisherSystem.playKO(winningSide, winnerName)
},
async playPerfect(winningSide: 'a' | 'b', winnerName: string) {
return finisherSystem.playPerfect(winningSide, winnerName)
},
2026-03-08 21:35:04 +00:00
async playVictoryCelebration(winningSide: 'a' | 'b', isUpset: boolean) {
return finisherSystem.playVictoryCelebration(winningSide, isUpset)
2026-03-08 21:35:04 +00:00
},
startMusic() { startMusic() },
stopMusic() { stopMusic() },
destroy() {
stopMusic()
stopTalking('a')
stopTalking('b')
2026-03-08 23:55:58 +00:00
// Destroy lingering speech bubbles
activeBubbleA.forEach(o => { if (o.exists()) o.destroy() })
activeBubbleB.forEach(o => { if (o.exists()) o.destroy() })
activeBubbleA.length = 0
activeBubbleB.length = 0
// Clear all tracked timers
for (const id of cleanupTimers) {
clearInterval(id)
clearTimeout(id)
}
cleanupTimers.clear()
2026-03-08 23:55:58 +00:00
// Destroy all remaining game objects before quitting
try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {}
k.quit()
},
}
}
export type FightSceneController = Awaited<ReturnType<typeof createFightScene>>