2026-03-06 16:27:54 +00:00
|
|
|
import kaplay from 'kaplay'
|
2026-03-08 20:30:02 +00:00
|
|
|
import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp, LoadSpriteOpt } from 'kaplay'
|
|
|
|
|
|
2026-03-07 19:42:49 +00:00
|
|
|
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'
|
2026-03-08 23:00:57 +00:00
|
|
|
import type { Fighter, FightSceneConfig, RoundEvent, FightContext, ChoreoContext } from './fight/types'
|
2026-03-08 22:16:50 +00:00
|
|
|
export type { FightSceneConfig, RoundEvent } from './fight/types'
|
2026-03-09 06:31:22 +00:00
|
|
|
import { ARENA_THEMES, spriteAnims } from './fight/constants'
|
|
|
|
|
import { pickChoreography } from './fight/choreography/picker'
|
|
|
|
|
export { pickChoreography }
|
|
|
|
|
import { isPerfMode, perfParticles, isReducedMotion, haptic } from './fight/config'
|
2026-03-08 22:23:50 +00:00
|
|
|
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'
|
2026-03-08 23:00:57 +00:00
|
|
|
import { createChoreographyMap } from './fight/choreography'
|
|
|
|
|
import { createFactories } from './fight/choreography/factories'
|
2026-03-08 23:24:20 +00:00
|
|
|
import { createMorphSystem } from './fight/morphs'
|
|
|
|
|
import { createEntranceSystem } from './fight/entrances'
|
|
|
|
|
import { createRoundSystem } from './fight/rounds'
|
|
|
|
|
import { createFinisherSystem } from './fight/finishers'
|
2026-03-09 06:38:46 +00:00
|
|
|
import { drawArenaDecor } from './fight/arena-renderer'
|
|
|
|
|
import { createShowboatSystem } from './fight/showboats'
|
2026-03-06 22:13:19 +00:00
|
|
|
import {
|
|
|
|
|
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
|
2026-03-08 23:46:30 +00:00
|
|
|
sfxJetpack, sfxExplosion, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly,
|
|
|
|
|
sfxBoing, sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh,
|
|
|
|
|
sfxRapidPunch, sfxPowerUp, sfxCoin, sfxRandomComedy,
|
|
|
|
|
startMusic, stopMusic, announceCool,
|
2026-03-08 22:11:58 +00:00
|
|
|
} from './audio'
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// 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])
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
export async function createFightScene(config: FightSceneConfig) {
|
2026-03-06 16:27:54 +00:00
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-09 00:23:39 +00:00
|
|
|
// Wrap k.shake to support reduced motion + haptic feedback
|
|
|
|
|
const _origShake = k.shake.bind(k)
|
|
|
|
|
k.shake = ((intensity: number) => {
|
|
|
|
|
if (intensity >= 3) haptic(Math.min(100, intensity * 8))
|
|
|
|
|
if (!isReducedMotion()) _origShake(intensity)
|
|
|
|
|
}) as typeof k.shake
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
const colorsA = getBotColors(botA.seed)
|
|
|
|
|
const colorsB = getBotColors(botB.seed)
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
let sheetA: string
|
|
|
|
|
let sheetB: string
|
2026-03-07 19:42:49 +00:00
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
try {
|
2026-03-07 19:42:49 +00:00
|
|
|
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)
|
2026-03-06 23:44:40 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[FightScene] Failed to generate sprite for botA:', err)
|
|
|
|
|
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
|
|
|
|
}
|
|
|
|
|
try {
|
2026-03-07 19:42:49 +00:00
|
|
|
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)
|
2026-03-06 23:44:40 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[FightScene] Failed to generate sprite for botB:', err)
|
|
|
|
|
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 19:42:49 +00:00
|
|
|
const isHumanA = botA.archetype === 'human'
|
|
|
|
|
const isHumanB = botB.archetype === 'human'
|
|
|
|
|
|
2026-03-08 15:54:58 +00:00
|
|
|
// Helper: load sprite with a 5s timeout so mobile never hangs forever
|
2026-03-08 20:30:02 +00:00
|
|
|
async function loadSpriteWithTimeout(name: string, src: string, opts: LoadSpriteOpt): Promise<void> {
|
2026-03-08 15:54:58 +00:00
|
|
|
try {
|
|
|
|
|
await Promise.race([
|
|
|
|
|
k.loadSprite(name, src, opts),
|
|
|
|
|
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
|
|
|
|
|
])
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[FightScene] Sprite '${name}' failed/timed out:`, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Await sprite loading — timeout prevents mobile hangs
|
2026-03-06 23:44:40 +00:00
|
|
|
await Promise.all([
|
2026-03-08 15:54:58 +00:00
|
|
|
loadSpriteWithTimeout('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
|
|
|
|
loadSpriteWithTimeout('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
2026-03-06 23:44:40 +00:00
|
|
|
])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 19:42:49 +00:00
|
|
|
// 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)
|
2026-03-08 15:54:58 +00:00
|
|
|
loadSpriteWithTimeout('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
2026-03-07 19:42:49 +00:00
|
|
|
}
|
|
|
|
|
if (isHumanB) {
|
|
|
|
|
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
2026-03-08 15:54:58 +00:00
|
|
|
loadSpriteWithTimeout('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
2026-03-07 19:42:49 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// 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 },
|
|
|
|
|
}
|
2026-03-08 15:54:58 +00:00
|
|
|
await loadSpriteWithTimeout('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
|
2026-03-06 22:13:19 +00:00
|
|
|
|
2026-03-07 19:42:49 +00:00
|
|
|
// Human sprites (the bot owner's silly human avatar for crowd)
|
2026-03-07 09:15:41 +00:00
|
|
|
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([
|
2026-03-08 15:54:58 +00:00
|
|
|
loadSpriteWithTimeout('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
|
|
|
|
loadSpriteWithTimeout('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
2026-03-07 09:15:41 +00:00
|
|
|
])
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
const W = k.width()
|
|
|
|
|
const H = k.height()
|
2026-03-08 20:39:11 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-03-07 22:07:52 +00:00
|
|
|
// Scale factor so sprites shrink on small canvases (reference: 500px tall)
|
2026-03-08 22:23:50 +00:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
2026-03-07 22:07:52 +00:00
|
|
|
const SF = Math.min(1, H / 500)
|
2026-03-06 16:27:54 +00:00
|
|
|
const GROUND_Y = H * 0.78
|
2026-03-06 22:13:19 +00:00
|
|
|
const HOME_A = W * 0.28
|
|
|
|
|
const HOME_B = W * 0.72
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function spawnBullet(fromX: number, fromY: number, toX: number, toY: number): Promise<void> {
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
const bullet = k.add([
|
|
|
|
|
k.rect(6, 2),
|
|
|
|
|
k.pos(fromX, fromY),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,'#ffee00')),
|
2026-03-06 22:13:19 +00:00
|
|
|
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),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,color)),
|
2026-03-06 22:13:19 +00:00
|
|
|
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() {
|
2026-03-08 20:30:02 +00:00
|
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
|
|
|
const fB = k.get('fighterB')[0] as Fighter
|
2026-03-06 22:13:19 +00:00
|
|
|
if (!fA || !fB) return
|
|
|
|
|
const origAX = fA.pos.x, origBX = fB.pos.x
|
|
|
|
|
const origAY = fA.pos.y, origBY = fB.pos.y
|
|
|
|
|
// 3-5 rapid cuts
|
|
|
|
|
const cuts = 3 + Math.floor(Math.random() * 3)
|
|
|
|
|
for (let i = 0; i < cuts; i++) {
|
|
|
|
|
// Random offset both fighters
|
|
|
|
|
fA.pos.x = origAX + (Math.random() - 0.5) * 30
|
|
|
|
|
fA.pos.y = origAY + (Math.random() - 0.5) * 15
|
|
|
|
|
fB.pos.x = origBX + (Math.random() - 0.5) * 30
|
|
|
|
|
fB.pos.y = origBY + (Math.random() - 0.5) * 15
|
|
|
|
|
screenFlash(Math.random() > 0.5 ? '#000000' : '#ffffff', 0.03)
|
|
|
|
|
await k.wait(0.04 + Math.random() * 0.03)
|
|
|
|
|
}
|
|
|
|
|
fA.pos.x = origAX; fA.pos.y = origAY
|
|
|
|
|
fB.pos.x = origBX; fB.pos.y = origBY
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hyperspeed lines — converging toward a point
|
|
|
|
|
function hyperSpeedLines(targetX: number, targetY: number, duration: number = 0.3) {
|
2026-03-09 00:00:48 +00:00
|
|
|
const lineCount = isPerfMode() ? 6 : 16
|
2026-03-08 20:30:02 +00:00
|
|
|
const lines: GameObj[] = []
|
2026-03-09 00:00:48 +00:00
|
|
|
for (let i = 0; i < lineCount; i++) {
|
|
|
|
|
const angle = (i / lineCount) * Math.PI * 2
|
2026-03-06 22:13:19 +00:00
|
|
|
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),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,'#ffffff')), k.opacity(0.3), k.z(52),
|
2026-03-06 22:13:19 +00:00
|
|
|
k.rotate(angle * 180 / Math.PI + 180),
|
|
|
|
|
])
|
|
|
|
|
line.onUpdate(() => {
|
|
|
|
|
const dx = targetX - line.pos.x
|
|
|
|
|
const dy = targetY - line.pos.y
|
|
|
|
|
const d = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
|
if (d > 10) {
|
|
|
|
|
line.pos.x += (dx / d) * 600 * k.dt()
|
|
|
|
|
line.pos.y += (dy / d) * 600 * k.dt()
|
|
|
|
|
}
|
|
|
|
|
line.opacity -= 0.8 * k.dt()
|
|
|
|
|
if (line.opacity <= 0 && line.exists()) line.destroy()
|
|
|
|
|
})
|
|
|
|
|
lines.push(line)
|
|
|
|
|
}
|
2026-03-08 23:55:58 +00:00
|
|
|
trackedTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Flash the whole screen
|
|
|
|
|
function screenFlash(color: string, duration: number = 0.1) {
|
|
|
|
|
const flash = k.add([
|
|
|
|
|
k.rect(W, H),
|
|
|
|
|
k.pos(0, 0),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,color)),
|
2026-03-06 22:13:19 +00:00
|
|
|
k.opacity(0.4),
|
|
|
|
|
k.z(50),
|
|
|
|
|
])
|
|
|
|
|
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:07:21 +00:00
|
|
|
// 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
|
2026-03-08 20:30:02 +00:00
|
|
|
function spawnAfterimages(fighter: Fighter, count: number = 3) {
|
2026-03-08 00:07:21 +00:00
|
|
|
if (!fighter?.exists()) return
|
2026-03-09 00:00:48 +00:00
|
|
|
const n = isPerfMode() ? Math.max(1, Math.round(count * 0.5)) : count
|
|
|
|
|
for (let i = 0; i < n; i++) {
|
2026-03-08 00:07:21 +00:00
|
|
|
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(
|
2026-03-08 20:30:02 +00:00
|
|
|
fighters: Fighter[],
|
2026-03-08 00:07:21 +00:00
|
|
|
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)
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 12:08:18 +00:00
|
|
|
// Grotesque close-up overlays removed — noops retained for call-site compatibility
|
2026-03-08 20:30:02 +00:00
|
|
|
function spawnGrotesqueDetails(_fighter: Fighter, _scaleFactor: number) {}
|
2026-03-08 12:08:18 +00:00
|
|
|
function destroyGrotesqueDetails() {}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
k.scene('fight', () => {
|
2026-03-06 23:44:40 +00:00
|
|
|
// === SKY / BACKGROUND ATMOSPHERE ===
|
|
|
|
|
// Vertical gradient backdrop — 6 bands from dark ceiling to ground horizon
|
|
|
|
|
const bgBase = theme.bg
|
|
|
|
|
for (let band = 0; band < 6; band++) {
|
|
|
|
|
const bandH = GROUND_Y / 6
|
|
|
|
|
const brighten = band * 3
|
|
|
|
|
k.add([
|
|
|
|
|
k.rect(W, bandH + 1), k.pos(0, band * bandH),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,bgBase)),
|
2026-03-06 23:44:40 +00:00
|
|
|
k.opacity(1 - band * 0.04),
|
|
|
|
|
])
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
// Subtle horizon glow — wide soft band just above ground
|
|
|
|
|
k.add([
|
|
|
|
|
k.rect(W, 20), k.pos(0, GROUND_Y - 20),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,theme.accent)), k.opacity(0.06),
|
2026-03-06 23:44:40 +00:00
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// === GROUND ===
|
|
|
|
|
// Main ground fill
|
2026-03-07 00:14:46 +00:00
|
|
|
k.add([k.rect(W, H - GROUND_Y + 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.ground))])
|
2026-03-06 23:44:40 +00:00
|
|
|
// Bright edge line at the surface
|
2026-03-07 00:14:46 +00:00
|
|
|
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.accent)), k.opacity(0.7)])
|
2026-03-06 23:44:40 +00:00
|
|
|
// Secondary highlight
|
2026-03-07 00:14:46 +00:00
|
|
|
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3)])
|
2026-03-06 23:44:40 +00:00
|
|
|
// Ground gradient — fades deeper
|
|
|
|
|
for (let i = 1; i <= 12; i++) {
|
2026-03-07 00:14:46 +00:00
|
|
|
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)])
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
// Perspective grid on the floor — converging lines for 3D depth
|
|
|
|
|
const vanishX = W / 2
|
|
|
|
|
const vanishY = GROUND_Y * 0.6
|
|
|
|
|
for (let i = 0; i <= 24; i++) {
|
|
|
|
|
const x = i * (W / 24)
|
|
|
|
|
// Lines from floor edge pointing toward vanishing point
|
|
|
|
|
const dx = x - vanishX
|
|
|
|
|
const topX = vanishX + dx * 0.3
|
|
|
|
|
k.add([
|
|
|
|
|
k.rect(1, GROUND_Y * 0.4 + 10), k.pos(x, GROUND_Y),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,theme.accent)), k.opacity(0.05),
|
2026-03-06 23:44:40 +00:00
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
// Horizontal depth lines on ground
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const gy = GROUND_Y + 8 + i * i * 3
|
|
|
|
|
if (gy < H) {
|
2026-03-07 00:14:46 +00:00
|
|
|
k.add([k.rect(W, 1), k.pos(0, gy), k.color(safeColor(k,theme.accent)), k.opacity(0.08 - i * 0.012)])
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === SIDE PILLARS / FRAME ===
|
|
|
|
|
// Subtle dark pillars on edges for framing
|
2026-03-07 00:14:46 +00:00
|
|
|
k.add([k.rect(8, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
|
|
|
|
|
k.add([k.rect(8, GROUND_Y), k.pos(W - 8, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
|
2026-03-06 23:44:40 +00:00
|
|
|
// Accent trim on pillars
|
2026-03-07 00:14:46 +00:00
|
|
|
k.add([k.rect(1, GROUND_Y), k.pos(8, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
|
|
|
|
|
k.add([k.rect(1, GROUND_Y), k.pos(W - 9, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
|
2026-03-06 23:44:40 +00:00
|
|
|
|
|
|
|
|
// === AMBIENT PARTICLES ===
|
|
|
|
|
// Floating dust/embers in the air (subtle, always present)
|
|
|
|
|
for (let i = 0; i < 18; i++) {
|
|
|
|
|
const particle = k.add([
|
|
|
|
|
k.circle(0.5 + Math.random() * 1.5),
|
|
|
|
|
k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
2026-03-07 00:14:46 +00:00
|
|
|
k.color(safeColor(k,theme.accent)),
|
2026-03-06 23:44:40 +00:00
|
|
|
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
|
|
|
|
|
})
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// Arena-specific decorations
|
2026-03-09 06:38:46 +00:00
|
|
|
drawArenaDecor({ k, W, H, GROUND_Y, arena, theme, safeColor })
|
2026-03-06 22:13:19 +00:00
|
|
|
|
|
|
|
|
// Umpire chair (center-back, behind fighters)
|
|
|
|
|
const CHAIR_X = W / 2
|
2026-03-07 22:07:52 +00:00
|
|
|
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)])
|
2026-03-06 22:13:19 +00:00
|
|
|
for (let i = 0; i < 6; i++) {
|
2026-03-07 22:07:52 +00:00
|
|
|
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)])
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
2026-03-07 22:07:52 +00:00
|
|
|
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)])
|
2026-03-06 22:13:19 +00:00
|
|
|
k.add([
|
|
|
|
|
k.sprite('judge', { anim: 'idle' }),
|
|
|
|
|
k.pos(CHAIR_X, CHAIR_SEAT_Y),
|
|
|
|
|
k.anchor('bot'),
|
2026-03-07 22:07:52 +00:00
|
|
|
k.scale(1.3 * SF),
|
2026-03-06 22:13:19 +00:00
|
|
|
k.z(4),
|
|
|
|
|
k.opacity(0.9),
|
|
|
|
|
'judge',
|
|
|
|
|
])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 22:07:52 +00:00
|
|
|
const scaleA = (1.8 + botA.tier * 0.4) * SF
|
2026-03-06 23:44:40 +00:00
|
|
|
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'])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 22:07:52 +00:00
|
|
|
const scaleB = (1.8 + botB.tier * 0.4) * SF
|
2026-03-06 23:44:40 +00:00
|
|
|
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'])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
config.onReady?.()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
k.go('fight')
|
|
|
|
|
|
|
|
|
|
let comboA = 0
|
|
|
|
|
let comboB = 0
|
|
|
|
|
|
2026-03-08 23:00:57 +00:00
|
|
|
// === CHOREOGRAPHY SYSTEM (extracted to fight/choreography/) ===
|
|
|
|
|
const choreoCtx: ChoreoContext = {
|
|
|
|
|
k, W, H, GROUND_Y, FRAME_SIZE, theme,
|
|
|
|
|
safeColor: (color: string) => safeColor(k, color),
|
2026-03-08 23:55:58 +00:00
|
|
|
trackedTimeout, trackedInterval, clearTracked,
|
2026-03-08 23:00:57 +00:00
|
|
|
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,
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
2026-03-08 23:00:57 +00:00
|
|
|
const choreographyMap = createChoreographyMap(choreoCtx)
|
|
|
|
|
const { spawnWeaponProp, destroyProp, spawnProp, moveProp } = createFactories(choreoCtx)
|
2026-03-07 00:14:46 +00:00
|
|
|
|
|
|
|
|
async function playBlock(side: 'a' | 'b') {
|
2026-03-08 20:30:02 +00:00
|
|
|
const defender = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
2026-03-07 00:14:46 +00:00
|
|
|
if (!defender) return
|
|
|
|
|
const dir = side === 'a' ? 1 : -1
|
|
|
|
|
// Spawn shield in front
|
2026-03-07 00:53:15 +00:00
|
|
|
const shieldObjs = spawnWeaponProp('shield', defender.pos.x + dir * 20, defender.pos.y - 25, side === 'b', 19)
|
2026-03-07 00:14:46 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 23:24:20 +00:00
|
|
|
// === MORPH SYSTEM (extracted to fight/morphs.ts) ===
|
|
|
|
|
const morphSystem = createMorphSystem(choreoCtx, {
|
|
|
|
|
botA, botB, colorsA, colorsB, isHumanA, isHumanB, loadSpriteWithTimeout,
|
|
|
|
|
})
|
|
|
|
|
const { destroyMorphOverlays, playMorph, applyCreatorOmniMorph, getMorphOrder } = morphSystem
|
2026-03-06 22:13:19 +00:00
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// === EMOTION & SPORTSMANSHIP SYSTEM ===
|
|
|
|
|
|
|
|
|
|
const heartfeltLines = [
|
2026-03-07 09:15:41 +00:00
|
|
|
'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!',
|
2026-03-07 00:14:46 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const crowdSympathyLines = [
|
2026-03-07 09:15:41 +00:00
|
|
|
'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!',
|
2026-03-07 00:14:46 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const respectLines = [
|
2026-03-07 09:15:41 +00:00
|
|
|
'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.',
|
2026-03-07 00:14:46 +00:00
|
|
|
]
|
|
|
|
|
|
2026-03-07 00:53:15 +00:00
|
|
|
// Sanitize text for Kaplay (treats [ ] as styled text tags)
|
2026-03-07 11:42:32 +00:00
|
|
|
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
|
2026-03-09 06:29:09 +00:00
|
|
|
let activeBubbleA: GameObj[] = []
|
|
|
|
|
let activeBubbleB: GameObj[] = []
|
2026-03-07 11:42:32 +00:00
|
|
|
|
2026-03-09 06:29:09 +00:00
|
|
|
function destroyBubble(els: GameObj[]) {
|
2026-03-07 11:42:32 +00:00
|
|
|
els.forEach(el => { if (el.exists()) el.destroy() })
|
|
|
|
|
els.length = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = 3) {
|
2026-03-08 20:30:02 +00:00
|
|
|
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
2026-03-07 11:42:32 +00:00
|
|
|
if (!fighter || !text) return
|
|
|
|
|
|
|
|
|
|
// Kill previous bubble on this side
|
|
|
|
|
if (side === 'a') destroyBubble(activeBubbleA)
|
|
|
|
|
else destroyBubble(activeBubbleB)
|
|
|
|
|
|
2026-03-09 06:29:09 +00:00
|
|
|
const allEls: GameObj[] = []
|
2026-03-07 11:42:32 +00:00
|
|
|
|
|
|
|
|
// Sanitize and truncate
|
|
|
|
|
const clean = safeText(text)
|
|
|
|
|
if (!clean) return
|
|
|
|
|
const maxLen = 80
|
|
|
|
|
const display = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '...' : clean
|
|
|
|
|
const lines = wrapBubbleText(display, 18)
|
|
|
|
|
const fontSize = 12
|
|
|
|
|
const lineH = fontSize + 4
|
|
|
|
|
const padX = 10, padY = 8
|
|
|
|
|
const textW = Math.max(60, Math.min(lines.reduce((m, l) => Math.max(m, l.length * (fontSize * 0.6)), 0) + 8, W * 0.38))
|
|
|
|
|
const bubbleW = textW + padX * 2
|
|
|
|
|
const bubbleH = lines.length * lineH + padY * 2
|
|
|
|
|
const tailSize = 10
|
|
|
|
|
|
2026-03-07 15:20:14 +00:00
|
|
|
// 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)
|
2026-03-07 11:42:32 +00:00
|
|
|
|
|
|
|
|
const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
|
2026-03-07 11:59:10 +00:00
|
|
|
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
|
2026-03-07 11:42:32 +00:00
|
|
|
|
2026-03-07 11:59:10 +00:00
|
|
|
// Single border rect (no glow/accent layers — keeps rendering clean)
|
2026-03-07 11:42:32 +00:00
|
|
|
const border = k.add([
|
2026-03-07 11:59:10 +00:00
|
|
|
k.rect(bubbleW + 4, bubbleH + 4),
|
|
|
|
|
k.pos(bx - bubbleW / 2 - 2, by - 2),
|
2026-03-07 11:42:32 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-03-07 11:59:10 +00:00
|
|
|
// Tail — pixel arrow pointing down to fighter
|
2026-03-07 11:42:32 +00:00
|
|
|
const tailX = bx + (side === 'a' ? bubbleW * 0.15 : -bubbleW * 0.15)
|
2026-03-07 11:59:10 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-07 11:42:32 +00:00
|
|
|
|
2026-03-07 15:20:14 +00:00
|
|
|
// 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'
|
2026-03-07 11:42:32 +00:00
|
|
|
for (let i = 0; i < lines.length; i++) {
|
2026-03-07 15:20:14 +00:00
|
|
|
tc.fillText(lines[i], padX, padY + i * lineH + 2)
|
|
|
|
|
}
|
|
|
|
|
const spriteKey = `bubble_${side}_${Date.now()}`
|
|
|
|
|
k.loadSprite(spriteKey, textCanvas.toDataURL()).then(() => {
|
2026-03-07 11:42:32 +00:00
|
|
|
const tEl = k.add([
|
2026-03-07 15:20:14 +00:00
|
|
|
k.sprite(spriteKey),
|
|
|
|
|
k.pos(bx - bubbleW / 2, by),
|
|
|
|
|
k.opacity(1), k.z(57),
|
2026-03-07 11:42:32 +00:00
|
|
|
])
|
|
|
|
|
allEls.push(tEl)
|
2026-03-07 15:20:14 +00:00
|
|
|
})
|
2026-03-07 11:42:32 +00:00
|
|
|
|
2026-03-07 11:59:10 +00:00
|
|
|
// Border glow pulse
|
2026-03-07 11:42:32 +00:00
|
|
|
border.onUpdate(() => {
|
|
|
|
|
border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-07 11:59:10 +00:00
|
|
|
// Pop-in animation
|
2026-03-07 11:42:32 +00:00
|
|
|
const anchorX = bx, anchorY = by + bubbleH
|
|
|
|
|
allEls.forEach(el => {
|
|
|
|
|
const origX = el.pos.x, origY = el.pos.y
|
2026-03-07 11:59:10 +00:00
|
|
|
const origOpacity = el.opacity
|
2026-03-07 11:42:32 +00:00
|
|
|
el.pos.x = anchorX + (origX - anchorX) * 0.1
|
|
|
|
|
el.pos.y = anchorY + (origY - anchorY) * 0.1
|
2026-03-07 11:59:10 +00:00
|
|
|
el.opacity = 0
|
2026-03-07 11:42:32 +00:00
|
|
|
k.tween(0, 1, 0.25, (t) => {
|
|
|
|
|
el.pos.x = anchorX + (origX - anchorX) * t
|
|
|
|
|
el.pos.y = anchorY + (origY - anchorY) * t
|
2026-03-07 11:59:10 +00:00
|
|
|
el.opacity = origOpacity * t
|
2026-03-07 11:42:32 +00:00
|
|
|
}, k.easings.easeOutBack)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Store reference
|
|
|
|
|
if (side === 'a') activeBubbleA = allEls
|
|
|
|
|
else activeBubbleB = allEls
|
|
|
|
|
|
|
|
|
|
// Fade out after duration
|
2026-03-08 23:55:58 +00:00
|
|
|
trackedTimeout(() => {
|
2026-03-07 11:42:32 +00:00
|
|
|
allEls.forEach(el => {
|
|
|
|
|
if (!el.exists()) return
|
|
|
|
|
k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => {
|
|
|
|
|
if (el.exists()) el.destroy()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}, duration * 1000)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wrapBubbleText(text: string, maxChars: number): string[] {
|
|
|
|
|
const words = text.split(' ')
|
|
|
|
|
const lines: string[] = []
|
|
|
|
|
let line = ''
|
|
|
|
|
for (const word of words) {
|
|
|
|
|
if (line.length + word.length + 1 > maxChars && line.length > 0) {
|
|
|
|
|
lines.push(line)
|
|
|
|
|
line = word
|
|
|
|
|
} else {
|
|
|
|
|
line = line ? line + ' ' + word : word
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (line) lines.push(line)
|
|
|
|
|
// Max 5 lines, last line truncated
|
|
|
|
|
if (lines.length > 5) {
|
|
|
|
|
const truncated = [...lines.slice(0, 4), lines.slice(4).join(' ').slice(0, maxChars - 3) + '...']
|
|
|
|
|
return truncated
|
|
|
|
|
}
|
|
|
|
|
return lines
|
|
|
|
|
}
|
2026-03-07 00:53:15 +00:00
|
|
|
|
2026-03-08 12:08:18 +00:00
|
|
|
// Talking mouth animation — a small rectangle that opens/closes near the fighter's face
|
2026-03-09 06:29:09 +00:00
|
|
|
const talkingAnims: Record<string, { objs: GameObj[]; timer: ReturnType<typeof setInterval> }> = {}
|
2026-03-08 12:08:18 +00:00
|
|
|
|
|
|
|
|
function startTalking(side: 'a' | 'b') {
|
|
|
|
|
stopTalking(side) // cleanup any existing
|
2026-03-08 20:30:02 +00:00
|
|
|
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
2026-03-08 12:08:18 +00:00
|
|
|
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
|
2026-03-08 20:39:11 +00:00
|
|
|
const timer = trackedInterval(() => {
|
2026-03-08 12:08:18 +00:00
|
|
|
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
|
2026-03-08 20:39:11 +00:00
|
|
|
clearTracked(anim.timer)
|
2026-03-08 12:08:18 +00:00
|
|
|
anim.objs.forEach(o => { if (o.exists()) o.destroy() })
|
|
|
|
|
delete talkingAnims[side]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Spawn floating text above a position
|
|
|
|
|
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
|
|
|
|
|
const label = k.add([
|
2026-03-07 00:53:15 +00:00
|
|
|
k.text(safeText(text), { size: 10 }),
|
2026-03-07 00:14:46 +00:00
|
|
|
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() })
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:00:16 +00:00
|
|
|
// 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()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-07 00:14:46 +00:00
|
|
|
|
2026-03-07 09:15:41 +00:00
|
|
|
// === HUMAN OWNER APPEARANCES ===
|
|
|
|
|
// The silly human behind each bot occasionally shows up
|
|
|
|
|
|
|
|
|
|
/** Spawn a human as a "coach" standing behind the fighter, cheering/panicking */
|
|
|
|
|
async function spawnHumanCoach(side: 'a' | 'b', mood: 'cheer' | 'panic' | 'coach') {
|
|
|
|
|
const spriteId = side === 'a' ? 'humanA' : 'humanB'
|
|
|
|
|
const homeX = side === 'a' ? HOME_A - 60 : HOME_B + 60
|
|
|
|
|
const tag = `human_${side}`
|
|
|
|
|
// Don't spawn if already visible
|
|
|
|
|
if (k.get(tag).length > 0) return
|
|
|
|
|
|
|
|
|
|
const human = k.add([
|
|
|
|
|
k.sprite(spriteId, { anim: mood }),
|
|
|
|
|
k.pos(homeX, GROUND_Y - 4),
|
|
|
|
|
k.anchor('bot'),
|
|
|
|
|
k.scale(side === 'a' ? 1.2 : -1.2, 1.2),
|
|
|
|
|
k.z(5),
|
|
|
|
|
k.opacity(0),
|
|
|
|
|
tag,
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Fade in
|
|
|
|
|
await k.tween(0, 0.85, 0.3, (v) => { human.opacity = v }, k.easings.easeOutQuad)
|
|
|
|
|
await k.wait(2.0)
|
|
|
|
|
// Fade out
|
|
|
|
|
await k.tween(0.85, 0, 0.5, (v) => { human.opacity = v }, k.easings.easeInQuad)
|
|
|
|
|
if (human.exists()) human.destroy()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Human runs across the screen (comedic moment) */
|
|
|
|
|
async function humanRunAcross(side: 'a' | 'b') {
|
|
|
|
|
const spriteId = side === 'a' ? 'humanA' : 'humanB'
|
|
|
|
|
const fromLeft = side === 'a'
|
|
|
|
|
const startX = fromLeft ? -40 : W + 40
|
|
|
|
|
const endX = fromLeft ? W + 40 : -40
|
|
|
|
|
|
|
|
|
|
const human = k.add([
|
|
|
|
|
k.sprite(spriteId, { anim: 'run' }),
|
|
|
|
|
k.pos(startX, GROUND_Y - 4),
|
|
|
|
|
k.anchor('bot'),
|
|
|
|
|
k.scale(fromLeft ? 1.3 : -1.3, 1.3),
|
|
|
|
|
k.z(8),
|
|
|
|
|
k.opacity(0.9),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Run across screen
|
|
|
|
|
await k.tween(startX, endX, 1.5, (v) => { human.pos.x = v }, k.easings.linear)
|
|
|
|
|
if (human.exists()) human.destroy()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Human briefly replaces the bot sprite (jumps in to fight, fails hilariously) */
|
|
|
|
|
async function humanJumpsIn(side: 'a' | 'b') {
|
|
|
|
|
const fighterTag = side === 'a' ? 'fighterA' : 'fighterB'
|
|
|
|
|
const spriteId = side === 'a' ? 'humanA' : 'humanB'
|
2026-03-08 20:30:02 +00:00
|
|
|
const fighter = k.get(fighterTag)[0] as Fighter
|
2026-03-07 09:15:41 +00:00
|
|
|
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'),
|
2026-03-07 10:18:46 +00:00
|
|
|
k.scale(fighter.scale.x / 3, fighter.scale.y / 3),
|
2026-03-07 09:15:41 +00:00
|
|
|
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()
|
2026-03-07 10:18:46 +00:00
|
|
|
if (roll > 0.15) return // 15% chance per round
|
2026-03-07 09:15:41 +00:00
|
|
|
|
|
|
|
|
const side: 'a' | 'b' = Math.random() < 0.5 ? 'a' : 'b'
|
|
|
|
|
const action = Math.random()
|
|
|
|
|
|
|
|
|
|
if (action < 0.4) {
|
|
|
|
|
// Coach appearance (most common)
|
|
|
|
|
const mood = Math.random() < 0.5 ? 'cheer' : 'panic'
|
|
|
|
|
await spawnHumanCoach(side, mood)
|
|
|
|
|
} else if (action < 0.7) {
|
|
|
|
|
// Run across screen
|
|
|
|
|
await humanRunAcross(side)
|
|
|
|
|
} else {
|
|
|
|
|
// Jump in and replace bot briefly (rarest, funniest)
|
|
|
|
|
await humanJumpsIn(side)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Respectful bow animation
|
2026-03-09 06:29:09 +00:00
|
|
|
async function playBow(fighter: Fighter) {
|
2026-03-07 00:14:46 +00:00
|
|
|
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
|
2026-03-09 06:29:09 +00:00
|
|
|
async function playFistBump(fighterA: Fighter, fighterB: Fighter) {
|
2026-03-07 00:14:46 +00:00
|
|
|
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
|
2026-03-09 06:29:09 +00:00
|
|
|
async function playHelpUp(winner: Fighter, loser: Fighter, winningSide: 'a' | 'b') {
|
2026-03-07 00:14:46 +00:00
|
|
|
const dir = winningSide === 'a' ? 1 : -1
|
|
|
|
|
const origWX = winner.pos.x
|
|
|
|
|
await k.tween(winner.pos.x, loser.pos.x - dir * 30, 0.4, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
|
|
|
|
winner.play('idle')
|
|
|
|
|
await k.wait(0.2)
|
|
|
|
|
loser.play('idle')
|
|
|
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 40, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
|
|
|
|
|
for (let i = 0; i < 3; i++) spawnHeart((winner.pos.x + loser.pos.x) / 2, winner.pos.y - 30)
|
|
|
|
|
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
|
|
|
|
await k.wait(0.6)
|
|
|
|
|
await k.tween(winner.pos.x, origWX, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === REFEREE LOBSTER FUNNY MOMENTS ===
|
|
|
|
|
|
|
|
|
|
async function judgeDoSomethingFunny() {
|
|
|
|
|
const judge = k.get('judge')[0]
|
|
|
|
|
if (!judge) return
|
|
|
|
|
|
|
|
|
|
const judgeOrigY = judge.pos.y
|
|
|
|
|
const funnyAction = Math.floor(Math.random() * 8)
|
|
|
|
|
|
|
|
|
|
if (funnyAction === 0) {
|
|
|
|
|
// Falls asleep, snaps awake
|
|
|
|
|
for (let z = 0; z < 3; z++) {
|
2026-03-08 23:55:58 +00:00
|
|
|
trackedTimeout(() => {
|
2026-03-07 00:14:46 +00:00
|
|
|
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')
|
|
|
|
|
}
|
2026-03-08 16:55:14 +00:00
|
|
|
sfxRandomComedy()
|
2026-03-07 00:14:46 +00:00
|
|
|
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
|
|
|
|
|
})
|
2026-03-08 16:55:14 +00:00
|
|
|
sfxRandomComedy()
|
2026-03-07 00:14:46 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:07:56 +00:00
|
|
|
|
2026-03-09 06:38:46 +00:00
|
|
|
// === SHOWBOAT SYSTEM (extracted to fight/showboats.ts) ===
|
|
|
|
|
const showboatSystem = createShowboatSystem(choreoCtx, { HOME_A, HOME_B, SF, botA, botB, spawnEmoteText })
|
2026-03-08 16:07:56 +00:00
|
|
|
|
|
|
|
|
|
2026-03-08 23:24:20 +00:00
|
|
|
// === 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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
return {
|
|
|
|
|
k,
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {},
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 11:42:32 +00:00
|
|
|
showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) {
|
|
|
|
|
showSpeechBubble(side, text, duration)
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-08 12:08:18 +00:00
|
|
|
startTalking(side: 'a' | 'b') { startTalking(side) },
|
|
|
|
|
stopTalking(side: 'a' | 'b') { stopTalking(side) },
|
|
|
|
|
|
2026-03-09 06:38:46 +00:00
|
|
|
startShowboating(side: 'a' | 'b') { showboatSystem.startShowboating(side) },
|
|
|
|
|
stopShowboating(side: 'a' | 'b') { showboatSystem.stopShowboating(side) },
|
2026-03-08 16:07:56 +00:00
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
async playEntrance() {
|
2026-03-08 23:24:20 +00:00
|
|
|
return entranceSystem.playEntrance()
|
2026-03-06 23:44:40 +00:00
|
|
|
},
|
|
|
|
|
|
2026-03-08 23:24:20 +00:00
|
|
|
// Physical contact delegates
|
2026-03-09 06:29:09 +00:00
|
|
|
_spawnDizzyStars(fighter: Fighter, duration: number) { return roundSystem._spawnDizzyStars(fighter, duration) },
|
2026-03-08 23:24:20 +00:00
|
|
|
_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) },
|
2026-03-08 17:01:06 +00:00
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) {
|
2026-03-08 23:24:20 +00:00
|
|
|
return roundSystem.playAttack(side, choreographyName, isCritical)
|
2026-03-06 16:27:54 +00:00
|
|
|
},
|
|
|
|
|
async playRound(event: RoundEvent) {
|
2026-03-08 23:24:20 +00:00
|
|
|
return roundSystem.playRound(event)
|
2026-03-06 16:27:54 +00:00
|
|
|
},
|
2026-03-06 22:13:19 +00:00
|
|
|
async playTaunt(side: 'a' | 'b') {
|
2026-03-08 23:24:20 +00:00
|
|
|
return roundSystem.playTaunt(side)
|
2026-03-06 22:13:19 +00:00
|
|
|
},
|
|
|
|
|
async playDodge(side: 'a' | 'b') {
|
2026-03-08 23:24:20 +00:00
|
|
|
return roundSystem.playDodge(side)
|
2026-03-06 22:13:19 +00:00
|
|
|
},
|
|
|
|
|
async playKO(winningSide: 'a' | 'b', winnerName: string) {
|
2026-03-08 23:24:20 +00:00
|
|
|
return finisherSystem.playKO(winningSide, winnerName)
|
2026-03-06 22:13:19 +00:00
|
|
|
},
|
|
|
|
|
async playPerfect(winningSide: 'a' | 'b', winnerName: string) {
|
2026-03-08 23:24:20 +00:00
|
|
|
return finisherSystem.playPerfect(winningSide, winnerName)
|
2026-03-06 16:27:54 +00:00
|
|
|
},
|
2026-03-08 21:35:04 +00:00
|
|
|
async playVictoryCelebration(winningSide: 'a' | 'b', isUpset: boolean) {
|
2026-03-08 23:24:20 +00:00
|
|
|
return finisherSystem.playVictoryCelebration(winningSide, isUpset)
|
2026-03-08 21:35:04 +00:00
|
|
|
},
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
startMusic() { startMusic() },
|
|
|
|
|
stopMusic() { stopMusic() },
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
destroy() {
|
2026-03-06 22:13:19 +00:00
|
|
|
stopMusic()
|
2026-03-08 20:39:11 +00:00
|
|
|
stopTalking('a')
|
|
|
|
|
stopTalking('b')
|
2026-03-08 23:55:58 +00:00
|
|
|
// Destroy lingering speech bubbles
|
|
|
|
|
activeBubbleA.forEach(o => { if (o.exists()) o.destroy() })
|
|
|
|
|
activeBubbleB.forEach(o => { if (o.exists()) o.destroy() })
|
|
|
|
|
activeBubbleA.length = 0
|
|
|
|
|
activeBubbleB.length = 0
|
|
|
|
|
// Clear all tracked timers
|
2026-03-08 20:39:11 +00:00
|
|
|
for (const id of cleanupTimers) {
|
|
|
|
|
clearInterval(id)
|
|
|
|
|
clearTimeout(id)
|
|
|
|
|
}
|
|
|
|
|
cleanupTimers.clear()
|
2026-03-08 23:55:58 +00:00
|
|
|
// Destroy all remaining game objects before quitting
|
|
|
|
|
try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {}
|
2026-03-06 16:27:54 +00:00
|
|
|
k.quit()
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
export type FightSceneController = Awaited<ReturnType<typeof createFightScene>>
|