302 lines
9.7 KiB
TypeScript
302 lines
9.7 KiB
TypeScript
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,
|
|
}
|
|
}
|