CI / check (push) Failing after 7m34s
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>
563 lines
20 KiB
TypeScript
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 }
|
|
},
|
|
}
|
|
}
|