feat: N8 hit impact & motion polish — freeze-frames, afterimages, particles, camera

- impactFreeze(): brief white flash pause on devastating critical hits
- spawnAfterimages(): trailing ghost sprites on fast dash moves
- spawnSparks(): varied particle sizes, rotational velocity, stronger gravity
- cameraZoom(): slight zoom-in on critical final exchanges, pull-back for ultimates
- Wired into dashPunch, aerialSlam, flyingKick, dashThrough, uppercut
- Critical exchanges get 1.3x camera zoom before the blow lands
- Ultimates get 0.85x pull-back to show the full move

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 00:07:21 +00:00
co-authored by Claude Opus 4.6
parent 05829e58ae
commit bc48cc8887
+83 -12
View File
@@ -304,23 +304,27 @@ export async function createFightScene(config: FightSceneConfig) {
// Particle helpers // Particle helpers
function spawnSparks(x: number, y: number, count: number, color: string) { function spawnSparks(x: number, y: number, count: number, color: string) {
const intense = count > 8
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2 const angle = Math.random() * Math.PI * 2
const speed = 100 + Math.random() * 300 const speed = (intense ? 150 : 100) + Math.random() * (intense ? 400 : 300)
const size = 2 + Math.random() * 4 const size = (intense ? 3 : 2) + Math.random() * (intense ? 6 : 4)
const rotSpeed = (Math.random() - 0.5) * 600
const spark = k.add([ const spark = k.add([
k.rect(size, size), k.rect(size, size * (0.5 + Math.random() * 0.5)),
k.pos(x, y), k.pos(x, y),
k.color(safeColor(k,color)), k.color(safeColor(k,color)),
k.opacity(1), k.opacity(1),
k.z(20), k.z(20),
k.rotate(Math.random() * 360), k.rotate(Math.random() * 360),
{ vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 100 }, k.anchor('center'),
{ vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 100, rotV: rotSpeed },
]) ])
spark.onUpdate(() => { spark.onUpdate(() => {
spark.pos.x += (spark as any).vx * k.dt() spark.pos.x += (spark as any).vx * k.dt()
spark.pos.y += (spark as any).vy * k.dt() spark.pos.y += (spark as any).vy * k.dt()
;(spark as any).vy += 400 * k.dt() // gravity ;(spark as any).vy += 500 * k.dt() // gravity
spark.angle += (spark as any).rotV * k.dt()
spark.opacity -= 1.5 * k.dt() spark.opacity -= 1.5 * k.dt()
if (spark.opacity <= 0) spark.destroy() if (spark.opacity <= 0) spark.destroy()
}) })
@@ -586,6 +590,52 @@ export async function createFightScene(config: FightSceneConfig) {
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy()) k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
} }
// Impact freeze-frame: brief pause with white flash on devastating crits
async function impactFreeze(duration: number = 0.08) {
const overlay = k.add([
k.rect(W, H), k.pos(0, 0),
k.color(safeColor(k, '#ffffff')), k.opacity(0.5), k.z(55),
])
// Kaplay has no built-in pause, so we just hold with a wait + overlay
await k.wait(duration)
k.tween(0.5, 0, 0.06, (v) => { overlay.opacity = v }).then(() => overlay.destroy())
}
// Spawn afterimages: trailing ghost sprites behind a moving fighter
function spawnAfterimages(fighter: any, count: number = 3) {
if (!fighter?.exists()) return
for (let i = 0; i < count; i++) {
const ghost = k.add([
k.rect(FRAME_SIZE * Math.abs(fighter.scale.x), FRAME_SIZE * Math.abs(fighter.scale.y)),
k.pos(fighter.pos.x - i * 8 * Math.sign(fighter.scale.x), fighter.pos.y),
k.color(safeColor(k, theme.accent)),
k.opacity(0.3 - i * 0.08),
k.z(fighter.z - 1),
k.anchor('bot'),
])
k.tween(ghost.opacity, 0, 0.15 + i * 0.05, (v) => { ghost.opacity = v }).then(() => ghost.destroy())
}
}
// Camera zoom: scale fighters toward/away from a focal point for impact emphasis
async function cameraZoom(
fighters: any[],
savedScales: { x: number; y: number }[],
zoomFactor: number,
duration: number,
easing: (t: number) => number = k.easings.easeOutQuad,
) {
await Promise.all(fighters.map((f, i) => {
if (!f?.exists()) return Promise.resolve()
const sx = savedScales[i].x
const sy = savedScales[i].y
return k.tween(Math.abs(f.scale.y), Math.abs(sy) * zoomFactor, duration, (v) => {
f.scale.x = sx > 0 ? v : -v
f.scale.y = v
}, easing)
}))
}
// Grotesque close-up: spawn exaggerated impact details during zoom-ins // Grotesque close-up: spawn exaggerated impact details during zoom-ins
let grotesqueObjs: any[] = [] let grotesqueObjs: any[] = []
function spawnGrotesqueDetails(fighter: any, scaleFactor: number) { function spawnGrotesqueDetails(fighter: any, scaleFactor: number) {
@@ -1985,7 +2035,8 @@ export async function createFightScene(config: FightSceneConfig) {
def.play(isCritical ? 'knockback' : 'hit') def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 12 : 5) k.shake(isCritical ? 12 : 5)
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 12 : 5, '#ffcc00') spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 12 : 5, '#ffcc00')
if (isCritical) { screenFlash('#ffffff'); sfxCritical() } if (isCritical) { screenFlash('#ffffff'); sfxCritical(); await impactFreeze() }
spawnAfterimages(atk, 2)
// Push defender // Push defender
const push = dir * (isCritical ? 100 : 35) const push = dir * (isCritical ? 100 : 35)
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
@@ -2014,7 +2065,8 @@ export async function createFightScene(config: FightSceneConfig) {
k.shake(isCritical ? 20 : 10) k.shake(isCritical ? 20 : 10)
spawnSparks(def.pos.x, def.pos.y - 20, 15, theme.accent) spawnSparks(def.pos.x, def.pos.y - 20, 15, theme.accent)
spawnShockwave(def.pos.x, GROUND_Y, theme.accent) spawnShockwave(def.pos.x, GROUND_Y, theme.accent)
if (isCritical) { screenFlash('#ff2d7b'); sfxExplosion() } else { sfxPunch() } if (isCritical) { screenFlash('#ff2d7b'); sfxExplosion(); await impactFreeze(0.1) } else { sfxPunch() }
spawnAfterimages(atk, 3)
const push = dir * (isCritical ? 120 : 50) const push = dir * (isCritical ? 120 : 50)
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.4) await k.wait(0.4)
@@ -2040,7 +2092,8 @@ export async function createFightScene(config: FightSceneConfig) {
def.play(isCritical ? 'knockback' : 'hit') def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 14 : 6) k.shake(isCritical ? 14 : 6)
spawnSparks(def.pos.x, def.pos.y - 40, 8, '#ff6600') spawnSparks(def.pos.x, def.pos.y - 40, 8, '#ff6600')
if (isCritical) { sfxCritical(); screenFlash('#ff6600') } else { sfxPunch() } if (isCritical) { sfxCritical(); screenFlash('#ff6600'); await impactFreeze() } else { sfxPunch() }
spawnAfterimages(atk, 3)
const push = dir * (isCritical ? 90 : 40) const push = dir * (isCritical ? 90 : 40)
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.3) await k.wait(0.3)
@@ -2061,7 +2114,8 @@ export async function createFightScene(config: FightSceneConfig) {
def.play(isCritical ? 'knockback' : 'hit') def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 15 : 7) k.shake(isCritical ? 15 : 7)
spawnSparks(origDX, def.pos.y - 30, 10, '#00f0ff') spawnSparks(origDX, def.pos.y - 30, 10, '#00f0ff')
if (isCritical) screenFlash('#00f0ff') spawnAfterimages(atk, 3)
if (isCritical) { screenFlash('#00f0ff'); await impactFreeze() }
// Dramatic pause behind opponent // Dramatic pause behind opponent
await k.wait(0.3) await k.wait(0.3)
// Zip back to start // Zip back to start
@@ -2082,7 +2136,7 @@ export async function createFightScene(config: FightSceneConfig) {
def.play('knockback') def.play('knockback')
k.shake(isCritical ? 18 : 8) k.shake(isCritical ? 18 : 8)
spawnSparks(def.pos.x, def.pos.y - 30, 12, '#ffe14d') spawnSparks(def.pos.x, def.pos.y - 30, 12, '#ffe14d')
if (isCritical) { screenFlash('#ffe14d'); sfxExplosion() } if (isCritical) { screenFlash('#ffe14d'); sfxExplosion(); await impactFreeze(0.1) }
const launchHeight = isCritical ? 250 : 160 const launchHeight = isCritical ? 250 : 160
await k.tween(def.pos.y, origDY - launchHeight, 0.25, (v) => { def.pos.y = v }, k.easings.easeOutQuad) await k.tween(def.pos.y, origDY - launchHeight, 0.25, (v) => { def.pos.y = v }, k.easings.easeOutQuad)
await k.wait(0.15) await k.wait(0.15)
@@ -7244,6 +7298,14 @@ export async function createFightScene(config: FightSceneConfig) {
await k.wait(0.2) await k.wait(0.2)
} }
} }
// Camera pull-back for ultimates: zoom out slightly to show the full move
if (isUltimate && !hyperDetail && fA && fB) {
await cameraZoom(
[fA, fB],
[{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }],
0.85, 0.2, k.easings.easeOutQuad,
)
}
if (exchangeCritical && isLastExchange) { if (exchangeCritical && isLastExchange) {
fanfareCritical() fanfareCritical()
@@ -7261,6 +7323,14 @@ export async function createFightScene(config: FightSceneConfig) {
][Math.floor(Math.random() * 10)]) ][Math.floor(Math.random() * 10)])
await k.wait(0.4) // let the voice line land before the hit animation await k.wait(0.4) // let the voice line land before the hit animation
announceCrowdReaction('gasp') announceCrowdReaction('gasp')
// Camera zoom-in for dramatic critical blow
if (!hyperDetail && fA && fB) {
await cameraZoom(
[fA, fB],
[{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }],
1.3, 0.2, k.easings.easeOutQuad,
)
}
// RGB glitch + hyperspeed lines on critical final blow // RGB glitch + hyperspeed lines on critical final blow
glitchRGB(0.3) glitchRGB(0.3)
const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0]
@@ -7325,8 +7395,9 @@ export async function createFightScene(config: FightSceneConfig) {
destroyGrotesqueDetails() destroyGrotesqueDetails()
destroyMorphOverlays() destroyMorphOverlays()
// Zoom back out from hyperdetail mode // Zoom back out from hyperdetail or critical camera zoom
if (hyperDetail && fA && fB && savedScaleAX != null && savedScaleBX != null) { const needsZoomOut = (hyperDetail || (isCritical && !hyperDetail)) && fA && fB && savedScaleAX != null && savedScaleBX != null
if (needsZoomOut) {
await Promise.all([ await Promise.all([
k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => { k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => {
fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v