177 lines
5.4 KiB
TypeScript
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()
|
|
}
|
|
}
|
|
}
|