Files
botfights/frontend/src/game/arcade/bot-bridge.ts
T
2026-04-11 19:46:37 +01:00

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,
}
}