feat: victory celebration animations

Normal wins: winner flex pose with camera flash bursts + confetti rain.
Upset wins (100+ Elo underdog): dramatic zoom on winner with golden
flash. Upset tag shown on win overlay. Wired into FightViewer after
KO/Perfect sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 21:35:04 +00:00
co-authored by Claude Opus 4.6
parent cc0fb4553a
commit 88ceba43b0
2 changed files with 50 additions and 1 deletions
+8 -1
View File
@@ -587,12 +587,19 @@ async function _doReplay() {
await sleep(300) // let fatality voice finish
}
// Victory celebration — detect upsets (winner had lower Elo)
const winnerElo = winningSide === 'a' ? (props.fight.botA?.eloRating ?? 1200) : (props.fight.botB?.eloRating ?? 1200)
const loserElo = winningSide === 'a' ? (props.fight.botB?.eloRating ?? 1200) : (props.fight.botA?.eloRating ?? 1200)
const isUpset = winnerElo < loserElo - 100
await scene.playVictoryCelebration(winningSide, isUpset)
glitching.value = true
sfxApplause()
sfxCrowdCheer()
await sleep(200)
glitching.value = false
await showOverlay(`${winnerName} WINS!`, '#00f0ff', 1800)
const upsetTag = isUpset ? ' UPSET!' : ''
await showOverlay(`${winnerName} WINS!${upsetTag}`, isUpset ? '#ff6600' : '#00f0ff', 1800)
logItems.value.push(
{ type: 'divider', round: 99, text: '', color: '' },
+42
View File
@@ -12118,6 +12118,48 @@ export async function createFightScene(config: FightSceneConfig) {
await k.wait(0.7)
},
async playVictoryCelebration(winningSide: 'a' | 'b', isUpset: boolean) {
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!winner) return
if (isUpset) {
// UPSET: Dramatic zoom on winner + shocked gasp
const origSX = winner.scale.x
const origSY = winner.scale.y
sfxZoomWhoosh()
await Promise.all([
k.tween(Math.abs(origSX), 4, 0.25, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeOutQuad),
k.tween(winner.pos.x, W / 2, 0.25, (v: number) => { winner.pos.x = v }, k.easings.easeOutQuad),
])
screenFlash('#ffe14d', 0.15)
k.shake(8)
await k.wait(0.6)
// Zoom back
await Promise.all([
k.tween(winner.scale.y, Math.abs(origSY), 0.2, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad),
k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.2, (v: number) => { winner.pos.x = v }, k.easings.easeInQuad),
])
} else {
// NORMAL: Winner flex pose with camera flash
winner.play('win')
await k.wait(0.3)
screenFlash('#ffffff', 0.08)
sfxSpecial()
await k.wait(0.15)
screenFlash('#ffffff', 0.06)
await k.wait(0.15)
screenFlash('#ffffff', 0.04)
// Confetti shower
for (let i = 0; i < 20; i++) {
const cx = Math.random() * W
const confetti = k.add([k.rect(4, 4), k.pos(cx, -5), k.color(safeColor(k, ['#ff2d7b', '#00f0ff', '#ffe14d', '#39ff14', '#b83dff', '#ff6600'][i % 6])), k.opacity(0.9), k.z(20), k.rotate(Math.random() * 360)])
k.tween(confetti.pos.y, GROUND_Y + 10, 0.8 + Math.random() * 0.5, (v: number) => { confetti.pos.y = v; confetti.angle += 180 * k.dt() }).then(() => confetti.destroy())
}
}
await k.wait(0.4)
},
startMusic() { startMusic() },
stopMusic() { stopMusic() },