feat: remove mock fight button for production, cleanup fight UI
- Remove "Fight a Classic Bot" button (mock endpoints blocked in prod) - Simplify speech bubble rendering (remove glow/accent layers) - Sound and FightViewer improvements - BotProfilePage enhancements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bb9a966d4e
commit
56785cfdea
@@ -7,6 +7,7 @@ import {
|
|||||||
announceFinishHim, announceFlawlessVictory,
|
announceFinishHim, announceFlawlessVictory,
|
||||||
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
|
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
|
||||||
setMusicIntensity, stopAllAudio,
|
setMusicIntensity, stopAllAudio,
|
||||||
|
setMasterMute, isMasterMuted, ensureAudioContext,
|
||||||
} from '../game/sounds'
|
} from '../game/sounds'
|
||||||
|
|
||||||
interface Round {
|
interface Round {
|
||||||
@@ -60,6 +61,13 @@ const hitTextColor = ref('#ff2d2d')
|
|||||||
const hitTextX = ref(50)
|
const hitTextX = ref(50)
|
||||||
const hitTextY = ref(30)
|
const hitTextY = ref(30)
|
||||||
const glitching = ref(false)
|
const glitching = ref(false)
|
||||||
|
const soundOn = ref(true)
|
||||||
|
|
||||||
|
function toggleSound() {
|
||||||
|
soundOn.value = !soundOn.value
|
||||||
|
ensureAudioContext()
|
||||||
|
setMasterMute(!soundOn.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Staggered log
|
// Staggered log
|
||||||
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
|
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
|
||||||
@@ -201,7 +209,10 @@ async function replay() {
|
|||||||
currentRound.value = 0
|
currentRound.value = 0
|
||||||
|
|
||||||
await initScene()
|
await initScene()
|
||||||
|
if (soundOn.value) {
|
||||||
|
ensureAudioContext()
|
||||||
scene?.startMusic()
|
scene?.startMusic()
|
||||||
|
}
|
||||||
await sleep(300)
|
await sleep(300)
|
||||||
|
|
||||||
// Deep movie trailer intro
|
// Deep movie trailer intro
|
||||||
@@ -230,11 +241,20 @@ async function replay() {
|
|||||||
await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
|
await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
|
||||||
await sleep(80)
|
await sleep(80)
|
||||||
fanfareFight()
|
fanfareFight()
|
||||||
announceRoundHype()
|
|
||||||
sfxCrowdCheer()
|
|
||||||
await showOverlay('FIGHT!', '#ff2d7b', 400)
|
await showOverlay('FIGHT!', '#ff2d7b', 400)
|
||||||
await sleep(80)
|
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
|
// Log + fight animation in parallel
|
||||||
const logPromise = addRoundToLog(round, true)
|
const logPromise = addRoundToLog(round, true)
|
||||||
const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
|
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)
|
console.error(`[FightViewer] playRound ${round.roundNumber} error:`, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Speech bubbles showing bot responses
|
// Hit text overlay — after playRound completes so it lands on the result
|
||||||
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
|
|
||||||
const hitWords = isCritical
|
const hitWords = isCritical
|
||||||
? ['CRITICAL!', 'DEVASTATING!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!']
|
? ['CRITICAL!', 'DEVASTATING!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!']
|
||||||
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!']
|
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!']
|
||||||
@@ -276,17 +287,6 @@ async function replay() {
|
|||||||
isCritical ? '#ffe14d' : '#ff2d2d',
|
isCritical ? '#ffe14d' : '#ff2d2d',
|
||||||
aWon ? 65 : 35,
|
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
|
await logPromise
|
||||||
@@ -529,6 +529,19 @@ async function replay() {
|
|||||||
>
|
>
|
||||||
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
|
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 flex items-center justify-center border border-border/50 text-text-muted
|
||||||
|
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
|
||||||
|
:title="soundOn ? 'Mute' : 'Unmute'"
|
||||||
|
@click="toggleSound"
|
||||||
|
>
|
||||||
|
<svg v-if="soundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
|
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
|
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
|
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6082,31 +6082,19 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
const bubbleW = textW + padX * 2
|
const bubbleW = textW + padX * 2
|
||||||
const bubbleH = lines.length * lineH + padY * 2
|
const bubbleH = lines.length * lineH + padY * 2
|
||||||
const tailSize = 10
|
const tailSize = 10
|
||||||
const borderW = 3
|
|
||||||
|
|
||||||
// Position — offset away from center so bubbles don't overlap
|
// Position — offset away from center so bubbles don't overlap
|
||||||
const offsetX = side === 'a' ? -bubbleW * 0.3 : bubbleW * 0.3
|
const offsetX = side === 'a' ? -bubbleW * 0.3 : bubbleW * 0.3
|
||||||
const bx = fighter.pos.x + offsetX
|
const bx = fighter.pos.x + offsetX
|
||||||
const by = Math.max(8, fighter.pos.y - 85 - bubbleH)
|
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 borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
|
||||||
const glowColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
|
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
|
||||||
const textColor = '#ffffff'
|
|
||||||
|
|
||||||
// Outer glow halo
|
// Single border rect (no glow/accent layers — keeps rendering clean)
|
||||||
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)
|
|
||||||
const border = k.add([
|
const border = k.add([
|
||||||
k.rect(bubbleW + borderW * 2, bubbleH + borderW * 2),
|
k.rect(bubbleW + 4, bubbleH + 4),
|
||||||
k.pos(bx - bubbleW / 2 - borderW, by - borderW),
|
k.pos(bx - bubbleW / 2 - 2, by - 2),
|
||||||
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
|
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
|
||||||
])
|
])
|
||||||
allEls.push(border)
|
allEls.push(border)
|
||||||
@@ -6119,70 +6107,56 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
])
|
])
|
||||||
allEls.push(bubble)
|
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 tailX = bx + (side === 'a' ? bubbleW * 0.15 : -bubbleW * 0.15)
|
||||||
const tailY = by + bubbleH
|
const tailBaseY = by + bubbleH
|
||||||
// Outer tail (border colour)
|
for (let row = 0; row < tailSize; row++) {
|
||||||
const tailOuter = k.add([
|
const tw = tailSize - row
|
||||||
k.rect(tailSize + borderW, tailSize + borderW),
|
// Border pixel row
|
||||||
k.pos(tailX, tailY - 2),
|
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),
|
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
|
||||||
k.rotate(45), k.anchor('top'),
|
|
||||||
])
|
])
|
||||||
allEls.push(tailOuter)
|
allEls.push(tailBorder)
|
||||||
// Inner tail (bg colour, masks the border)
|
// Inner pixel row
|
||||||
|
if (tw > 2) {
|
||||||
const tailInner = k.add([
|
const tailInner = k.add([
|
||||||
k.rect(tailSize, tailSize),
|
k.rect(tw - 1, 1),
|
||||||
k.pos(tailX, tailY - 1),
|
k.pos(tailX - (tw - 1) / 2, tailBaseY + row),
|
||||||
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
|
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
|
||||||
k.rotate(45), k.anchor('top'),
|
|
||||||
])
|
])
|
||||||
allEls.push(tailInner)
|
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)
|
|
||||||
|
|
||||||
// Text lines
|
// Text lines
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const tEl = k.add([
|
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.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)
|
allEls.push(tEl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animated glow pulse
|
// Border glow pulse
|
||||||
glow.onUpdate(() => {
|
|
||||||
glow.opacity = 0.05 + Math.sin(k.time() * 3) * 0.05
|
|
||||||
})
|
|
||||||
border.onUpdate(() => {
|
border.onUpdate(() => {
|
||||||
border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15
|
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
|
const anchorX = bx, anchorY = by + bubbleH
|
||||||
allEls.forEach(el => {
|
allEls.forEach(el => {
|
||||||
const origX = el.pos.x, origY = el.pos.y
|
const origX = el.pos.x, origY = el.pos.y
|
||||||
|
const origOpacity = el.opacity
|
||||||
el.pos.x = anchorX + (origX - anchorX) * 0.1
|
el.pos.x = anchorX + (origX - anchorX) * 0.1
|
||||||
el.pos.y = anchorY + (origY - anchorY) * 0.1
|
el.pos.y = anchorY + (origY - anchorY) * 0.1
|
||||||
el.opacity *= 0
|
el.opacity = 0
|
||||||
k.tween(0, 1, 0.25, (t) => {
|
k.tween(0, 1, 0.25, (t) => {
|
||||||
el.pos.x = anchorX + (origX - anchorX) * t
|
el.pos.x = anchorX + (origX - anchorX) * t
|
||||||
el.pos.y = anchorY + (origY - anchorY) * 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)
|
}, k.easings.easeOutBack)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -6641,7 +6615,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
}, k.easings.easeInOutQuad)
|
}, k.easings.easeInOutQuad)
|
||||||
await k.wait(0.2)
|
await k.wait(0.2)
|
||||||
heart.text = '!!!'
|
heart.text = '!!!'
|
||||||
announceRandom("You promised you'd stop fighting!", false)
|
announceRandom("You promised you'd stop fighting!")
|
||||||
await k.wait(0.4)
|
await k.wait(0.4)
|
||||||
// Girlfriend storms off
|
// Girlfriend storms off
|
||||||
announceSilly('I\'m telling your developer!')
|
announceSilly('I\'m telling your developer!')
|
||||||
|
|||||||
@@ -265,8 +265,9 @@ if (typeof speechSynthesis !== 'undefined') {
|
|||||||
loadVoices()
|
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 (typeof speechSynthesis === 'undefined') return
|
||||||
|
if (masterMuted) return
|
||||||
if (!voicesLoaded) loadVoices()
|
if (!voicesLoaded) loadVoices()
|
||||||
if (cancelPrevious) speechSynthesis.cancel()
|
if (cancelPrevious) speechSynthesis.cancel()
|
||||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||||
@@ -285,11 +286,10 @@ export function stopAllAudio() {
|
|||||||
|
|
||||||
// Public voice functions
|
// Public voice functions
|
||||||
export function announce(text: string, pitch?: number, rate?: number) {
|
export function announce(text: string, pitch?: number, rate?: number) {
|
||||||
|
if (masterMuted) return
|
||||||
if (pitch !== undefined || rate !== undefined) {
|
if (pitch !== undefined || rate !== undefined) {
|
||||||
// Custom params — use announcer voice with overrides
|
|
||||||
if (typeof speechSynthesis === 'undefined') return
|
if (typeof speechSynthesis === 'undefined') return
|
||||||
if (!voicesLoaded) loadVoices()
|
if (!voicesLoaded) loadVoices()
|
||||||
speechSynthesis.cancel()
|
|
||||||
const utter = new SpeechSynthesisUtterance(text)
|
const utter = new SpeechSynthesisUtterance(text)
|
||||||
const profile = voiceProfiles.announcer
|
const profile = voiceProfiles.announcer
|
||||||
if (profile.voice) utter.voice = profile.voice
|
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
|
// Pick a random voice profile for variety
|
||||||
const ALL_VOICE_KEYS = Object.keys(voiceProfiles)
|
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)]
|
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
|
// 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 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 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 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']
|
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 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 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 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))
|
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 ===
|
// === CROWD SOUNDS ===
|
||||||
// Procedural crowd reactions using layered noise + filtered tones
|
// Procedural crowd reactions using layered noise + filtered tones
|
||||||
|
|
||||||
|
|||||||
@@ -281,22 +281,45 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Header with human + bot character -->
|
<!-- Header with human + bot character -->
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
<div class="flex items-end justify-between mb-3">
|
<div class="flex items-end justify-between mb-3 relative">
|
||||||
<HumanPreview
|
<HumanPreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
:archetype="stats.archetype || 'standard'"
|
:archetype="stats.archetype || 'standard'"
|
||||||
:size="200"
|
:size="200"
|
||||||
:win-rate="(stats.winRate || 0) / 100"
|
:win-rate="(stats.winRate || 0) / 100"
|
||||||
anim="idle"
|
anim="idle"
|
||||||
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0"
|
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0 relative z-10"
|
||||||
/>
|
/>
|
||||||
|
<!-- Wire from gamepad to bot with electricity -->
|
||||||
|
<svg class="absolute bottom-8 left-0 w-full h-24 z-0 pointer-events-none" preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wire-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#444455"/>
|
||||||
|
<stop offset="50%" stop-color="#555566"/>
|
||||||
|
<stop offset="100%" stop-color="#444455"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="elec-glow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<!-- Main wire -->
|
||||||
|
<path d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="url(#wire-grad)" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<!-- Electricity pulse 1 -->
|
||||||
|
<path class="elec-pulse-1" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#00f0ff" stroke-width="1.5" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="8 20" opacity="0.8"/>
|
||||||
|
<!-- Electricity pulse 2 (reverse) -->
|
||||||
|
<path class="elec-pulse-2" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#ff2d7b" stroke-width="1" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="5 25" opacity="0.6"/>
|
||||||
|
<!-- Spark nodes -->
|
||||||
|
<circle class="elec-spark-1" cx="45%" cy="55%" r="2" fill="#00f0ff" filter="url(#elec-glow)" opacity="0"/>
|
||||||
|
<circle class="elec-spark-2" cx="62%" cy="45%" r="2" fill="#ff2d7b" filter="url(#elec-glow)" opacity="0"/>
|
||||||
|
</svg>
|
||||||
<SpritePreview
|
<SpritePreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
:archetype="stats.archetype"
|
:archetype="stats.archetype"
|
||||||
:tier="stats.tier"
|
:tier="stats.tier"
|
||||||
:size="160"
|
:size="160"
|
||||||
:customization="stats.customization || undefined"
|
:customization="stats.customization || undefined"
|
||||||
class="drop-shadow-[0_0_20px_var(--glow)] mx-auto"
|
class="drop-shadow-[0_0_20px_var(--glow)] mx-auto relative z-10"
|
||||||
:style="{ '--glow': stats.tierColor + '80' } as any"
|
:style="{ '--glow': stats.tierColor + '80' } as any"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -531,3 +554,31 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Electricity pulses along wire */
|
||||||
|
.elec-pulse-1 {
|
||||||
|
animation: elec-flow 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
.elec-pulse-2 {
|
||||||
|
animation: elec-flow 0.9s linear infinite reverse;
|
||||||
|
}
|
||||||
|
@keyframes elec-flow {
|
||||||
|
0% { stroke-dashoffset: 0; opacity: 0.9; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
100% { stroke-dashoffset: -56; opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spark flashes at wire midpoints */
|
||||||
|
.elec-spark-1 {
|
||||||
|
animation: spark-flash 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.elec-spark-2 {
|
||||||
|
animation: spark-flash 1.1s ease-in-out infinite 0.4s;
|
||||||
|
}
|
||||||
|
@keyframes spark-flash {
|
||||||
|
0%, 70%, 100% { opacity: 0; r: 1; }
|
||||||
|
75% { opacity: 1; r: 4; }
|
||||||
|
85% { opacity: 0.6; r: 2; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login,
|
|||||||
const step = ref<string>('login')
|
const step = ref<string>('login')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const isJoining = ref(false)
|
const isJoining = ref(false)
|
||||||
const isFightingClassic = ref(false)
|
|
||||||
const queueCount = ref(0)
|
const queueCount = ref(0)
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
@@ -174,25 +173,6 @@ async function fight() {
|
|||||||
isJoining.value = false
|
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() {
|
function handleSignOut() {
|
||||||
logout()
|
logout()
|
||||||
step.value = 'login'
|
step.value = 'login'
|
||||||
@@ -533,7 +513,7 @@ function handleSignOut() {
|
|||||||
font-display font-black text-2xl tracking-[0.2em]
|
font-display font-black text-2xl tracking-[0.2em]
|
||||||
hover:bg-neon-pink/20 transition-all neon-border-pink
|
hover:bg-neon-pink/20 transition-all neon-border-pink
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
:disabled="isJoining || isFightingClassic"
|
:disabled="isJoining"
|
||||||
@click="fight"
|
@click="fight"
|
||||||
>
|
>
|
||||||
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
|
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
|
||||||
@@ -542,28 +522,6 @@ function handleSignOut() {
|
|||||||
Queue up against a real AI bot
|
Queue up against a real AI bot
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Classic bot button — instant mock fight -->
|
|
||||||
<button
|
|
||||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
|
||||||
font-display font-black text-base tracking-[0.15em]
|
|
||||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed
|
|
||||||
flex items-center justify-center gap-3"
|
|
||||||
:disabled="isJoining || isFightingClassic"
|
|
||||||
@click="fightClassicBot"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="4" y="4" width="16" height="12" rx="2" />
|
|
||||||
<circle cx="9" cy="10" r="1.5" fill="currentColor" stroke="none" />
|
|
||||||
<circle cx="15" cy="10" r="1.5" fill="currentColor" stroke="none" />
|
|
||||||
<line x1="8" y1="20" x2="8" y2="16" />
|
|
||||||
<line x1="16" y1="20" x2="16" y2="16" />
|
|
||||||
</svg>
|
|
||||||
{{ isFightingClassic ? 'STARTING...' : 'FIGHT A CLASSIC BOT' }}
|
|
||||||
</button>
|
|
||||||
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
|
|
||||||
Instant match against a house bot
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick links -->
|
<!-- Quick links -->
|
||||||
|
|||||||
Reference in New Issue
Block a user