diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index f399e24..3f912f7 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -12,7 +12,7 @@ import { prefetchQuestion, prefetchAnswer, prefetchNarration, awaitQuestionReady, awaitAnswerReady, playQuestionNow, playAnswerNow, playNarrationNow, - sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, + sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, sfxModem, } from '../game/audio' import { isKokoroLoading, getKokoroProgress } from '../game/tts' import { isPerfMode, setPerfMode } from '../game/fight/config' @@ -257,6 +257,16 @@ const challengeLabel = (type: string) => { return labels[type] || type.replace(/_/g, ' ').toUpperCase() } +const CODE_CHALLENGE_TYPES = new Set(['code_golf', 'hack_battle']) +function isCodeAnswer(challengeType: string, answer: string): boolean { + if (CODE_CHALLENGE_TYPES.has(challengeType)) return true + // Heuristic: code-like tokens in the answer + const codeTokens = ['=>', '===', '!==', '&&', '||', '++', '--', '{}', '();', 'function ', 'return ', 'const ', 'let ', 'var ', 'def ', 'import ', 'class '] + let hits = 0 + for (const t of codeTokens) { if (answer.includes(t)) hits++ } + return hits >= 2 +} + const tierClass = (t: number) => `tier-${t}` function sleep(ms: number): Promise { return new Promise((resolve, reject) => { @@ -431,8 +441,9 @@ async function _doReplay() { } // 2. Bot A: ensure audio ready, then show log + bubble + mouth + voice all at once + const isCodeRound = isCodeAnswer(round.challengeType, round.botAResponse || '') if (round.botAResponse) { - if (doTTS) await awaitAnswerReady(props.fight.botA!.name, round.botAResponse) + if (doTTS && !isCodeRound) await awaitAnswerReady(props.fight.botA!.name, round.botAResponse) scene?.stopShowboating('a') logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse || '[NO RESPONSE]'}`, color: 'neon-cyan' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' }) @@ -443,7 +454,10 @@ async function _doReplay() { } // Ensure bubble stays visible for at least 400ms even if TTS resolves instantly const minBubbleA = sleep(400).catch(() => {}) - if (doTTS) await playAnswerNow(props.fight.botA!.name, round.botAResponse) + if (doTTS) { + if (isCodeRound) await sfxModem() + else await playAnswerNow(props.fight.botA!.name, round.botAResponse) + } await minBubbleA if (!doTTS && !insanityMode.value) await sleep(400) scene?.stopTalking('a') @@ -452,7 +466,7 @@ async function _doReplay() { // 3. Bot B: ensure audio ready, then show log + bubble + mouth + voice all at once if (round.botBResponse) { - if (doTTS) await awaitAnswerReady(props.fight.botB!.name, round.botBResponse) + if (doTTS && !isCodeRound) await awaitAnswerReady(props.fight.botB!.name, round.botBResponse) scene?.stopShowboating('b') logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse || '[NO RESPONSE]'}`, color: 'neon-pink' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' }) @@ -464,7 +478,10 @@ async function _doReplay() { scene.startTalking('b') } const minBubbleB = sleep(400).catch(() => {}) - if (doTTS) await playAnswerNow(props.fight.botB!.name, round.botBResponse) + if (doTTS) { + if (isCodeRound) await sfxModem() + else await playAnswerNow(props.fight.botB!.name, round.botBResponse) + } await minBubbleB if (!doTTS && !insanityMode.value) await sleep(400) scene?.stopTalking('b') diff --git a/frontend/src/game/audio/index.ts b/frontend/src/game/audio/index.ts index db02a09..9a4ff46 100644 --- a/frontend/src/game/audio/index.ts +++ b/frontend/src/game/audio/index.ts @@ -160,6 +160,7 @@ export { sfxCrowdCheer, sfxApplause, sfxDrumRoll, + sfxModem, announceCrowdReaction, } from './sfx' diff --git a/frontend/src/game/audio/sfx.ts b/frontend/src/game/audio/sfx.ts index ae94fe4..6ba88b8 100644 --- a/frontend/src/game/audio/sfx.ts +++ b/frontend/src/game/audio/sfx.ts @@ -885,3 +885,54 @@ export function announceCrowdReaction(type: 'cheer' | 'gasp' | 'ooh' | 'applause const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause } fns[type]() } + +/** 56k modem handshake — bleeps, carrier tones, and white noise bursts */ +export function sfxModem(durationMs = 1800): Promise { + const c = getCtx() + const d = getSfxDest() + const t = c.currentTime + const dur = durationMs / 1000 + + // Phase 1: Initial dial tone beeps (0-0.3s) + tone(2600, 'sine', 0.08, d, t) + tone(2400, 'sine', 0.08, d, t + 0.1) + tone(2600, 'sine', 0.06, d, t + 0.2) + + // Phase 2: Carrier negotiation — rapid alternating tones (0.3-0.8s) + const freqs = [1200, 2400, 1800, 2100, 1650, 2250, 1950, 2400, 1200, 2100] + for (let i = 0; i < freqs.length; i++) { + tone(freqs[i], 'square', 0.04, d, t + 0.3 + i * 0.05) + } + + // Phase 3: Data burst — filtered white noise (0.8-1.4s) + const noiseLen = Math.max(1, Math.floor(c.sampleRate * 0.6)) + const noiseBuf = c.createBuffer(1, noiseLen, c.sampleRate) + const noiseData = noiseBuf.getChannelData(0) + for (let i = 0; i < noiseLen; i++) { + // Modulated noise — gives it that distinctive warbling character + noiseData[i] = (Math.random() * 2 - 1) * Math.sin(i * 0.02) * Math.cos(i * 0.005) + } + const noiseSrc = c.createBufferSource() + noiseSrc.buffer = noiseBuf + const noiseGain = c.createGain() + noiseGain.gain.setValueAtTime(0.12, t + 0.8) + noiseGain.gain.exponentialRampToValueAtTime(0.001, t + 1.4) + const bp = c.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = 1800 + bp.Q.value = 2 + noiseSrc.connect(bp) + bp.connect(noiseGain) + noiseGain.connect(d) + noiseSrc.start(t + 0.8) + noiseSrc.stop(t + 1.4) + + // Phase 4: Final handshake chirps (1.4-dur) + const chirpCount = Math.floor((dur - 1.4) / 0.08) + for (let i = 0; i < chirpCount; i++) { + const f = 1200 + Math.sin(i * 1.7) * 800 + tone(f, 'sine', 0.04, d, t + 1.4 + i * 0.08) + } + + return new Promise(resolve => setTimeout(resolve, durationMs)) +} diff --git a/frontend/src/game/audio/voice.ts b/frontend/src/game/audio/voice.ts index d229092..b0b32fe 100644 --- a/frontend/src/game/audio/voice.ts +++ b/frontend/src/game/audio/voice.ts @@ -378,10 +378,10 @@ const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_ 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)]) } -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)]) } +export function announceDramatic(text: string, cancel = false) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)], cancel) } +export function announceHype(text: string, cancel = false) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], cancel) } +export function announceSilly(text: string, cancel = false) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)], cancel) } +export function announceCool(text: string, cancel = false) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)], cancel) } // === HYPE LINES === @@ -494,8 +494,8 @@ export function announceRoundHype() { const CREATOR_VOICES = ['ancient', 'hal', 'mainframe', 'echo_v', 'final_boss', 'wizard_v', 'deep', 'preacher'] -function announceCreator(text: string) { - speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)]) +function announceCreator(text: string, cancel = false) { + speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)], cancel) } const CREATOR_ENTRANCE_LINES = [ @@ -640,9 +640,9 @@ const CREATOR_DEVASTATING_LINES = [ const CREATOR_ANSWER_VOICES = ['hal', 'mainframe', 'echo_v', 'ancient', 'wizard_v'] -export function announceCreatorEntrance() { +export function announceCreatorEntrance(cancel = false) { const line = CREATOR_ENTRANCE_LINES[Math.floor(Math.random() * CREATOR_ENTRANCE_LINES.length)] - announceCreator(line) + announceCreator(line, cancel) } export function announceCreatorRound() { diff --git a/frontend/src/game/fight/entrances.ts b/frontend/src/game/fight/entrances.ts index cf916e2..fcec22c 100644 --- a/frontend/src/game/fight/entrances.ts +++ b/frontend/src/game/fight/entrances.ts @@ -87,7 +87,6 @@ async function playEntrance() { // Robe overlay const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)] const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(robeColor)), k.opacity(0.85), k.z(11)]) - announceDeepIntro() // Slow walk in await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad) await k.wait(0.3) @@ -109,7 +108,7 @@ async function playEntrance() { const gfX = startX + dir * 40 const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor('#ff69b4')), k.opacity(0.9), k.z(11)]) const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor('#ff0000')), k.z(12)]) - announceSilly('We need to talk! About your ELO!') + announceSilly('We need to talk! About your ELO!', true) // Walk in arguing await k.tween(startX, homeX + dir * 30, 0.6, (v) => { fighter.pos.x = v; gf.pos.x = v + dir * 40; heart.pos.x = v + dir * 40 @@ -322,7 +321,7 @@ async function playEntrance() { fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 // Bouncer arm const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor('#444444')), k.z(12)]) - announceSilly('And stay out! You\'re BANNED from the other fight!') + announceSilly('And stay out! You\'re BANNED from the other fight!', true) await k.wait(0.3) // Throw sfxZoomWhoosh() @@ -381,7 +380,7 @@ async function playEntrance() { const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor('#888888')), k.opacity(0.9), k.z(9)]) const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor('#444')), k.z(9)]) fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1 - announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!') + announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!', true) sfxZoomWhoosh() await k.tween(startX, homeX, 0.5, (v) => { fighter.pos.x = v; cartBody.pos.x = v; cartWheel.pos.x = v + (fromLeft ? 15 : -15) @@ -400,7 +399,7 @@ async function playEntrance() { fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 // Spotlight cone const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor('#ffe14d')), k.opacity(0.08), k.z(1)]) - announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!') + announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!', true) await k.tween(startX, homeX, 1.0, (v) => { fighter.pos.x = v; spot.pos.x = v - 30 }, k.easings.easeInOutQuad) @@ -494,7 +493,7 @@ async function playEntrance() { p.angle = Math.sin(k.time() * 3 + i) * 15 }) } - announceCreatorEntrance() + announceCreatorEntrance(true) rainDrops.forEach(r => { if (r.exists()) r.destroy() }) }