This commit is contained in:
Dorian
2026-04-11 19:46:37 +01:00
parent 52752a92bf
commit 32e6c19f72
44 changed files with 5511 additions and 39 deletions
+301
View File
@@ -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,
}
}
+138
View File
@@ -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
}
+98
View File
@@ -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
+10
View File
@@ -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'
+288
View File
@@ -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
}
+113
View File
@@ -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
}
+176
View File
@@ -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()
}
}
}
+192
View File
@@ -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
}
+117
View File
@@ -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
}