diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index e008e3c..e7fec19 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -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: '' }, diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 7fb7e44..1b1543a 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -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() },