Files
botfights/frontend/src/game/FightScene.ts
T
DorianandClaude Opus 4.6 d47be39156 fix: pass archetype and customization in sprite fallback (BUG-10)
Sprite generation fallback now preserves archetype and customization
instead of silently reverting to a random character.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:39:32 +00:00

1273 lines
51 KiB
TypeScript

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,
} from './audio'
// 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 >= 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<void> {
try {
await Promise.race([
k.loadSprite(name, src, opts),
new Promise<never>((_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)
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.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<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) {
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',
])
const scaleA = (FIGHTER_BASE_SCALE + botA.tier * FIGHTER_TIER_SCALE) * 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 = (FIGHTER_BASE_SCALE + botB.tier * FIGHTER_TIER_SCALE) * 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 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<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' ? 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)
},
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() {
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)
},
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()
// 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>>