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}`
- Instant match against a house bot -