From bc48cc8887dd18ad2f075d130896d213b50d5638 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 00:07:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20N8=20hit=20impact=20&=20motion=20polish?= =?UTF-8?q?=20=E2=80=94=20freeze-frames,=20afterimages,=20particles,=20cam?= =?UTF-8?q?era?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/game/FightScene.ts | 95 ++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 60cbdea..0dce6bc 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -304,23 +304,27 @@ export async function createFightScene(config: FightSceneConfig) { // Particle helpers function spawnSparks(x: number, y: number, count: number, color: string) { + const intense = count > 8 for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2 - const speed = 100 + Math.random() * 300 - const size = 2 + Math.random() * 4 + const speed = (intense ? 150 : 100) + Math.random() * (intense ? 400 : 300) + const size = (intense ? 3 : 2) + Math.random() * (intense ? 6 : 4) + const rotSpeed = (Math.random() - 0.5) * 600 const spark = k.add([ - k.rect(size, size), + k.rect(size, size * (0.5 + Math.random() * 0.5)), k.pos(x, y), k.color(safeColor(k,color)), k.opacity(1), k.z(20), 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.pos.x += (spark as any).vx * 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() 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()) } + // 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 let grotesqueObjs: any[] = [] function spawnGrotesqueDetails(fighter: any, scaleFactor: number) { @@ -1985,7 +2035,8 @@ export async function createFightScene(config: FightSceneConfig) { def.play(isCritical ? 'knockback' : 'hit') k.shake(isCritical ? 12 : 5) 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 const push = dir * (isCritical ? 100 : 35) 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) spawnSparks(def.pos.x, def.pos.y - 20, 15, 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) k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) await k.wait(0.4) @@ -2040,7 +2092,8 @@ export async function createFightScene(config: FightSceneConfig) { def.play(isCritical ? 'knockback' : 'hit') k.shake(isCritical ? 14 : 6) 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) k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) await k.wait(0.3) @@ -2061,7 +2114,8 @@ export async function createFightScene(config: FightSceneConfig) { def.play(isCritical ? 'knockback' : 'hit') k.shake(isCritical ? 15 : 7) 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 await k.wait(0.3) // Zip back to start @@ -2082,7 +2136,7 @@ export async function createFightScene(config: FightSceneConfig) { def.play('knockback') k.shake(isCritical ? 18 : 8) 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 await k.tween(def.pos.y, origDY - launchHeight, 0.25, (v) => { def.pos.y = v }, k.easings.easeOutQuad) await k.wait(0.15) @@ -7244,6 +7298,14 @@ export async function createFightScene(config: FightSceneConfig) { 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) { fanfareCritical() @@ -7261,6 +7323,14 @@ export async function createFightScene(config: FightSceneConfig) { ][Math.floor(Math.random() * 10)]) await k.wait(0.4) // let the voice line land before the hit animation 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 glitchRGB(0.3) const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] @@ -7325,8 +7395,9 @@ export async function createFightScene(config: FightSceneConfig) { destroyGrotesqueDetails() destroyMorphOverlays() - // Zoom back out from hyperdetail mode - if (hyperDetail && fA && fB && savedScaleAX != null && savedScaleBX != null) { + // Zoom back out from hyperdetail or critical camera zoom + const needsZoomOut = (hyperDetail || (isCritical && !hyperDetail)) && fA && fB && savedScaleAX != null && savedScaleBX != null + if (needsZoomOut) { await Promise.all([ k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => { fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v