stuff
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
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 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)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 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 {
|
||||
start() {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { PlayerInput, FighterInstance } from './types'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Arcade Bot Bridge — communicates with server to get bot actions
|
||||
// and translates them into frame-level PlayerInput
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** High-level actions a bot can respond with */
|
||||
export type BotAction =
|
||||
| 'idle'
|
||||
| 'move_forward'
|
||||
| 'move_back'
|
||||
| 'jump'
|
||||
| 'crouch'
|
||||
| 'punch'
|
||||
| 'kick'
|
||||
| 'block'
|
||||
| 'jump_punch'
|
||||
| 'jump_kick'
|
||||
| 'fireball'
|
||||
| 'uppercut'
|
||||
| 'dash_punch'
|
||||
| 'spinning_kick'
|
||||
| 'super_jump_kick'
|
||||
|
||||
/** Snapshot of game state sent to the bot */
|
||||
export interface ArcadeGameState {
|
||||
self: { hp: number; x: number; state: string; grounded: boolean }
|
||||
opponent: { hp: number; x: number; state: string; grounded: boolean }
|
||||
distance: number
|
||||
timer: number
|
||||
round: number
|
||||
maxRounds: number
|
||||
facingRight: boolean
|
||||
}
|
||||
|
||||
interface ActionStep {
|
||||
input: Partial<PlayerInput>
|
||||
frames: number
|
||||
/** If true, direction keys are relative (forward/back resolved at execution time) */
|
||||
relative?: boolean
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Action → frame-level input mapping
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function forwardKey(facingRight: boolean): 'right' | 'left' {
|
||||
return facingRight ? 'right' : 'left'
|
||||
}
|
||||
|
||||
function backKey(facingRight: boolean): 'right' | 'left' {
|
||||
return facingRight ? 'left' : 'right'
|
||||
}
|
||||
|
||||
/** Maps an action name to a sequence of frame-level input steps */
|
||||
function actionToSteps(action: BotAction): ActionStep[] {
|
||||
switch (action) {
|
||||
case 'idle':
|
||||
return [{ input: {}, frames: 15 }]
|
||||
case 'move_forward':
|
||||
return [{ input: { _forward: true } as any, frames: 18, relative: true }]
|
||||
case 'move_back':
|
||||
return [{ input: { _back: true } as any, frames: 15, relative: true }]
|
||||
case 'jump':
|
||||
return [
|
||||
{ input: { up: true }, frames: 3 },
|
||||
{ input: {}, frames: 25 },
|
||||
]
|
||||
case 'crouch':
|
||||
return [{ input: { down: true }, frames: 18 }]
|
||||
case 'punch':
|
||||
return [
|
||||
{ input: { punch: true }, frames: 2 },
|
||||
{ input: {}, frames: 12 },
|
||||
]
|
||||
case 'kick':
|
||||
return [
|
||||
{ input: { kick: true }, frames: 2 },
|
||||
{ input: {}, frames: 16 },
|
||||
]
|
||||
case 'block':
|
||||
return [{ input: { _back: true } as any, frames: 25, relative: true }]
|
||||
case 'jump_punch':
|
||||
return [
|
||||
{ input: { up: true }, frames: 3 },
|
||||
{ input: {}, frames: 8 },
|
||||
{ input: { punch: true }, frames: 2 },
|
||||
{ input: {}, frames: 15 },
|
||||
]
|
||||
case 'jump_kick':
|
||||
return [
|
||||
{ input: { up: true }, frames: 3 },
|
||||
{ input: {}, frames: 8 },
|
||||
{ input: { kick: true }, frames: 2 },
|
||||
{ input: {}, frames: 15 },
|
||||
]
|
||||
// Combo sequences — produce frame-level inputs that match combo detection
|
||||
case 'fireball':
|
||||
return [
|
||||
{ input: { down: true }, frames: 3 },
|
||||
{ input: { _forward: true } as any, frames: 3, relative: true },
|
||||
{ input: { _forward: true, punch: true } as any, frames: 2, relative: true },
|
||||
{ input: {}, frames: 22 },
|
||||
]
|
||||
case 'uppercut':
|
||||
return [
|
||||
{ input: { down: true }, frames: 3 },
|
||||
{ input: { _forward: true } as any, frames: 3, relative: true },
|
||||
{ input: { _forward: true, kick: true } as any, frames: 2, relative: true },
|
||||
{ input: {}, frames: 22 },
|
||||
]
|
||||
case 'dash_punch':
|
||||
return [
|
||||
{ input: { _back: true } as any, frames: 3, relative: true },
|
||||
{ input: {}, frames: 2 },
|
||||
{ input: { _back: true } as any, frames: 3, relative: true },
|
||||
{ input: { _back: true, punch: true } as any, frames: 2, relative: true },
|
||||
{ input: {}, frames: 18 },
|
||||
]
|
||||
case 'spinning_kick':
|
||||
return [
|
||||
{ input: { _back: true } as any, frames: 3, relative: true },
|
||||
{ input: {}, frames: 2 },
|
||||
{ input: { _back: true } as any, frames: 3, relative: true },
|
||||
{ input: { _back: true, kick: true } as any, frames: 2, relative: true },
|
||||
{ input: {}, frames: 20 },
|
||||
]
|
||||
case 'super_jump_kick':
|
||||
return [
|
||||
{ input: { down: true }, frames: 3 },
|
||||
{ input: { up: true }, frames: 3 },
|
||||
{ input: { up: true, kick: true }, frames: 2 },
|
||||
{ input: {}, frames: 24 },
|
||||
]
|
||||
default:
|
||||
return [{ input: {}, frames: 10 }]
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve relative direction markers into actual left/right keys */
|
||||
function resolveStep(step: ActionStep, facingRight: boolean): { input: PlayerInput; frames: number } {
|
||||
const base: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
|
||||
const raw = step.input as any
|
||||
|
||||
if (step.relative) {
|
||||
if (raw._forward) base[forwardKey(facingRight)] = true
|
||||
if (raw._back) base[backKey(facingRight)] = true
|
||||
}
|
||||
|
||||
if (raw.up) base.up = true
|
||||
if (raw.down) base.down = true
|
||||
if (raw.left) base.left = true
|
||||
if (raw.right) base.right = true
|
||||
if (raw.punch) base.punch = true
|
||||
if (raw.kick) base.kick = true
|
||||
|
||||
return { input: base, frames: step.frames }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Action Queue Executor
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
interface QueueEntry {
|
||||
input: PlayerInput
|
||||
framesLeft: number
|
||||
}
|
||||
|
||||
export interface BotBridge {
|
||||
/** Call once per frame to get the current PlayerInput for the bot */
|
||||
getInput(facingRight: boolean): PlayerInput
|
||||
/** Feed new actions from the server */
|
||||
enqueueActions(actions: BotAction[]): void
|
||||
/** Send game state to server and get new actions */
|
||||
requestActions(state: ArcadeGameState): void
|
||||
/** Stop all polling */
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
const EMPTY_INPUT: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
|
||||
|
||||
export function createBotBridge(botId: string): BotBridge {
|
||||
const queue: QueueEntry[] = []
|
||||
const pendingActions: ActionStep[][] = []
|
||||
let fetching = false
|
||||
let destroyed = false
|
||||
|
||||
function enqueueActions(actions: BotAction[]): void {
|
||||
for (const action of actions) {
|
||||
const steps = actionToSteps(action)
|
||||
pendingActions.push(steps)
|
||||
}
|
||||
}
|
||||
|
||||
function expandNextAction(facingRight: boolean): void {
|
||||
if (pendingActions.length === 0) return
|
||||
const steps = pendingActions.shift()!
|
||||
for (const step of steps) {
|
||||
const resolved = resolveStep(step, facingRight)
|
||||
queue.push({ input: resolved.input, framesLeft: resolved.frames })
|
||||
}
|
||||
}
|
||||
|
||||
function getInput(facingRight: boolean): PlayerInput {
|
||||
// Expand pending actions into resolved queue entries as needed
|
||||
if (queue.length === 0 && pendingActions.length > 0) {
|
||||
expandNextAction(facingRight)
|
||||
}
|
||||
|
||||
if (queue.length === 0) return { ...EMPTY_INPUT }
|
||||
|
||||
const current = queue[0]
|
||||
current.framesLeft--
|
||||
const input = { ...current.input }
|
||||
|
||||
if (current.framesLeft <= 0) {
|
||||
queue.shift()
|
||||
// Pre-expand next action
|
||||
if (queue.length === 0 && pendingActions.length > 0) {
|
||||
expandNextAction(facingRight)
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
async function requestActions(state: ArcadeGameState): Promise<void> {
|
||||
if (fetching || destroyed) return
|
||||
fetching = true
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/arcade/bot-action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ botId, gameState: state }),
|
||||
})
|
||||
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json() as { actions?: string[] }
|
||||
if (data.actions && Array.isArray(data.actions)) {
|
||||
const validActions = data.actions
|
||||
.map(a => a.trim().toLowerCase())
|
||||
.filter(isValidAction) as BotAction[]
|
||||
if (validActions.length > 0) {
|
||||
enqueueActions(validActions)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error — bot will idle until next poll
|
||||
} finally {
|
||||
fetching = false
|
||||
}
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
destroyed = true
|
||||
queue.length = 0
|
||||
pendingActions.length = 0
|
||||
}
|
||||
|
||||
return { getInput, enqueueActions, requestActions, destroy }
|
||||
}
|
||||
|
||||
function isValidAction(s: string): s is BotAction {
|
||||
return [
|
||||
'idle', 'move_forward', 'move_back', 'jump', 'crouch',
|
||||
'punch', 'kick', 'block', 'jump_punch', 'jump_kick',
|
||||
'fireball', 'uppercut', 'dash_punch', 'spinning_kick', 'super_jump_kick',
|
||||
].includes(s)
|
||||
}
|
||||
|
||||
/** Build ArcadeGameState from two fighter instances and match info */
|
||||
export function buildGameState(
|
||||
self: FighterInstance,
|
||||
opponent: FighterInstance,
|
||||
timer: number,
|
||||
round: number,
|
||||
maxRounds: number,
|
||||
): ArcadeGameState {
|
||||
return {
|
||||
self: {
|
||||
hp: self.combat.hp,
|
||||
x: Math.round(self.obj.pos.x),
|
||||
state: self.combat.state,
|
||||
grounded: self.physics.grounded,
|
||||
},
|
||||
opponent: {
|
||||
hp: opponent.combat.hp,
|
||||
x: Math.round(opponent.obj.pos.x),
|
||||
state: opponent.combat.state,
|
||||
grounded: opponent.physics.grounded,
|
||||
},
|
||||
distance: Math.round(Math.abs(self.obj.pos.x - opponent.obj.pos.x)),
|
||||
timer,
|
||||
round,
|
||||
maxRounds,
|
||||
facingRight: self.physics.facingRight,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FighterInstance, Hitbox } from './types'
|
||||
import {
|
||||
HURTBOX_WIDTH, HURTBOX_HEIGHT, CROUCH_HURTBOX_HEIGHT,
|
||||
CHIP_DAMAGE_RATIO, COMBO_DAMAGE_SCALING,
|
||||
} from './constants'
|
||||
import { MOVES } from './moves'
|
||||
|
||||
export interface HitResult {
|
||||
type: 'hit' | 'blocked'
|
||||
damage: number
|
||||
hitstun: number
|
||||
blockstun: number
|
||||
knockbackX: number
|
||||
knockbackY: number
|
||||
hitbox: Hitbox
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all active hitboxes of the attacker's current move against the defender.
|
||||
* Returns HitResult if any hitbox connects, null otherwise.
|
||||
*/
|
||||
export function checkHit(attacker: FighterInstance, defender: FighterInstance): HitResult | null {
|
||||
const { combat, physics, obj } = attacker
|
||||
if (!combat.currentMove || combat.hasHitThisAttack) return null
|
||||
|
||||
const move = MOVES[combat.currentMove]
|
||||
if (!move) return null
|
||||
|
||||
const frame = combat.attackFrame
|
||||
|
||||
for (const hitbox of move.hitboxes) {
|
||||
if (frame < hitbox.activeFrames[0] || frame > hitbox.activeFrames[1]) continue
|
||||
|
||||
const result = testHitbox(attacker, defender, hitbox)
|
||||
if (result) return result
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function testHitbox(
|
||||
attacker: FighterInstance,
|
||||
defender: FighterInstance,
|
||||
hitbox: Hitbox,
|
||||
): HitResult | null {
|
||||
const dir = attacker.physics.facingRight ? 1 : -1
|
||||
|
||||
// Hitbox world position
|
||||
const hx = attacker.obj.pos.x + hitbox.offsetX * dir
|
||||
const hy = attacker.obj.pos.y + hitbox.offsetY
|
||||
const hLeft = hx - hitbox.width / 2
|
||||
const hRight = hx + hitbox.width / 2
|
||||
const hTop = hy - hitbox.height / 2
|
||||
const hBottom = hy + hitbox.height / 2
|
||||
|
||||
// Defender hurtbox (centered on position, extends upward)
|
||||
const isCrouching = defender.combat.state === 'crouching' || defender.combat.state === 'blocking'
|
||||
const hurtH = isCrouching ? CROUCH_HURTBOX_HEIGHT : HURTBOX_HEIGHT
|
||||
const dLeft = defender.obj.pos.x - HURTBOX_WIDTH / 2
|
||||
const dRight = defender.obj.pos.x + HURTBOX_WIDTH / 2
|
||||
const dTop = defender.obj.pos.y - hurtH
|
||||
const dBottom = defender.obj.pos.y
|
||||
|
||||
// AABB overlap test
|
||||
if (hRight < dLeft || hLeft > dRight || hBottom < dTop || hTop > dBottom) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if defender is blocking
|
||||
const isBlocking = isDefenderBlocking(attacker, defender)
|
||||
|
||||
if (isBlocking) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
damage: Math.round(hitbox.damage * CHIP_DAMAGE_RATIO),
|
||||
hitstun: 0,
|
||||
blockstun: hitbox.blockstun,
|
||||
knockbackX: hitbox.knockbackX * 0.3,
|
||||
knockbackY: 0,
|
||||
hitbox,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply combo damage scaling
|
||||
const comboScale = Math.pow(COMBO_DAMAGE_SCALING, defender.combat.comboCount)
|
||||
const scaledDamage = Math.round(hitbox.damage * comboScale)
|
||||
|
||||
return {
|
||||
type: 'hit',
|
||||
damage: scaledDamage,
|
||||
hitstun: hitbox.hitstun,
|
||||
blockstun: 0,
|
||||
knockbackX: hitbox.knockbackX,
|
||||
knockbackY: hitbox.knockbackY,
|
||||
hitbox,
|
||||
}
|
||||
}
|
||||
|
||||
function isDefenderBlocking(attacker: FighterInstance, defender: FighterInstance): boolean {
|
||||
if (defender.combat.state !== 'blocking') return false
|
||||
if (!defender.physics.grounded) return false
|
||||
|
||||
// Must be holding direction away from attacker
|
||||
const holdingBack = defender.physics.facingRight
|
||||
? defender.input.left && !defender.input.right
|
||||
: defender.input.right && !defender.input.left
|
||||
|
||||
return holdingBack
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply hit result to the defender. Mutates defender state.
|
||||
*/
|
||||
export function applyHit(
|
||||
attacker: FighterInstance,
|
||||
defender: FighterInstance,
|
||||
result: HitResult,
|
||||
): void {
|
||||
// Deal damage
|
||||
defender.combat.hp = Math.max(0, defender.combat.hp - result.damage)
|
||||
|
||||
// Mark attacker's attack as having connected (prevent multi-hit per hitbox window)
|
||||
attacker.combat.hasHitThisAttack = true
|
||||
|
||||
if (result.type === 'hit') {
|
||||
// Increment combo counter
|
||||
attacker.combat.comboCount++
|
||||
attacker.combat.comboDamage += result.damage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset combo counter (called when the opponent recovers from hitstun).
|
||||
*/
|
||||
export function resetCombo(fighter: FighterInstance): void {
|
||||
fighter.combat.comboCount = 0
|
||||
fighter.combat.comboDamage = 0
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Arcade Mode — all tunable constants in one place
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// --- Physics ---
|
||||
export const GRAVITY = 1800 // pixels/sec²
|
||||
export const WALK_SPEED = 200 // pixels/sec
|
||||
export const JUMP_VELOCITY = -580 // pixels/sec (upward)
|
||||
export const CROUCH_SLOW = 0.3 // movement multiplier while crouching
|
||||
export const AIR_CONTROL = 0.6 // horizontal movement multiplier in air
|
||||
export const KNOCKBACK_FRICTION = 800 // deceleration when sliding from knockback
|
||||
|
||||
// --- Stage ---
|
||||
export const STAGE_LEFT = 30 // left boundary
|
||||
export const STAGE_RIGHT = 770 // right boundary (800 - 30)
|
||||
export const CANVAS_WIDTH = 800
|
||||
export const CANVAS_HEIGHT = 500
|
||||
|
||||
// --- Health ---
|
||||
export const MAX_HP = 1000
|
||||
|
||||
// --- Damage values ---
|
||||
export const PUNCH_DAMAGE = 50
|
||||
export const KICK_DAMAGE = 70
|
||||
export const CROUCH_PUNCH_DAMAGE = 40
|
||||
export const CROUCH_KICK_DAMAGE = 60
|
||||
export const AIR_PUNCH_DAMAGE = 55
|
||||
export const AIR_KICK_DAMAGE = 75
|
||||
export const FIREBALL_DAMAGE = 60
|
||||
export const UPPERCUT_DAMAGE = 100
|
||||
export const DASH_PUNCH_DAMAGE = 80
|
||||
export const SPINNING_KICK_DAMAGE = 90
|
||||
export const SUPER_JUMP_KICK_DAMAGE = 110
|
||||
export const CHIP_DAMAGE_RATIO = 0.15 // blocked specials deal 15% damage
|
||||
|
||||
// --- Frame data (at 60fps, 1 frame ≈ 16.7ms) ---
|
||||
export const PUNCH_STARTUP = 3
|
||||
export const PUNCH_ACTIVE = 3
|
||||
export const PUNCH_RECOVERY = 8
|
||||
export const KICK_STARTUP = 5
|
||||
export const KICK_ACTIVE = 4
|
||||
export const KICK_RECOVERY = 12
|
||||
export const SPECIAL_STARTUP = 8
|
||||
export const SPECIAL_ACTIVE = 5
|
||||
export const SPECIAL_RECOVERY = 15
|
||||
|
||||
// --- Stun frames ---
|
||||
export const HITSTUN_LIGHT = 12
|
||||
export const HITSTUN_HEAVY = 18
|
||||
export const HITSTUN_SPECIAL = 22
|
||||
export const BLOCKSTUN_LIGHT = 6
|
||||
export const BLOCKSTUN_HEAVY = 10
|
||||
export const BLOCKSTUN_SPECIAL = 14
|
||||
|
||||
// --- Knockback ---
|
||||
export const PUNCH_KNOCKBACK_X = 80
|
||||
export const KICK_KNOCKBACK_X = 120
|
||||
export const UPPERCUT_KNOCKBACK_Y = -400
|
||||
export const UPPERCUT_KNOCKBACK_X = 60
|
||||
export const DASH_PUNCH_KNOCKBACK_X = 200
|
||||
export const SPINNING_KICK_KNOCKBACK_X = 150
|
||||
export const SUPER_JUMP_KICK_KNOCKBACK_Y = -300
|
||||
|
||||
// --- Combo system ---
|
||||
export const COMBO_INPUT_WINDOW = 300 // ms to complete a combo sequence
|
||||
export const COMBO_BUFFER_SIZE = 10 // circular buffer capacity
|
||||
export const COMBO_DAMAGE_SCALING = 0.85 // each subsequent hit deals 85% of previous
|
||||
|
||||
// --- Hurtbox (defender) ---
|
||||
export const HURTBOX_WIDTH = 50
|
||||
export const HURTBOX_HEIGHT = 90
|
||||
export const CROUCH_HURTBOX_HEIGHT = 55
|
||||
|
||||
// --- Projectile ---
|
||||
export const FIREBALL_SPEED = 400 // pixels/sec
|
||||
export const FIREBALL_WIDTH = 16
|
||||
export const FIREBALL_HEIGHT = 12
|
||||
export const MAX_PROJECTILES = 2 // per player on screen
|
||||
|
||||
// --- Round ---
|
||||
export const ROUND_START_DELAY = 1.5 // seconds before "FIGHT!"
|
||||
export const ROUND_END_DELAY = 2.0 // seconds after KO before next round
|
||||
export const KO_SLOWMO_DURATION = 0.5 // seconds of slow-motion on KO hit
|
||||
|
||||
// --- Fighter positioning ---
|
||||
export const P1_START_X = 250 // player 1 starting X
|
||||
export const P2_START_X = 550 // player 2 starting X
|
||||
export const MIN_DISTANCE = 40 // minimum distance between fighters (push-box)
|
||||
|
||||
// --- Visual ---
|
||||
export const FIGHTER_SCALE = 2.2 // sprite scale for arcade mode (slightly larger for TV/4K)
|
||||
export const HIT_SHAKE_LIGHT = 4
|
||||
export const HIT_SHAKE_HEAVY = 8
|
||||
export const HIT_SHAKE_SPECIAL = 14
|
||||
export const HIT_FLASH_DURATION = 0.08 // seconds
|
||||
export const SPARK_COUNT_LIGHT = 5
|
||||
export const SPARK_COUNT_HEAVY = 10
|
||||
export const SPARK_COUNT_SPECIAL = 16
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './types'
|
||||
export * from './constants'
|
||||
export { updatePhysics, applyMovement, enforcePushBox, updateFacing } from './physics'
|
||||
export { updateStateMachine, startComboMove, enterHitstun, enterBlockstun, enterKO, enterWin } from './state-machine'
|
||||
export { checkHit, applyHit, resetCombo } from './combat'
|
||||
export type { HitResult } from './combat'
|
||||
export { MOVES, COMBOS, matchCombo } from './moves'
|
||||
export { spawnFireball, updateProjectiles, clearAllProjectiles } from './projectiles'
|
||||
export { createBotBridge, buildGameState } from './bot-bridge'
|
||||
export type { BotBridge, BotAction, ArcadeGameState } from './bot-bridge'
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { MoveDefinition, ComboDefinition, InputEvent } from './types'
|
||||
import {
|
||||
PUNCH_DAMAGE, KICK_DAMAGE, CROUCH_PUNCH_DAMAGE, CROUCH_KICK_DAMAGE,
|
||||
AIR_PUNCH_DAMAGE, AIR_KICK_DAMAGE, FIREBALL_DAMAGE, UPPERCUT_DAMAGE,
|
||||
DASH_PUNCH_DAMAGE, SPINNING_KICK_DAMAGE, SUPER_JUMP_KICK_DAMAGE,
|
||||
PUNCH_STARTUP, PUNCH_ACTIVE, PUNCH_RECOVERY,
|
||||
KICK_STARTUP, KICK_ACTIVE, KICK_RECOVERY,
|
||||
SPECIAL_STARTUP, SPECIAL_ACTIVE, SPECIAL_RECOVERY,
|
||||
HITSTUN_LIGHT, HITSTUN_HEAVY, HITSTUN_SPECIAL,
|
||||
BLOCKSTUN_LIGHT, BLOCKSTUN_HEAVY, BLOCKSTUN_SPECIAL,
|
||||
PUNCH_KNOCKBACK_X, KICK_KNOCKBACK_X,
|
||||
UPPERCUT_KNOCKBACK_X, UPPERCUT_KNOCKBACK_Y,
|
||||
DASH_PUNCH_KNOCKBACK_X, SPINNING_KICK_KNOCKBACK_X,
|
||||
SUPER_JUMP_KICK_KNOCKBACK_Y,
|
||||
COMBO_INPUT_WINDOW,
|
||||
} from './constants'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Move Definitions
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export const MOVES: Record<string, MoveDefinition> = {
|
||||
// --- Standing normals ---
|
||||
punch: {
|
||||
name: 'punch',
|
||||
animation: 'attack',
|
||||
totalFrames: PUNCH_STARTUP + PUNCH_ACTIVE + PUNCH_RECOVERY,
|
||||
hitboxes: [{
|
||||
offsetX: 35, offsetY: -45, width: 28, height: 22,
|
||||
damage: PUNCH_DAMAGE,
|
||||
hitstun: HITSTUN_LIGHT, blockstun: BLOCKSTUN_LIGHT,
|
||||
knockbackX: PUNCH_KNOCKBACK_X, knockbackY: 0,
|
||||
activeFrames: [PUNCH_STARTUP, PUNCH_STARTUP + PUNCH_ACTIVE - 1],
|
||||
}],
|
||||
recovery: PUNCH_RECOVERY,
|
||||
canCancel: true,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
kick: {
|
||||
name: 'kick',
|
||||
animation: 'kick',
|
||||
totalFrames: KICK_STARTUP + KICK_ACTIVE + KICK_RECOVERY,
|
||||
hitboxes: [{
|
||||
offsetX: 38, offsetY: -35, width: 32, height: 24,
|
||||
damage: KICK_DAMAGE,
|
||||
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
|
||||
knockbackX: KICK_KNOCKBACK_X, knockbackY: 0,
|
||||
activeFrames: [KICK_STARTUP, KICK_STARTUP + KICK_ACTIVE - 1],
|
||||
}],
|
||||
recovery: KICK_RECOVERY,
|
||||
canCancel: false,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
// --- Crouch normals ---
|
||||
crouchPunch: {
|
||||
name: 'crouchPunch',
|
||||
animation: 'attack',
|
||||
totalFrames: PUNCH_STARTUP + PUNCH_ACTIVE + PUNCH_RECOVERY + 2,
|
||||
hitboxes: [{
|
||||
offsetX: 30, offsetY: -20, width: 26, height: 18,
|
||||
damage: CROUCH_PUNCH_DAMAGE,
|
||||
hitstun: HITSTUN_LIGHT, blockstun: BLOCKSTUN_LIGHT,
|
||||
knockbackX: PUNCH_KNOCKBACK_X * 0.7, knockbackY: 0,
|
||||
activeFrames: [PUNCH_STARTUP, PUNCH_STARTUP + PUNCH_ACTIVE - 1],
|
||||
}],
|
||||
recovery: PUNCH_RECOVERY + 2,
|
||||
canCancel: true,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
crouchKick: {
|
||||
name: 'crouchKick',
|
||||
animation: 'kick',
|
||||
totalFrames: KICK_STARTUP + KICK_ACTIVE + KICK_RECOVERY + 2,
|
||||
hitboxes: [{
|
||||
offsetX: 35, offsetY: -12, width: 36, height: 16,
|
||||
damage: CROUCH_KICK_DAMAGE,
|
||||
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
|
||||
knockbackX: KICK_KNOCKBACK_X * 0.6, knockbackY: 0,
|
||||
activeFrames: [KICK_STARTUP, KICK_STARTUP + KICK_ACTIVE - 1],
|
||||
}],
|
||||
recovery: KICK_RECOVERY + 2,
|
||||
canCancel: false,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
// --- Aerial normals ---
|
||||
airPunch: {
|
||||
name: 'airPunch',
|
||||
animation: 'attack',
|
||||
totalFrames: PUNCH_STARTUP + PUNCH_ACTIVE + 6,
|
||||
hitboxes: [{
|
||||
offsetX: 30, offsetY: -50, width: 26, height: 24,
|
||||
damage: AIR_PUNCH_DAMAGE,
|
||||
hitstun: HITSTUN_LIGHT + 2, blockstun: BLOCKSTUN_LIGHT + 2,
|
||||
knockbackX: PUNCH_KNOCKBACK_X, knockbackY: 0,
|
||||
activeFrames: [PUNCH_STARTUP, PUNCH_STARTUP + PUNCH_ACTIVE - 1],
|
||||
}],
|
||||
recovery: 6,
|
||||
canCancel: false,
|
||||
isAerial: true,
|
||||
},
|
||||
|
||||
airKick: {
|
||||
name: 'airKick',
|
||||
animation: 'kick',
|
||||
totalFrames: KICK_STARTUP + KICK_ACTIVE + 8,
|
||||
hitboxes: [{
|
||||
offsetX: 34, offsetY: -40, width: 34, height: 26,
|
||||
damage: AIR_KICK_DAMAGE,
|
||||
hitstun: HITSTUN_HEAVY + 2, blockstun: BLOCKSTUN_HEAVY + 2,
|
||||
knockbackX: KICK_KNOCKBACK_X, knockbackY: -80,
|
||||
activeFrames: [KICK_STARTUP, KICK_STARTUP + KICK_ACTIVE - 1],
|
||||
}],
|
||||
recovery: 8,
|
||||
canCancel: false,
|
||||
isAerial: true,
|
||||
},
|
||||
|
||||
// --- Special moves (combo-activated) ---
|
||||
fireball: {
|
||||
name: 'fireball',
|
||||
animation: 'special',
|
||||
totalFrames: SPECIAL_STARTUP + SPECIAL_ACTIVE + SPECIAL_RECOVERY,
|
||||
hitboxes: [], // projectile handles its own hitbox
|
||||
recovery: SPECIAL_RECOVERY,
|
||||
canCancel: false,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
uppercut: {
|
||||
name: 'uppercut',
|
||||
animation: 'special',
|
||||
totalFrames: 6 + SPECIAL_ACTIVE + SPECIAL_RECOVERY + 4,
|
||||
hitboxes: [{
|
||||
offsetX: 20, offsetY: -60, width: 30, height: 50,
|
||||
damage: UPPERCUT_DAMAGE,
|
||||
hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL,
|
||||
knockbackX: UPPERCUT_KNOCKBACK_X, knockbackY: UPPERCUT_KNOCKBACK_Y,
|
||||
activeFrames: [6, 6 + SPECIAL_ACTIVE - 1],
|
||||
}],
|
||||
recovery: SPECIAL_RECOVERY + 4,
|
||||
canCancel: false,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
dashPunch: {
|
||||
name: 'dashPunch',
|
||||
animation: 'special',
|
||||
totalFrames: 4 + SPECIAL_ACTIVE + SPECIAL_RECOVERY + 2,
|
||||
hitboxes: [{
|
||||
offsetX: 45, offsetY: -40, width: 35, height: 25,
|
||||
damage: DASH_PUNCH_DAMAGE,
|
||||
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
|
||||
knockbackX: DASH_PUNCH_KNOCKBACK_X, knockbackY: 0,
|
||||
activeFrames: [4, 4 + SPECIAL_ACTIVE - 1],
|
||||
}],
|
||||
recovery: SPECIAL_RECOVERY + 2,
|
||||
canCancel: false,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
spinningKick: {
|
||||
name: 'spinningKick',
|
||||
animation: 'special',
|
||||
totalFrames: 6 + 8 + SPECIAL_RECOVERY + 3,
|
||||
hitboxes: [
|
||||
// Hit 1 (early)
|
||||
{
|
||||
offsetX: 30, offsetY: -40, width: 35, height: 30,
|
||||
damage: SPINNING_KICK_DAMAGE * 0.4,
|
||||
hitstun: HITSTUN_LIGHT + 4, blockstun: BLOCKSTUN_LIGHT + 4,
|
||||
knockbackX: SPINNING_KICK_KNOCKBACK_X * 0.3, knockbackY: 0,
|
||||
activeFrames: [6, 8],
|
||||
},
|
||||
// Hit 2 (late)
|
||||
{
|
||||
offsetX: 35, offsetY: -40, width: 35, height: 30,
|
||||
damage: SPINNING_KICK_DAMAGE * 0.6,
|
||||
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
|
||||
knockbackX: SPINNING_KICK_KNOCKBACK_X, knockbackY: -60,
|
||||
activeFrames: [10, 13],
|
||||
},
|
||||
],
|
||||
recovery: SPECIAL_RECOVERY + 3,
|
||||
canCancel: false,
|
||||
isAerial: false,
|
||||
},
|
||||
|
||||
superJumpKick: {
|
||||
name: 'superJumpKick',
|
||||
animation: 'special',
|
||||
totalFrames: 5 + SPECIAL_ACTIVE + SPECIAL_RECOVERY + 6,
|
||||
hitboxes: [{
|
||||
offsetX: 20, offsetY: -70, width: 30, height: 55,
|
||||
damage: SUPER_JUMP_KICK_DAMAGE,
|
||||
hitstun: HITSTUN_SPECIAL + 4, blockstun: BLOCKSTUN_SPECIAL + 4,
|
||||
knockbackX: 80, knockbackY: SUPER_JUMP_KICK_KNOCKBACK_Y,
|
||||
activeFrames: [5, 5 + SPECIAL_ACTIVE - 1],
|
||||
}],
|
||||
recovery: SPECIAL_RECOVERY + 6,
|
||||
canCancel: false,
|
||||
isAerial: false,
|
||||
},
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Combo Definitions — inputs use relative directions (forward/back)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export const COMBOS: ComboDefinition[] = [
|
||||
{ name: 'Fireball', inputs: ['down', 'forward', 'A'], window: COMBO_INPUT_WINDOW, move: 'fireball' },
|
||||
{ name: 'Uppercut', inputs: ['down', 'forward', 'B'], window: COMBO_INPUT_WINDOW, move: 'uppercut' },
|
||||
{ name: 'Dash Punch', inputs: ['back', 'back', 'A'], window: COMBO_INPUT_WINDOW + 100, move: 'dashPunch' },
|
||||
{ name: 'Spinning Kick', inputs: ['back', 'back', 'B'], window: COMBO_INPUT_WINDOW + 100, move: 'spinningKick' },
|
||||
{ name: 'Super Jump Kick', inputs: ['down', 'up', 'B'], window: COMBO_INPUT_WINDOW, move: 'superJumpKick' },
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Combo Input Matching
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Check if the input buffer matches any combo definition.
|
||||
* Returns the move name if matched, null otherwise.
|
||||
* Directions are relative: 'forward' = toward opponent, 'back' = away.
|
||||
*/
|
||||
export function matchCombo(buffer: InputEvent[], facingRight: boolean): string | null {
|
||||
if (buffer.length < 2) return null
|
||||
|
||||
const now = performance.now()
|
||||
|
||||
// Check each combo, longest input sequence first for priority
|
||||
for (const combo of COMBOS) {
|
||||
if (matchSingleCombo(buffer, combo, facingRight, now)) {
|
||||
return combo.move
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function matchSingleCombo(
|
||||
buffer: InputEvent[],
|
||||
combo: ComboDefinition,
|
||||
facingRight: boolean,
|
||||
now: number,
|
||||
): boolean {
|
||||
const inputs = combo.inputs
|
||||
let inputIdx = inputs.length - 1
|
||||
let bufIdx = buffer.length - 1
|
||||
|
||||
// The last input must be a button press that just happened
|
||||
const lastInput = inputs[inputIdx]
|
||||
const lastEvent = buffer[bufIdx]
|
||||
if (!lastEvent) return false
|
||||
if (now - lastEvent.time > 100) return false // must be very recent
|
||||
|
||||
if (lastInput === 'A' && lastEvent.button !== 'A') return false
|
||||
if (lastInput === 'B' && lastEvent.button !== 'B') return false
|
||||
|
||||
inputIdx--
|
||||
bufIdx--
|
||||
|
||||
// Walk backward through the buffer matching directional inputs
|
||||
const windowStart = now - combo.window
|
||||
|
||||
while (inputIdx >= 0 && bufIdx >= 0) {
|
||||
const event = buffer[bufIdx]
|
||||
if (event.time < windowStart) return false // too old
|
||||
|
||||
const required = resolveDirection(inputs[inputIdx], facingRight)
|
||||
|
||||
if (event.direction === required) {
|
||||
inputIdx--
|
||||
}
|
||||
bufIdx--
|
||||
}
|
||||
|
||||
return inputIdx < 0
|
||||
}
|
||||
|
||||
function resolveDirection(dir: string, facingRight: boolean): string {
|
||||
if (dir === 'forward') return facingRight ? 'right' : 'left'
|
||||
if (dir === 'back') return facingRight ? 'left' : 'right'
|
||||
return dir // 'up', 'down' are absolute
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
GRAVITY, WALK_SPEED, JUMP_VELOCITY, AIR_CONTROL, KNOCKBACK_FRICTION,
|
||||
STAGE_LEFT, STAGE_RIGHT, MIN_DISTANCE,
|
||||
} from './constants'
|
||||
import type { FighterInstance } from './types'
|
||||
|
||||
/**
|
||||
* Apply gravity, velocity, position, ground clamping, and stage bounds.
|
||||
* Pure function — no side effects beyond mutating the fighter's pos/physics.
|
||||
*/
|
||||
export function updatePhysics(fighter: FighterInstance, groundY: number, dt: number): void {
|
||||
const { physics, obj } = fighter
|
||||
|
||||
// Apply gravity when airborne
|
||||
if (!physics.grounded) {
|
||||
physics.vy += GRAVITY * dt
|
||||
}
|
||||
|
||||
// Apply velocity to position
|
||||
obj.pos.x += physics.vx * dt
|
||||
obj.pos.y += physics.vy * dt
|
||||
|
||||
// Ground collision
|
||||
if (obj.pos.y >= groundY) {
|
||||
obj.pos.y = groundY
|
||||
physics.vy = 0
|
||||
physics.grounded = true
|
||||
}
|
||||
|
||||
// Stage boundaries
|
||||
obj.pos.x = Math.max(STAGE_LEFT, Math.min(STAGE_RIGHT, obj.pos.x))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply movement from input. Called before updatePhysics in the game loop.
|
||||
*/
|
||||
export function applyMovement(fighter: FighterInstance, dt: number): void {
|
||||
const { physics, combat, input } = fighter
|
||||
const state = combat.state
|
||||
|
||||
// No movement during attack, hit, knockback, ko, or win states
|
||||
if (state === 'attacking' || state === 'kicking' || state === 'special' ||
|
||||
state === 'hit' || state === 'knockback' || state === 'ko' || state === 'win') {
|
||||
// Apply knockback friction when grounded and in knockback
|
||||
if (state === 'knockback' && physics.grounded && physics.vx !== 0) {
|
||||
const friction = KNOCKBACK_FRICTION * dt
|
||||
if (Math.abs(physics.vx) <= friction) {
|
||||
physics.vx = 0
|
||||
} else {
|
||||
physics.vx -= Math.sign(physics.vx) * friction
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Horizontal movement
|
||||
const speedMult = physics.grounded ? 1 : AIR_CONTROL
|
||||
if (state !== 'blocking') {
|
||||
if (input.left && !input.right) {
|
||||
physics.vx = -WALK_SPEED * speedMult
|
||||
} else if (input.right && !input.left) {
|
||||
physics.vx = WALK_SPEED * speedMult
|
||||
} else {
|
||||
// Decelerate to stop on ground, maintain air momentum
|
||||
if (physics.grounded) {
|
||||
physics.vx = 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Blocking: no horizontal movement, decelerate
|
||||
if (physics.grounded) physics.vx = 0
|
||||
}
|
||||
|
||||
// Jump
|
||||
if (input.up && physics.grounded && state !== 'crouching' && state !== 'blocking') {
|
||||
physics.vy = JUMP_VELOCITY
|
||||
physics.grounded = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce push-box: fighters can't overlap.
|
||||
* Call after updatePhysics for both fighters.
|
||||
*/
|
||||
export function enforcePushBox(f1: FighterInstance, f2: FighterInstance): void {
|
||||
const dist = Math.abs(f1.obj.pos.x - f2.obj.pos.x)
|
||||
if (dist < MIN_DISTANCE) {
|
||||
const overlap = (MIN_DISTANCE - dist) / 2
|
||||
if (f1.obj.pos.x < f2.obj.pos.x) {
|
||||
f1.obj.pos.x -= overlap
|
||||
f2.obj.pos.x += overlap
|
||||
} else {
|
||||
f1.obj.pos.x += overlap
|
||||
f2.obj.pos.x -= overlap
|
||||
}
|
||||
// Re-clamp to stage after push
|
||||
f1.obj.pos.x = Math.max(STAGE_LEFT, Math.min(STAGE_RIGHT, f1.obj.pos.x))
|
||||
f2.obj.pos.x = Math.max(STAGE_LEFT, Math.min(STAGE_RIGHT, f2.obj.pos.x))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update facing direction: fighters always face each other.
|
||||
*/
|
||||
export function updateFacing(f1: FighterInstance, f2: FighterInstance): void {
|
||||
f1.physics.facingRight = f1.obj.pos.x < f2.obj.pos.x
|
||||
f2.physics.facingRight = f2.obj.pos.x < f1.obj.pos.x
|
||||
|
||||
// Flip sprite via scale (negative X = face left)
|
||||
const baseScale = Math.abs(f1.obj.scale.x)
|
||||
f1.obj.scale.x = f1.physics.facingRight ? baseScale : -baseScale
|
||||
f2.obj.scale.x = f2.physics.facingRight ? baseScale : -baseScale
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { GameObj, PosComp, RectComp, AnchorComp, ColorComp, OpacityComp, ZComp } from 'kaplay'
|
||||
import type { KaplayInstance, FighterInstance } from './types'
|
||||
import {
|
||||
FIREBALL_SPEED, FIREBALL_WIDTH, FIREBALL_HEIGHT, FIREBALL_DAMAGE,
|
||||
HURTBOX_WIDTH, HURTBOX_HEIGHT, CROUCH_HURTBOX_HEIGHT,
|
||||
HITSTUN_SPECIAL, BLOCKSTUN_SPECIAL, CHIP_DAMAGE_RATIO,
|
||||
MAX_PROJECTILES, STAGE_LEFT, STAGE_RIGHT,
|
||||
} from './constants'
|
||||
import type { HitResult } from './combat'
|
||||
|
||||
type ProjectileObj = GameObj<PosComp | RectComp | AnchorComp | ColorComp | OpacityComp | ZComp>
|
||||
|
||||
interface Projectile {
|
||||
obj: ProjectileObj
|
||||
owner: 1 | 2
|
||||
speed: number
|
||||
damage: number
|
||||
alive: boolean
|
||||
}
|
||||
|
||||
const projectiles: Projectile[] = []
|
||||
|
||||
/**
|
||||
* Spawn a fireball projectile from the attacker's position.
|
||||
*/
|
||||
export function spawnFireball(
|
||||
k: KaplayInstance,
|
||||
fighter: FighterInstance,
|
||||
safeColor: (color: string) => ReturnType<KaplayInstance['Color']['fromHex']>,
|
||||
): void {
|
||||
// Count existing projectiles for this player
|
||||
const existing = projectiles.filter(p => p.owner === fighter.player && p.alive).length
|
||||
if (existing >= MAX_PROJECTILES) return
|
||||
|
||||
const dir = fighter.physics.facingRight ? 1 : -1
|
||||
const x = fighter.obj.pos.x + 40 * dir
|
||||
const y = fighter.obj.pos.y - 40
|
||||
|
||||
// Outer glow
|
||||
k.add([
|
||||
k.rect(FIREBALL_WIDTH + 6, FIREBALL_HEIGHT + 6),
|
||||
k.pos(x, y),
|
||||
k.anchor('center'),
|
||||
k.color(safeColor('#ff880044')),
|
||||
k.opacity(0.3),
|
||||
k.z(14),
|
||||
`fireball_glow_${fighter.player}`,
|
||||
{ speed: FIREBALL_SPEED * dir, owner: fighter.player },
|
||||
])
|
||||
|
||||
const obj = k.add([
|
||||
k.rect(FIREBALL_WIDTH, FIREBALL_HEIGHT),
|
||||
k.pos(x, y),
|
||||
k.anchor('center'),
|
||||
k.color(safeColor('#ff6600')),
|
||||
k.opacity(1),
|
||||
k.z(15),
|
||||
`fireball_${fighter.player}`,
|
||||
]) as unknown as ProjectileObj
|
||||
|
||||
const projectile: Projectile = {
|
||||
obj,
|
||||
owner: fighter.player,
|
||||
speed: FIREBALL_SPEED * dir,
|
||||
damage: FIREBALL_DAMAGE,
|
||||
alive: true,
|
||||
}
|
||||
projectiles.push(projectile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all projectiles. Called each frame from the game loop.
|
||||
* Returns hit results for any projectile that connected.
|
||||
*/
|
||||
export function updateProjectiles(
|
||||
k: KaplayInstance,
|
||||
fighters: [FighterInstance, FighterInstance],
|
||||
dt: number,
|
||||
): { target: FighterInstance; result: HitResult }[] {
|
||||
const hits: { target: FighterInstance; result: HitResult }[] = []
|
||||
|
||||
// Update glow positions to follow their fireballs
|
||||
for (const player of [1, 2] as const) {
|
||||
const glows = k.get(`fireball_glow_${player}`) as unknown as ProjectileObj[]
|
||||
for (const glow of glows) {
|
||||
const spd = (glow as any).speed as number
|
||||
glow.pos.x += spd * dt
|
||||
if (glow.pos.x < STAGE_LEFT - 50 || glow.pos.x > STAGE_RIGHT + 50) {
|
||||
glow.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const proj of projectiles) {
|
||||
if (!proj.alive) continue
|
||||
|
||||
// Move
|
||||
proj.obj.pos.x += proj.speed * dt
|
||||
|
||||
// Off-screen cleanup
|
||||
if (proj.obj.pos.x < STAGE_LEFT - 50 || proj.obj.pos.x > STAGE_RIGHT + 50) {
|
||||
proj.obj.destroy()
|
||||
proj.alive = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Check collision with opponent
|
||||
const target = fighters.find(f => f.player !== proj.owner)
|
||||
if (!target) continue
|
||||
|
||||
const isCrouching = target.combat.state === 'crouching' || target.combat.state === 'blocking'
|
||||
const hurtH = isCrouching ? CROUCH_HURTBOX_HEIGHT : HURTBOX_HEIGHT
|
||||
const tLeft = target.obj.pos.x - HURTBOX_WIDTH / 2
|
||||
const tRight = target.obj.pos.x + HURTBOX_WIDTH / 2
|
||||
const tTop = target.obj.pos.y - hurtH
|
||||
const tBottom = target.obj.pos.y
|
||||
|
||||
const pLeft = proj.obj.pos.x - FIREBALL_WIDTH / 2
|
||||
const pRight = proj.obj.pos.x + FIREBALL_WIDTH / 2
|
||||
const pTop = proj.obj.pos.y - FIREBALL_HEIGHT / 2
|
||||
const pBottom = proj.obj.pos.y + FIREBALL_HEIGHT / 2
|
||||
|
||||
if (pRight >= tLeft && pLeft <= tRight && pBottom >= tTop && pTop <= tBottom) {
|
||||
const isBlocking = target.combat.state === 'blocking' && target.physics.grounded
|
||||
const result: HitResult = isBlocking
|
||||
? {
|
||||
type: 'blocked',
|
||||
damage: Math.round(proj.damage * CHIP_DAMAGE_RATIO),
|
||||
hitstun: 0,
|
||||
blockstun: BLOCKSTUN_SPECIAL,
|
||||
knockbackX: 60,
|
||||
knockbackY: 0,
|
||||
hitbox: { offsetX: 0, offsetY: 0, width: FIREBALL_WIDTH, height: FIREBALL_HEIGHT, damage: proj.damage, hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL, knockbackX: 120, knockbackY: 0, activeFrames: [0, 0] },
|
||||
}
|
||||
: {
|
||||
type: 'hit',
|
||||
damage: proj.damage,
|
||||
hitstun: HITSTUN_SPECIAL,
|
||||
blockstun: 0,
|
||||
knockbackX: 120,
|
||||
knockbackY: -80,
|
||||
hitbox: { offsetX: 0, offsetY: 0, width: FIREBALL_WIDTH, height: FIREBALL_HEIGHT, damage: proj.damage, hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL, knockbackX: 120, knockbackY: -80, activeFrames: [0, 0] },
|
||||
}
|
||||
|
||||
hits.push({ target, result })
|
||||
proj.obj.destroy()
|
||||
proj.alive = false
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead projectiles
|
||||
for (let i = projectiles.length - 1; i >= 0; i--) {
|
||||
if (!projectiles[i].alive) projectiles.splice(i, 1)
|
||||
}
|
||||
|
||||
return hits
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all projectiles (round reset).
|
||||
*/
|
||||
export function clearAllProjectiles(k: KaplayInstance): void {
|
||||
for (const proj of projectiles) {
|
||||
if (proj.alive && proj.obj.exists()) {
|
||||
proj.obj.destroy()
|
||||
}
|
||||
}
|
||||
projectiles.length = 0
|
||||
|
||||
// Clean glow objects
|
||||
for (const player of [1, 2]) {
|
||||
for (const glow of k.get(`fireball_glow_${player}`)) {
|
||||
glow.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { FighterInstance } from './types'
|
||||
import { MOVES, matchCombo } from './moves'
|
||||
import type { InputEvent } from './types'
|
||||
|
||||
/**
|
||||
* Update fighter state machine based on current input and state.
|
||||
* Returns the name of a combo move to execute, or null.
|
||||
*/
|
||||
export function updateStateMachine(
|
||||
fighter: FighterInstance,
|
||||
inputBuffer: InputEvent[],
|
||||
): string | null {
|
||||
const { combat, physics, input } = fighter
|
||||
const state = combat.state
|
||||
|
||||
combat.stateTimer++
|
||||
|
||||
// --- Terminal states ---
|
||||
if (state === 'ko' || state === 'win') return null
|
||||
|
||||
// --- Stun states: count down and return to idle ---
|
||||
if (state === 'hit') {
|
||||
combat.stunTimer--
|
||||
if (combat.stunTimer <= 0) {
|
||||
transition(fighter, 'idle')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (state === 'knockback') {
|
||||
combat.stunTimer--
|
||||
if (combat.stunTimer <= 0 && physics.grounded) {
|
||||
transition(fighter, 'idle')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (state === 'blocking') {
|
||||
if (combat.blockTimer > 0) {
|
||||
combat.blockTimer--
|
||||
return null
|
||||
}
|
||||
// Holding back = stay blocking; release = idle
|
||||
const holdingBack = isHoldingBack(fighter)
|
||||
if (!holdingBack || !physics.grounded) {
|
||||
transition(fighter, 'idle')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Attack states: advance frame, return to idle on completion ---
|
||||
if (state === 'attacking' || state === 'kicking' || state === 'special') {
|
||||
combat.attackFrame++
|
||||
const move = combat.currentMove ? MOVES[combat.currentMove] : null
|
||||
if (move && combat.attackFrame >= move.totalFrames) {
|
||||
transition(fighter, 'idle')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Actionable states: idle, walking, jumping, crouching ---
|
||||
|
||||
// Check for combo input first (highest priority)
|
||||
const combo = matchCombo(inputBuffer, fighter.physics.facingRight)
|
||||
if (combo && physics.grounded) {
|
||||
return combo
|
||||
}
|
||||
|
||||
// Check attack buttons
|
||||
if (input.punch) {
|
||||
if (physics.grounded) {
|
||||
if (input.down) {
|
||||
startAttack(fighter, 'crouchPunch')
|
||||
} else {
|
||||
startAttack(fighter, 'punch')
|
||||
}
|
||||
} else {
|
||||
startAttack(fighter, 'airPunch')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (input.kick) {
|
||||
if (physics.grounded) {
|
||||
if (input.down) {
|
||||
startAttack(fighter, 'crouchKick')
|
||||
} else {
|
||||
startAttack(fighter, 'kick')
|
||||
}
|
||||
} else {
|
||||
startAttack(fighter, 'airKick')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Blocking: holding back while grounded
|
||||
if (isHoldingBack(fighter) && physics.grounded) {
|
||||
if ((state as string) !== 'blocking') transition(fighter, 'blocking')
|
||||
return null
|
||||
}
|
||||
|
||||
// Crouching
|
||||
if (input.down && physics.grounded) {
|
||||
if (state !== 'crouching') transition(fighter, 'crouching')
|
||||
return null
|
||||
}
|
||||
|
||||
// Walking
|
||||
if ((input.left || input.right) && physics.grounded) {
|
||||
if (state !== 'walking') transition(fighter, 'walking')
|
||||
return null
|
||||
}
|
||||
|
||||
// Jumping (handled in physics, but update state)
|
||||
if (!physics.grounded) {
|
||||
if (state !== 'jumping') transition(fighter, 'jumping')
|
||||
return null
|
||||
}
|
||||
|
||||
// Default: idle
|
||||
if (state !== 'idle') transition(fighter, 'idle')
|
||||
return null
|
||||
}
|
||||
|
||||
function transition(fighter: FighterInstance, newState: FighterInstance['combat']['state']): void {
|
||||
fighter.combat.state = newState
|
||||
fighter.combat.stateTimer = 0
|
||||
fighter.combat.attackFrame = 0
|
||||
fighter.combat.currentMove = null
|
||||
fighter.combat.hasHitThisAttack = false
|
||||
}
|
||||
|
||||
function startAttack(fighter: FighterInstance, moveName: string): void {
|
||||
const move = MOVES[moveName]
|
||||
if (!move) return
|
||||
const stateMap: Record<string, FighterInstance['combat']['state']> = {
|
||||
attack: 'attacking',
|
||||
kick: 'kicking',
|
||||
special: 'special',
|
||||
}
|
||||
fighter.combat.state = stateMap[move.animation] || 'attacking'
|
||||
fighter.combat.stateTimer = 0
|
||||
fighter.combat.attackFrame = 0
|
||||
fighter.combat.currentMove = moveName
|
||||
fighter.combat.hasHitThisAttack = false
|
||||
}
|
||||
|
||||
export function startComboMove(fighter: FighterInstance, moveName: string): void {
|
||||
startAttack(fighter, moveName)
|
||||
}
|
||||
|
||||
export function enterHitstun(fighter: FighterInstance, stunFrames: number, knockbackX: number, knockbackY: number): void {
|
||||
const isKnockback = knockbackY < 0 || Math.abs(knockbackX) > 150
|
||||
fighter.combat.state = isKnockback ? 'knockback' : 'hit'
|
||||
fighter.combat.stateTimer = 0
|
||||
fighter.combat.stunTimer = stunFrames
|
||||
fighter.combat.attackFrame = 0
|
||||
fighter.combat.currentMove = null
|
||||
|
||||
const dir = fighter.physics.facingRight ? -1 : 1 // knock away from attacker
|
||||
fighter.physics.vx = knockbackX * dir
|
||||
fighter.physics.vy = knockbackY
|
||||
if (knockbackY < 0) fighter.physics.grounded = false
|
||||
}
|
||||
|
||||
export function enterBlockstun(fighter: FighterInstance, stunFrames: number, pushback: number): void {
|
||||
fighter.combat.state = 'blocking'
|
||||
fighter.combat.stateTimer = 0
|
||||
fighter.combat.blockTimer = stunFrames
|
||||
|
||||
const dir = fighter.physics.facingRight ? -1 : 1
|
||||
fighter.physics.vx = pushback * dir
|
||||
}
|
||||
|
||||
export function enterKO(fighter: FighterInstance): void {
|
||||
fighter.combat.state = 'ko'
|
||||
fighter.combat.stateTimer = 0
|
||||
fighter.combat.currentMove = null
|
||||
}
|
||||
|
||||
export function enterWin(fighter: FighterInstance): void {
|
||||
fighter.combat.state = 'win'
|
||||
fighter.combat.stateTimer = 0
|
||||
fighter.combat.currentMove = null
|
||||
}
|
||||
|
||||
function isHoldingBack(fighter: FighterInstance): boolean {
|
||||
if (fighter.physics.facingRight) {
|
||||
return fighter.input.left && !fighter.input.right
|
||||
}
|
||||
return fighter.input.right && !fighter.input.left
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp } from 'kaplay'
|
||||
import type kaplay from 'kaplay'
|
||||
import type { SpriteCustomization } from '../sprites'
|
||||
|
||||
export type KaplayInstance = ReturnType<typeof kaplay>
|
||||
|
||||
export type ArcadeFighter = GameObj<SpriteComp | PosComp | ScaleComp | AnchorComp | OpacityComp | ColorComp | RotateComp | ZComp>
|
||||
|
||||
export type FighterState =
|
||||
| 'idle' | 'walking' | 'jumping' | 'crouching'
|
||||
| 'attacking' | 'kicking' | 'special'
|
||||
| 'hit' | 'knockback' | 'blocking' | 'ko' | 'win'
|
||||
|
||||
export interface FighterPhysics {
|
||||
vx: number
|
||||
vy: number
|
||||
grounded: boolean
|
||||
facingRight: boolean
|
||||
}
|
||||
|
||||
export interface FighterCombat {
|
||||
hp: number
|
||||
maxHp: number
|
||||
state: FighterState
|
||||
stateTimer: number // frames spent in current state
|
||||
stunTimer: number // frames of hitstun remaining
|
||||
blockTimer: number // frames of blockstun remaining
|
||||
comboCount: number // current combo hit count
|
||||
comboDamage: number // accumulated damage in current combo (for scaling)
|
||||
attackFrame: number // current frame within active attack
|
||||
currentMove: string | null // name of move being executed
|
||||
hasHitThisAttack: boolean // prevent multi-hit on single swing
|
||||
}
|
||||
|
||||
export interface Hitbox {
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
width: number
|
||||
height: number
|
||||
damage: number
|
||||
hitstun: number
|
||||
blockstun: number
|
||||
knockbackX: number
|
||||
knockbackY: number
|
||||
activeFrames: [number, number]
|
||||
}
|
||||
|
||||
export interface MoveDefinition {
|
||||
name: string
|
||||
animation: string // maps to sprite anim name: 'attack', 'kick', 'special'
|
||||
totalFrames: number
|
||||
hitboxes: Hitbox[]
|
||||
recovery: number
|
||||
canCancel: boolean // can be cancelled into other moves on hit
|
||||
isAerial: boolean // can be performed in air
|
||||
}
|
||||
|
||||
export interface ComboDefinition {
|
||||
name: string
|
||||
inputs: string[] // e.g. ['down', 'forward', 'A'] — forward/back are relative
|
||||
window: number // ms to complete the sequence
|
||||
move: string // key into MOVES
|
||||
}
|
||||
|
||||
export interface PlayerInput {
|
||||
up: boolean
|
||||
down: boolean
|
||||
left: boolean
|
||||
right: boolean
|
||||
punch: boolean
|
||||
kick: boolean
|
||||
}
|
||||
|
||||
export interface InputEvent {
|
||||
direction: 'up' | 'down' | 'left' | 'right' | null
|
||||
button: 'A' | 'B' | null
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface ArcadeConfig {
|
||||
canvas: HTMLCanvasElement
|
||||
player1: { seed: string; tier: number; archetype?: string; name: string; customization?: SpriteCustomization }
|
||||
player2: { seed: string; tier: number; archetype?: string; name: string; customization?: SpriteCustomization }
|
||||
arena: string
|
||||
rounds: 1 | 3 | 5
|
||||
roundTime: 30 | 60 | 99
|
||||
/** When set, P2 is controlled by this bot (CPU mode) */
|
||||
cpuBotId?: string
|
||||
}
|
||||
|
||||
export interface ArcadeCallbacks {
|
||||
onHpChange: (p1hp: number, p2hp: number) => void
|
||||
onRoundEnd: (winner: 1 | 2 | 0, p1wins: number, p2wins: number) => void
|
||||
onMatchEnd: (winner: 1 | 2) => void
|
||||
onTimerTick: (seconds: number) => void
|
||||
onCombo: (player: 1 | 2, count: number, moveName: string) => void
|
||||
}
|
||||
|
||||
export interface ArcadeSceneController {
|
||||
start: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
destroy: () => void
|
||||
setInput: (player: 1 | 2, input: PlayerInput) => void
|
||||
on: <K extends keyof ArcadeCallbacks>(event: K, cb: ArcadeCallbacks[K]) => void
|
||||
/** Get a snapshot of the current game state (for bot bridge) */
|
||||
getGameState: () => { fighter1: FighterInstance; fighter2: FighterInstance; timer: number; round: number; roundActive: boolean } | null
|
||||
}
|
||||
|
||||
export interface FighterInstance {
|
||||
obj: ArcadeFighter
|
||||
physics: FighterPhysics
|
||||
combat: FighterCombat
|
||||
player: 1 | 2
|
||||
name: string
|
||||
input: PlayerInput
|
||||
}
|
||||
Reference in New Issue
Block a user