import kaplay from 'kaplay' import type { GameObj } from 'kaplay' import { generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, } from './sprites' import { ARENA_THEMES, spriteAnims } from './fight/constants' import { drawArenaDecor } from './fight/arena-renderer' import { GROUND_Y_RATIO, FIGHTER_BASE_SCALE } from './fight/config' import { sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxBlock, sfxExplosion, startMusic, stopMusic, } from './audio' import { spawnSparks as _spawnSparks } from './fight/particles' import type { ArcadeConfig, ArcadeSceneController, ArcadeCallbacks, FighterInstance, FighterState, PlayerInput, InputEvent, } from './arcade/types' type KaplayInstance = ReturnType import { MAX_HP, FIGHTER_SCALE, CANVAS_WIDTH, CANVAS_HEIGHT, P1_START_X, P2_START_X, HIT_SHAKE_LIGHT, HIT_SHAKE_HEAVY, HIT_SHAKE_SPECIAL, HIT_FLASH_DURATION, SPARK_COUNT_LIGHT, SPARK_COUNT_HEAVY, SPARK_COUNT_SPECIAL, ROUND_START_DELAY, ROUND_END_DELAY, KO_SLOWMO_DURATION, COMBO_BUFFER_SIZE, } from './arcade/constants' import { updatePhysics, applyMovement, enforcePushBox, updateFacing } from './arcade/physics' import { updateStateMachine, startComboMove, enterHitstun, enterBlockstun, enterKO, enterWin } from './arcade/state-machine' import { checkHit, applyHit, resetCombo } from './arcade/combat' import type { HitResult } from './arcade/combat' import { MOVES } from './arcade/moves' import { spawnFireball, updateProjectiles, clearAllProjectiles } from './arcade/projectiles' // ═══════════════════════════════════════════════════════════════ // Convert CSS color to Kaplay Color // ═══════════════════════════════════════════════════════════════ function safeColor(k: KaplayInstance, color: string) { if (color.startsWith('#')) { try { return k.Color.fromHex(color) } catch { /* fall through */ } } 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]) } // ═══════════════════════════════════════════════════════════════ // Arcade Scene Factory // ═══════════════════════════════════════════════════════════════ export async function createArcadeScene(config: ArcadeConfig): Promise { const { canvas, player1, player2, arena, rounds, roundTime } = config const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost // --- Kaplay init --- const k = kaplay({ canvas, width: canvas.width || CANVAS_WIDTH, height: canvas.height || CANVAS_HEIGHT, background: theme.bg, global: false, scale: 1, crisp: true, texFilter: 'nearest', }) const W = k.width() const H = k.height() const GROUND_Y = H * GROUND_Y_RATIO // --- Timer management --- const cleanupTimers = new Set>() function trackedTimeout(fn: () => void, ms: number) { const id = setTimeout(() => { cleanupTimers.delete(id); fn() }, ms) cleanupTimers.add(id) return id } function trackedInterval(fn: () => void, ms: number) { const id = setInterval(fn, ms) cleanupTimers.add(id) return id } function clearTracked(id: ReturnType) { clearInterval(id); clearTimeout(id); cleanupTimers.delete(id) } const fightCtx = { k, W, H, theme, trackedTimeout, trackedInterval, clearTracked, safeColor: (c: string) => safeColor(k, c) } const spawnSparks = (x: number, y: number, count: number, color: string) => _spawnSparks(fightCtx, x, y, count, color) // --- Load sprites --- const colorsA = getBotColors(player1.seed) const colorsB = getBotColors(player2.seed) const sheetA = generateSpriteSheet(player1.seed, player1.tier, colorsA.primary, colorsA.secondary, player1.archetype, player1.customization) const sheetB = generateSpriteSheet(player2.seed, player2.tier, colorsB.primary, colorsB.secondary, player2.archetype, player2.customization) await Promise.all([ k.loadSprite('p1', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }), k.loadSprite('p2', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }), ]) // --- Callbacks --- const callbacks: Partial = {} // --- Match state --- let p1Wins = 0 let p2Wins = 0 let currentRound = 0 let roundTimer = roundTime let roundTimerHandle: ReturnType | null = null let roundActive = false let paused = false let matchOver = false // --- Input buffers for combo detection --- const p1InputBuffer: InputEvent[] = [] const p2InputBuffer: InputEvent[] = [] // --- Create fighters --- function createFighter(spriteName: string, startX: number, player: 1 | 2, name: string): FighterInstance { const obj = k.add([ k.sprite(spriteName, { anim: 'idle' }), k.pos(startX, GROUND_Y), k.anchor('bot'), k.scale(player === 1 ? FIGHTER_SCALE : -FIGHTER_SCALE, FIGHTER_SCALE), k.z(10), k.opacity(1), k.color(safeColor(k, '#ffffff')), k.rotate(0), ]) return { obj, physics: { vx: 0, vy: 0, grounded: true, facingRight: player === 1 }, combat: { hp: MAX_HP, maxHp: MAX_HP, state: 'idle', stateTimer: 0, stunTimer: 0, blockTimer: 0, comboCount: 0, comboDamage: 0, attackFrame: 0, currentMove: null, hasHitThisAttack: false, }, player, name, input: { up: false, down: false, left: false, right: false, punch: false, kick: false }, } } let fighter1: FighterInstance let fighter2: FighterInstance // Scene readiness — k.go() defers the scene callback to the next frame end, // so we need to wait for fighters to be created before start() can run. let sceneReady: () => void const sceneReadyPromise = new Promise(resolve => { sceneReady = resolve }) // ═══════════════════════════════════════════════════════════════ // Scene Setup // ═══════════════════════════════════════════════════════════════ k.scene('arcade', () => { // Draw arena background drawArenaDecor({ k, W, H, GROUND_Y, arena, theme, safeColor, }) // Ground line k.add([ k.rect(W, 2), k.pos(0, GROUND_Y), k.color(safeColor(k, theme.ground)), k.z(5), k.opacity(0.5), ]) // Create fighters fighter1 = createFighter('p1', P1_START_X, 1, player1.name) fighter2 = createFighter('p2', P2_START_X, 2, player2.name) // Signal that the scene is ready (fighters exist) sceneReady() // ═══════════════════════════════════════════════════════════ // Main Game Loop // ═══════════════════════════════════════════════════════════ k.onUpdate(() => { if (paused || !roundActive || matchOver) return const dt = k.dt() const fighters: [FighterInstance, FighterInstance] = [fighter1, fighter2] for (const fighter of fighters) { const inputBuffer = fighter.player === 1 ? p1InputBuffer : p2InputBuffer // State machine update (may trigger combo) const comboMove = updateStateMachine(fighter, inputBuffer) if (comboMove) { startComboMove(fighter, comboMove) // Fireball spawns a projectile instead of using a hitbox if (comboMove === 'fireball') { spawnFireball(k, fighter, (c: string) => safeColor(k, c)) sfxSpecial() } } // Movement from input applyMovement(fighter, dt) // Physics (gravity, velocity, bounds) updatePhysics(fighter, GROUND_Y, dt) } // Push-box (prevent overlap) enforcePushBox(fighter1, fighter2) // Facing (always face opponent) updateFacing(fighter1, fighter2) // --- Hit detection --- for (const [attacker, defender] of [[fighter1, fighter2], [fighter2, fighter1]] as const) { const result = checkHit(attacker, defender) if (result) { processHit(attacker, defender, result) } } // --- Projectile updates --- const projHits = updateProjectiles(k, fighters, dt) for (const { target, result } of projHits) { const attacker = target.player === 1 ? fighter2 : fighter1 processHit(attacker, target, result) } // --- Update animations --- updateAnimation(fighter1) updateAnimation(fighter2) // --- HP callback --- callbacks.onHpChange?.(fighter1.combat.hp, fighter2.combat.hp) // --- Check KO --- if (fighter1.combat.hp <= 0 || fighter2.combat.hp <= 0) { endRound() } }) }) // ═══════════════════════════════════════════════════════════════ // Hit Processing // ═══════════════════════════════════════════════════════════════ function processHit(attacker: FighterInstance, defender: FighterInstance, result: HitResult): void { applyHit(attacker, defender, result) if (result.type === 'hit') { // Visual and audio feedback const isSpecial = attacker.combat.currentMove && MOVES[attacker.combat.currentMove]?.animation === 'special' const sparkCount = isSpecial ? SPARK_COUNT_SPECIAL : (result.damage >= 70 ? SPARK_COUNT_HEAVY : SPARK_COUNT_LIGHT) const shakeIntensity = isSpecial ? HIT_SHAKE_SPECIAL : (result.damage >= 70 ? HIT_SHAKE_HEAVY : HIT_SHAKE_LIGHT) spawnSparks(defender.obj.pos.x, defender.obj.pos.y - 40, sparkCount, theme.accent) k.shake(shakeIntensity) // SFX if (isSpecial) { sfxSpecial() } else if (result.damage >= 70) { sfxKick() } else { sfxPunch() } // Hit flash const origOpacity = defender.obj.opacity defender.obj.opacity = 0.4 trackedTimeout(() => { if (defender.obj.exists()) defender.obj.opacity = origOpacity }, HIT_FLASH_DURATION * 1000) // Enter hitstun enterHitstun(defender, result.hitstun, result.knockbackX, result.knockbackY) // Combo notification if (attacker.combat.comboCount >= 2) { callbacks.onCombo?.(attacker.player, attacker.combat.comboCount, attacker.combat.currentMove || 'combo') } // Critical hit effect for big damage if (result.damage >= 90) { sfxCritical() } } else { // Blocked sfxBlock() enterBlockstun(defender, result.blockstun, result.knockbackX) spawnSparks(defender.obj.pos.x, defender.obj.pos.y - 40, 3, '#8888ff') } // Reset combo if defender was in idle/walking (new combo chain starting) if (result.type === 'hit' && attacker.combat.comboCount === 1) { resetCombo(attacker) attacker.combat.comboCount = 1 } } // ═══════════════════════════════════════════════════════════════ // Animation // ═══════════════════════════════════════════════════════════════ function updateAnimation(fighter: FighterInstance): void { const { combat, obj } = fighter const animMap: Partial> = { idle: 'idle', walking: 'idle', // no walk row — idle with movement looks fine at 48px jumping: 'idle', // static pose in air crouching: 'idle', // handled via scale squish below attacking: 'attack', kicking: 'kick', special: 'special', hit: 'hit', knockback: 'knockback', blocking: 'idle', // shield VFX handled separately ko: 'ko', win: 'win', } const targetAnim = animMap[combat.state] || 'idle' const currentAnim = obj.curAnim?.() // Only change animation if different if (currentAnim !== targetAnim) { obj.play(targetAnim) } // Crouch squish effect const baseScaleY = FIGHTER_SCALE if (combat.state === 'crouching' || combat.state === 'blocking') { obj.scale.y = baseScaleY * 0.7 } else { obj.scale.y = baseScaleY } } // ═══════════════════════════════════════════════════════════════ // Round Management // ═══════════════════════════════════════════════════════════════ function startRound(): void { currentRound++ roundActive = false roundTimer = roundTime // Reset fighters to starting positions resetFighter(fighter1, P1_START_X, true) resetFighter(fighter2, P2_START_X, false) // Clear projectiles clearAllProjectiles(k) // Clear combo buffers p1InputBuffer.length = 0 p2InputBuffer.length = 0 // Countdown then start callbacks.onTimerTick?.(roundTimer) callbacks.onHpChange?.(fighter1.combat.hp, fighter2.combat.hp) trackedTimeout(() => { roundActive = true startMusic() // Round timer roundTimerHandle = trackedInterval(() => { if (paused || !roundActive) return roundTimer-- callbacks.onTimerTick?.(roundTimer) if (roundTimer <= 0) { endRound() } }, 1000) }, ROUND_START_DELAY * 1000) } function endRound(): void { if (!roundActive) return roundActive = false if (roundTimerHandle !== null) { clearTracked(roundTimerHandle) roundTimerHandle = null } // Determine round winner let roundWinner: 1 | 2 | 0 if (fighter1.combat.hp <= 0 && fighter2.combat.hp <= 0) { roundWinner = 0 // draw } else if (fighter1.combat.hp <= 0) { roundWinner = 2 } else if (fighter2.combat.hp <= 0) { roundWinner = 1 } else { // Timer ran out — higher HP wins roundWinner = fighter1.combat.hp >= fighter2.combat.hp ? 1 : 2 } // KO animation if (roundWinner === 1 || roundWinner === 2) { const loser = roundWinner === 1 ? fighter2 : fighter1 const winner = roundWinner === 1 ? fighter1 : fighter2 enterKO(loser) enterWin(winner) sfxExplosion() k.shake(HIT_SHAKE_SPECIAL) } if (roundWinner === 1) p1Wins++ else if (roundWinner === 2) p2Wins++ callbacks.onRoundEnd?.(roundWinner, p1Wins, p2Wins) // Check match end const winsNeeded = Math.ceil(rounds / 2) if (p1Wins >= winsNeeded || p2Wins >= winsNeeded) { matchOver = true stopMusic() const matchWinner = p1Wins >= winsNeeded ? 1 : 2 trackedTimeout(() => { callbacks.onMatchEnd?.(matchWinner as 1 | 2) }, ROUND_END_DELAY * 1000) } else { // Next round after delay trackedTimeout(() => { startRound() }, ROUND_END_DELAY * 1000) } } function resetFighter(fighter: FighterInstance, startX: number, facingRight: boolean): void { fighter.obj.pos.x = startX fighter.obj.pos.y = GROUND_Y fighter.physics.vx = 0 fighter.physics.vy = 0 fighter.physics.grounded = true fighter.physics.facingRight = facingRight fighter.combat.hp = MAX_HP fighter.combat.state = 'idle' fighter.combat.stateTimer = 0 fighter.combat.stunTimer = 0 fighter.combat.blockTimer = 0 fighter.combat.comboCount = 0 fighter.combat.comboDamage = 0 fighter.combat.attackFrame = 0 fighter.combat.currentMove = null fighter.combat.hasHitThisAttack = false const baseScale = FIGHTER_SCALE fighter.obj.scale.x = facingRight ? baseScale : -baseScale fighter.obj.scale.y = baseScale fighter.obj.opacity = 1 } // ═══════════════════════════════════════════════════════════════ // Input Buffer Management // ═══════════════════════════════════════════════════════════════ function pushInput(player: 1 | 2, input: PlayerInput, prevInput: PlayerInput): void { const buffer = player === 1 ? p1InputBuffer : p2InputBuffer const now = performance.now() // Detect new directional presses (edge-triggered) if (input.up && !prevInput.up) buffer.push({ direction: 'up', button: null, time: now }) if (input.down && !prevInput.down) buffer.push({ direction: 'down', button: null, time: now }) if (input.left && !prevInput.left) buffer.push({ direction: 'left', button: null, time: now }) if (input.right && !prevInput.right) buffer.push({ direction: 'right', button: null, time: now }) // Detect new button presses if (input.punch && !prevInput.punch) buffer.push({ direction: null, button: 'A', time: now }) if (input.kick && !prevInput.kick) buffer.push({ direction: null, button: 'B', time: now }) // Trim buffer while (buffer.length > COMBO_BUFFER_SIZE) buffer.shift() } // ═══════════════════════════════════════════════════════════════ // Controller Interface // ═══════════════════════════════════════════════════════════════ // Store previous inputs for edge detection let prevP1: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false } let prevP2: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false } // Start the scene k.go('arcade') return { async start() { await sceneReadyPromise matchOver = false p1Wins = 0 p2Wins = 0 currentRound = 0 startRound() }, pause() { paused = true }, resume() { paused = false }, destroy() { paused = true roundActive = false stopMusic() for (const id of cleanupTimers) { clearTimeout(id) clearInterval(id) } cleanupTimers.clear() clearAllProjectiles(k) k.quit() }, setInput(player: 1 | 2, input: PlayerInput) { const fighter = player === 1 ? fighter1 : fighter2 if (!fighter) return const prev = player === 1 ? prevP1 : prevP2 pushInput(player, input, prev) // Update live input state on the fighter fighter.input.up = input.up fighter.input.down = input.down fighter.input.left = input.left fighter.input.right = input.right fighter.input.punch = input.punch fighter.input.kick = input.kick // Store for next frame edge detection if (player === 1) { prevP1 = { ...input } } else { prevP2 = { ...input } } }, on(event, cb) { (callbacks as any)[event] = cb }, getGameState() { if (!fighter1 || !fighter2) return null return { fighter1, fighter2, timer: roundTimer, round: currentRound, roundActive } }, } }