diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index f87c658..4741da3 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -7,6 +7,7 @@ import { announceFinishHim, announceFlawlessVictory, sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll, setMusicIntensity, stopAllAudio, + setMasterMute, isMasterMuted, ensureAudioContext, } from '../game/sounds' interface Round { @@ -60,6 +61,13 @@ const hitTextColor = ref('#ff2d2d') const hitTextX = ref(50) const hitTextY = ref(30) const glitching = ref(false) +const soundOn = ref(true) + +function toggleSound() { + soundOn.value = !soundOn.value + ensureAudioContext() + setMasterMute(!soundOn.value) +} // Staggered log const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([]) @@ -201,7 +209,10 @@ async function replay() { currentRound.value = 0 await initScene() - scene?.startMusic() + if (soundOn.value) { + ensureAudioContext() + scene?.startMusic() + } await sleep(300) // Deep movie trailer intro @@ -230,11 +241,20 @@ async function replay() { await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600) await sleep(80) fanfareFight() - announceRoundHype() - sfxCrowdCheer() await showOverlay('FIGHT!', '#ff2d7b', 400) await sleep(80) + // Show speech bubbles BEFORE the fight animation so viewers see what bots said + if (scene) { + if (round.botAResponse) scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 3.5) + if (round.botBResponse) { + setTimeout(() => { + if (scene && round.botBResponse) scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 3.2) + }, 400) + } + } + await sleep(300) + // Log + fight animation in parallel const logPromise = addRoundToLog(round, true) const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4 @@ -257,16 +277,7 @@ async function replay() { console.error(`[FightViewer] playRound ${round.roundNumber} error:`, err) } - // Speech bubbles showing bot responses - if (scene) { - if (round.botAResponse) scene.showSpeechBubble('a', round.botAResponse.slice(0, 100), 2.8) - // Stagger bot B slightly so they don't pop in at the exact same time - setTimeout(() => { - if (scene && round.botBResponse) scene.showSpeechBubble('b', round.botBResponse.slice(0, 100), 2.5) - }, 300) - } - - // Hit text overlay + // Hit text overlay — after playRound completes so it lands on the result const hitWords = isCritical ? ['CRITICAL!', 'DEVASTATING!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!'] : ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!'] @@ -276,17 +287,6 @@ async function replay() { isCritical ? '#ffe14d' : '#ff2d2d', aWon ? 65 : 35, ) - // Crowd reactions - if (isCritical) { - sfxCrowdGasp() - setTimeout(() => sfxCrowdOoh(), 400) - } else if (Math.random() < 0.4) { - sfxCrowdOoh() - } - // Random hype voiceover on big moments - if (isCritical || Math.random() < 0.3) { - setTimeout(() => announceRandomHype(), 300) - } } await logPromise @@ -529,6 +529,19 @@ async function replay() { > {{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }} + {{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }} diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 163d7fa..fc926a7 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -6082,31 +6082,19 @@ export async function createFightScene(config: FightSceneConfig) { const bubbleW = textW + padX * 2 const bubbleH = lines.length * lineH + padY * 2 const tailSize = 10 - const borderW = 3 // Position — offset away from center so bubbles don't overlap const offsetX = side === 'a' ? -bubbleW * 0.3 : bubbleW * 0.3 const bx = fighter.pos.x + offsetX const by = Math.max(8, fighter.pos.y - 85 - bubbleH) - // Colours — vivid neon - const bgColor = side === 'a' ? '#001a22' : '#22001a' const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b' - const glowColor = side === 'a' ? '#00f0ff' : '#ff2d7b' - const textColor = '#ffffff' + const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e' - // Outer glow halo - const glow = k.add([ - k.rect(bubbleW + 12, bubbleH + 12), - k.pos(bx - bubbleW / 2 - 6, by - 6), - k.color(safeColor(k, glowColor)), k.opacity(0.08), k.z(53), - ]) - allEls.push(glow) - - // Border (thick, behind bubble) + // Single border rect (no glow/accent layers — keeps rendering clean) const border = k.add([ - k.rect(bubbleW + borderW * 2, bubbleH + borderW * 2), - k.pos(bx - bubbleW / 2 - borderW, by - borderW), + k.rect(bubbleW + 4, bubbleH + 4), + k.pos(bx - bubbleW / 2 - 2, by - 2), k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54), ]) allEls.push(border) @@ -6119,70 +6107,56 @@ export async function createFightScene(config: FightSceneConfig) { ]) allEls.push(bubble) - // Tail — two overlapping rects to form a triangle look + // Tail — pixel arrow pointing down to fighter const tailX = bx + (side === 'a' ? bubbleW * 0.15 : -bubbleW * 0.15) - const tailY = by + bubbleH - // Outer tail (border colour) - const tailOuter = k.add([ - k.rect(tailSize + borderW, tailSize + borderW), - k.pos(tailX, tailY - 2), - k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54), - k.rotate(45), k.anchor('top'), - ]) - allEls.push(tailOuter) - // Inner tail (bg colour, masks the border) - const tailInner = k.add([ - k.rect(tailSize, tailSize), - k.pos(tailX, tailY - 1), - k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55), - k.rotate(45), k.anchor('top'), - ]) - allEls.push(tailInner) - // Cover strip — hides top half of the rotated tail square - const tailCover = k.add([ - k.rect(bubbleW, borderW + 2), - k.pos(bx - bubbleW / 2, tailY - borderW), - k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(56), - ]) - allEls.push(tailCover) - - // Inner accent line at top of bubble - const accent = k.add([ - k.rect(bubbleW - 8, 2), - k.pos(bx - bubbleW / 2 + 4, by + 3), - k.color(safeColor(k, borderColor)), k.opacity(0.4), k.z(56), - ]) - allEls.push(accent) + const tailBaseY = by + bubbleH + for (let row = 0; row < tailSize; row++) { + const tw = tailSize - row + // Border pixel row + const tailBorder = k.add([ + k.rect(tw + 2, 1), + k.pos(tailX - (tw + 2) / 2, tailBaseY + row), + k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54), + ]) + allEls.push(tailBorder) + // Inner pixel row + if (tw > 2) { + const tailInner = k.add([ + k.rect(tw - 1, 1), + k.pos(tailX - (tw - 1) / 2, tailBaseY + row), + k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55), + ]) + allEls.push(tailInner) + } + } // Text lines for (let i = 0; i < lines.length; i++) { const tEl = k.add([ - k.text(safeText(lines[i]), { size: fontSize }), + k.text(safeText(lines[i]), { size: fontSize, font: 'monospace' }), k.pos(bx - bubbleW / 2 + padX, by + padY + i * lineH + 2), - k.color(safeColor(k, textColor)), k.opacity(1), k.z(57), + k.color(255, 255, 255), k.opacity(1), k.z(57), ]) allEls.push(tEl) } - // Animated glow pulse - glow.onUpdate(() => { - glow.opacity = 0.05 + Math.sin(k.time() * 3) * 0.05 - }) + // Border glow pulse border.onUpdate(() => { border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15 }) - // Pop-in — scale up from anchor point with bounce + // Pop-in animation const anchorX = bx, anchorY = by + bubbleH allEls.forEach(el => { const origX = el.pos.x, origY = el.pos.y + const origOpacity = el.opacity el.pos.x = anchorX + (origX - anchorX) * 0.1 el.pos.y = anchorY + (origY - anchorY) * 0.1 - el.opacity *= 0 + el.opacity = 0 k.tween(0, 1, 0.25, (t) => { el.pos.x = anchorX + (origX - anchorX) * t el.pos.y = anchorY + (origY - anchorY) * t - el.opacity = (el === glow ? 0.08 : el === border ? 0.85 : el === bubble || el === tailInner || el === tailCover ? 0.95 : el === tailOuter ? 0.85 : el === accent ? 0.4 : 1) * t + el.opacity = origOpacity * t }, k.easings.easeOutBack) }) @@ -6641,7 +6615,7 @@ export async function createFightScene(config: FightSceneConfig) { }, k.easings.easeInOutQuad) await k.wait(0.2) heart.text = '!!!' - announceRandom("You promised you'd stop fighting!", false) + announceRandom("You promised you'd stop fighting!") await k.wait(0.4) // Girlfriend storms off announceSilly('I\'m telling your developer!') diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index ba58367..28ea7e4 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -265,8 +265,9 @@ if (typeof speechSynthesis !== 'undefined') { loadVoices() } -function speak(text: string, profileName: string, cancelPrevious: boolean = true, _echo: boolean = false) { +function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) { if (typeof speechSynthesis === 'undefined') return + if (masterMuted) return if (!voicesLoaded) loadVoices() if (cancelPrevious) speechSynthesis.cancel() const profile = voiceProfiles[profileName] || voiceProfiles.announcer @@ -285,11 +286,10 @@ export function stopAllAudio() { // Public voice functions export function announce(text: string, pitch?: number, rate?: number) { + if (masterMuted) return if (pitch !== undefined || rate !== undefined) { - // Custom params — use announcer voice with overrides if (typeof speechSynthesis === 'undefined') return if (!voicesLoaded) loadVoices() - speechSynthesis.cancel() const utter = new SpeechSynthesisUtterance(text) const profile = voiceProfiles.announcer if (profile.voice) utter.voice = profile.voice @@ -310,16 +310,16 @@ export function announceSmooth(text: string) { speak(text, 'smooth') } // Pick a random voice profile for variety const ALL_VOICE_KEYS = Object.keys(voiceProfiles) -export function announceRandom(text: string, echo: boolean = false) { +export function announceRandom(text: string) { const key = ALL_VOICE_KEYS[Math.floor(Math.random() * ALL_VOICE_KEYS.length)] - speak(text, key, true, echo) + speak(text, key) } // Announce with a specific mood category const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v', 'final_boss', 'game_over', 'mainframe', 'hal', 'ancient', 'giant'] const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'wrestler_v', 'karen', 'terrified', 'power_up', 'news', 'scottish'] const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch', 'drunk', 'stoner', 'fairy', 'tutorial', 'npc', 'dial_up', 'glitchbot', 'grandma', 'conspiracy'] const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper', 'posh', 'aussie', 'french', 'sensei', 'ai_core', 'android_v', 'siri', 'professor', 'texan', 'boss_taunt', 'sleepy'] -export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)], true, true) } +export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)]) } export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)]) } export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) } export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)]) } @@ -1794,6 +1794,30 @@ export function setSfxVolume(v: number) { if (sfxGain) sfxGain.gain.value = Math.max(0, Math.min(1, v)) } +let masterMuted = false +const MUSIC_VOL = 0.12 +const SFX_VOL = 0.25 + +export function setMasterMute(muted: boolean) { + masterMuted = muted + if (muted) { + if (musicGain) musicGain.gain.value = 0 + if (sfxGain) sfxGain.gain.value = 0 + if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() + } else { + if (musicGain) musicGain.gain.value = MUSIC_VOL + if (sfxGain) sfxGain.gain.value = SFX_VOL + } +} + +export function isMasterMuted(): boolean { + return masterMuted +} + +export function ensureAudioContext() { + getCtx() +} + // === CROWD SOUNDS === // Procedural crowd reactions using layered noise + filtered tones diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index 0838f8e..15d54c8 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -281,22 +281,45 @@ const tierClass = (t: number) => `tier-${t}` + + diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index 061e9d1..95a9731 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -11,7 +11,6 @@ const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, const step = ref('login') const error = ref('') const isJoining = ref(false) -const isFightingClassic = ref(false) const queueCount = ref(0) let pollHandle: ReturnType | null = null @@ -174,25 +173,6 @@ async function fight() { isJoining.value = false } -async function fightClassicBot() { - if (!bot.value || isFightingClassic.value) return - isFightingClassic.value = true - error.value = '' - try { - const res = await fetch(`/api/fights/mock/${bot.value.id}`, { method: 'POST' }) - if (res.ok) { - const data = await res.json() - router.push(`/arena/${data.fightId}`) - } else { - const data = await res.json() - error.value = data.error || 'Failed to start fight.' - } - } catch { - error.value = 'Network error.' - } - isFightingClassic.value = false -} - function handleSignOut() { logout() step.value = 'login' @@ -533,7 +513,7 @@ function handleSignOut() { font-display font-black text-2xl tracking-[0.2em] hover:bg-neon-pink/20 transition-all neon-border-pink disabled:opacity-30 disabled:cursor-not-allowed" - :disabled="isJoining || isFightingClassic" + :disabled="isJoining" @click="fight" > {{ isJoining ? 'MATCHING...' : 'FIGHT' }} @@ -542,28 +522,6 @@ function handleSignOut() { Queue up against a real AI bot

- - -

- Instant match against a house bot -