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' export type { FightSceneConfig, RoundEvent } from './fight/types' import { ARENA_THEMES, spriteAnims } from './fight/constants' import { pickChoreography } from './fight/choreography/picker' export { pickChoreography } import { isPerfMode, perfParticles, isReducedMotion, haptic, GROUND_Y_RATIO, HOME_A_RATIO, HOME_B_RATIO, REFERENCE_HEIGHT, MOBILE_SCALE_CAP, SPRITE_LOAD_TIMEOUT_MS, FIGHTER_BASE_SCALE, FIGHTER_TIER_SCALE, JUDGE_SCALE, HUMAN_COACH_SCALE, HUMAN_RUN_SCALE, AMBIENT_PARTICLE_COUNT, SKY_GRADIENT_BANDS, GROUND_DEPTH_LINES, PERSPECTIVE_GRID_LINES, PILLAR_WIDTH, SPEECH_BUBBLE_MAX_CHARS, SPEECH_BUBBLE_WRAP, SPEECH_BUBBLE_MAX_LINES, SPEECH_BUBBLE_FONT_SIZE, SPEECH_BUBBLE_DEFAULT_DURATION, HUMAN_SHOW_CHANCE, HUMAN_COACH_CHANCE, HUMAN_RUN_CHANCE, HAPTIC_MIN_SHAKE, HAPTIC_SCALE, } 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 { drawArenaDecor } from './fight/arena-renderer' import { createShowboatSystem } from './fight/showboats' import { sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit, sfxJetpack, sfxExplosion, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly, sfxBoing, sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh, sfxRapidPunch, sfxPowerUp, sfxCoin, sfxRandomComedy, startMusic, stopMusic, announceCool, clearAllAudioTimers, } from './audio' // Convert any CSS color string to a kaplay Color object safely function safeColor(k: ReturnType, 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 >= HAPTIC_MIN_SHAKE) haptic(Math.min(100, intensity * HAPTIC_SCALE)) 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, botA.archetype, botA.customization) } 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, botB.archetype, botB.customization) } 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 { try { await Promise.race([ k.loadSprite(name, src, opts), new Promise((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), SPRITE_LOAD_TIMEOUT_MS)), ]) } 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, botA.archetype, botA.customization) 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, botB.archetype, botB.customization) 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 = {} 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>() function trackedTimeout(fn: () => void, ms: number): ReturnType { const id = setTimeout(() => { cleanupTimers.delete(id); fn() }, ms) cleanupTimers.add(id) return id } function trackedInterval(fn: () => void, ms: number): ReturnType { const id = setInterval(fn, ms) cleanupTimers.add(id) return id } function clearTracked(id: ReturnType) { 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.max(MOBILE_SCALE_CAP, Math.min(1, H / REFERENCE_HEIGHT)) const GROUND_Y = H * GROUND_Y_RATIO const HOME_A = W * HOME_A_RATIO const HOME_B = W * HOME_B_RATIO function spawnBullet(fromX: number, fromY: number, toX: number, toY: number): Promise { 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) { const lineCount = isPerfMode() ? 6 : 16 const lines: GameObj[] = [] 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) } 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 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() {} 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 < SKY_GRADIENT_BANDS; band++) { const bandH = GROUND_Y / SKY_GRADIENT_BANDS 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 <= GROUND_DEPTH_LINES; 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 <= PERSPECTIVE_GRID_LINES; i++) { const x = i * (W / PERSPECTIVE_GRID_LINES) // 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(PILLAR_WIDTH, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) k.add([k.rect(PILLAR_WIDTH, GROUND_Y), k.pos(W - PILLAR_WIDTH, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) // Accent trim on pillars k.add([k.rect(1, GROUND_Y), k.pos(PILLAR_WIDTH, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)]) k.add([k.rect(1, GROUND_Y), k.pos(W - PILLAR_WIDTH - 1, 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 < AMBIENT_PARTICLE_COUNT; 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({ k, W, H, GROUND_Y, arena, theme, safeColor }) // 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(JUDGE_SCALE * SF), k.z(4), k.opacity(0.9), 'judge', ]) // On mobile (narrow canvas), reduce fighter size so they fit the viewport const isMobileCanvas = W < 600 const fBase = isMobileCanvas ? 1.1 : FIGHTER_BASE_SCALE const fTier = isMobileCanvas ? 0.2 : FIGHTER_TIER_SCALE const scaleA = (fBase + botA.tier * fTier) * 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 = (fBase + botB.tier * fTier) * 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), 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 = SPEECH_BUBBLE_DEFAULT_DURATION) { 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 display = clean.length > SPEECH_BUBBLE_MAX_CHARS ? clean.slice(0, SPEECH_BUBBLE_MAX_CHARS - 1) + '...' : clean const lines = wrapBubbleText(display, SPEECH_BUBBLE_WRAP) const fontSize = SPEECH_BUBBLE_FONT_SIZE 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 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 hideSpeechBubble(side: 'a' | 'b') { const els = side === 'a' ? activeBubbleA : activeBubbleB if (!els.length) return els.forEach(el => { if (!el.exists()) return k.tween(el.opacity, 0, 0.25, (v) => { el.opacity = v }).then(() => { if (el.exists()) el.destroy() }) }) if (side === 'a') activeBubbleA = [] else activeBubbleB = [] } 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) if (lines.length > SPEECH_BUBBLE_MAX_LINES) { const truncated = [...lines.slice(0, SPEECH_BUBBLE_MAX_LINES - 1), lines.slice(SPEECH_BUBBLE_MAX_LINES - 1).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 }> = {} 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' ? HUMAN_COACH_SCALE : -HUMAN_COACH_SCALE, HUMAN_COACH_SCALE), 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 ? HUMAN_RUN_SCALE : -HUMAN_RUN_SCALE, HUMAN_RUN_SCALE), 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 > HUMAN_SHOW_CHANCE) return const side: 'a' | 'b' = Math.random() < 0.5 ? 'a' : 'b' const action = Math.random() if (action < HUMAN_COACH_CHANCE) { // Coach appearance (most common) const mood = Math.random() < 0.5 ? 'cheer' : 'panic' await spawnHumanCoach(side, mood) } else if (action < HUMAN_RUN_CHANCE) { // 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++) { 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) } } // === SHOWBOAT SYSTEM (extracted to fight/showboats.ts) === const showboatSystem = createShowboatSystem(choreoCtx, { HOME_A, HOME_B, SF, botA, botB, spawnEmoteText }) // === 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) }, hideSpeechBubble(side: 'a' | 'b') { hideSpeechBubble(side) }, startTalking(side: 'a' | 'b') { startTalking(side) }, stopTalking(side: 'a' | 'b') { stopTalking(side) }, startShowboating(side: 'a' | 'b') { showboatSystem.startShowboating(side) }, stopShowboating(side: 'a' | 'b') { showboatSystem.stopShowboating(side) }, async playEntrance() { // Timeout entrance to prevent hanging on mobile (max 8s) await Promise.race([ entranceSystem.playEntrance(), new Promise(r => trackedTimeout(r, 8000)), ]) }, // 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) }, async playVictoryCelebration(winningSide: 'a' | 'b', isUpset: boolean) { return finisherSystem.playVictoryCelebration(winningSide, isUpset) }, startMusic() { startMusic() }, stopMusic() { stopMusic() }, destroy() { stopMusic() stopTalking('a') stopTalking('b') // 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() // Clear audio timers (SFX/voice stagger delays) clearAllAudioTimers() // Destroy all remaining game objects before quitting try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {} k.quit() }, } } export type FightSceneController = Awaited>