Files
botfights/frontend/src/game/ArcadeScene.ts
T
DorianandClaude Opus 4.6 fb35075b01
CI / check (push) Failing after 7m34s
fix: arcade mode crash — kaplay go() defers scene init to next frame
k.go('arcade') schedules the scene callback on frameEnd, not synchronously.
start() was called before fighters existed, causing "Cannot read properties
of undefined (reading 'obj')". Fix: await a readiness promise that resolves
once the scene callback has created the fighters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 20:17:10 +01:00

563 lines
20 KiB
TypeScript

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<typeof kaplay>
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<ArcadeSceneController> {
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<ReturnType<typeof setTimeout>>()
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<typeof setTimeout>) {
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<ArcadeCallbacks> = {}
// --- Match state ---
let p1Wins = 0
let p2Wins = 0
let currentRound = 0
let roundTimer = roundTime
let roundTimerHandle: ReturnType<typeof setInterval> | 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<void>(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<Record<FighterState, string>> = {
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 }
},
}
}