Files
botfights/frontend/src/game/arcade/combat.ts
T

139 lines
3.9 KiB
TypeScript
Raw Normal View History

2026-04-11 19:46:37 +01:00
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
}