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 }