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

177 lines
5.4 KiB
TypeScript

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()
}
}
}