feat: THE CREATOR god-tier character, bitcoin choreographies, omni-morph, cameos
- Guy Fawkes mask archetype with golden outline, bitcoin chest symbol, laptop, tier-gated effects - 12 custom bitcoin-themed choreographies (8 regular + 4 exclusive ultimates) - Omni-morph system: creator morphs into any of 80+ archetypes instead of cycling 3 - 6% per-round cameo in non-creator fights — drops golden ₿ gifts to fighters - Custom golden code rain entrance with persistent orbiting particles - pickChoreography: 50% ultimate chance (100% on crits), all tier ultimates + 4 exclusive - Server auto-assigns the_creator archetype on login/register for creator pubkey - FightViewer, payments, queue, orchestrator improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8a3480e5ab
commit
6d390f69b2
@@ -104,6 +104,7 @@ const voiceProfiles: Record<string, VoiceProfile> = {
|
||||
robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone
|
||||
screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, // Frantic energy
|
||||
smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, // Natural narrator
|
||||
question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, // Clear, brisk question announcer
|
||||
// 24 new profiles
|
||||
whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper
|
||||
boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming
|
||||
@@ -204,6 +205,12 @@ function loadVoices() {
|
||||
voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0]
|
||||
voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)]
|
||||
voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)]
|
||||
// Question reader: highest quality voice available — tries premium/neural first
|
||||
voiceProfiles.question_reader.voice = preferPremium([
|
||||
/evan.*premium/i, /samantha.*enhanced/i, /daniel.*premium/i,
|
||||
/google.*us.*english/i, /google.*uk.*english/i,
|
||||
/evan/i, /aaron/i, /daniel/i, /james/i,
|
||||
]) || anyVoices[0]
|
||||
// Assign voices to new profiles — spread across available voices for max variety
|
||||
const vLen = anyVoices.length
|
||||
const pick = (i: number) => anyVoices[i % vLen]
|
||||
@@ -340,6 +347,52 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
|
||||
speechSynthesis.speak(utter)
|
||||
}
|
||||
|
||||
/** Like speakAsync but with a forced minimum rate */
|
||||
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
|
||||
if (!voicesLoaded) loadVoices()
|
||||
if (speechSynthesis.paused) speechSynthesis.resume()
|
||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||
const utter = new SpeechSynthesisUtterance(text)
|
||||
if (profile.voice) utter.voice = profile.voice
|
||||
utter.pitch = profile.pitch
|
||||
utter.rate = Math.max(minRate, profile.rate)
|
||||
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
||||
_speechQueueDepth++
|
||||
const safetyTimeout = setTimeout(resolve, 15_000)
|
||||
utter.onend = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
|
||||
utter.onerror = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
|
||||
speechSynthesis.speak(utter)
|
||||
})
|
||||
}
|
||||
|
||||
/** Like speak() but returns a promise that resolves when the utterance finishes */
|
||||
function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
|
||||
if (!voicesLoaded) loadVoices()
|
||||
if (speechSynthesis.paused) speechSynthesis.resume()
|
||||
if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
|
||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||
const utter = new SpeechSynthesisUtterance(text)
|
||||
if (profile.voice) utter.voice = profile.voice
|
||||
utter.pitch = profile.pitch
|
||||
utter.rate = profile.rate
|
||||
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
||||
_speechQueueDepth++
|
||||
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
|
||||
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
|
||||
// Safety timeout — never block forever (max 15s for any utterance)
|
||||
const safetyTimeout = setTimeout(resolve, 15_000)
|
||||
const origOnEnd = utter.onend
|
||||
utter.onend = (ev) => { clearTimeout(safetyTimeout); (origOnEnd as any)(ev) }
|
||||
const origOnError = utter.onerror
|
||||
utter.onerror = (ev) => { clearTimeout(safetyTimeout); (origOnError as any)(ev) }
|
||||
speechSynthesis.speak(utter)
|
||||
})
|
||||
}
|
||||
|
||||
export function stopAllAudio() {
|
||||
stopMusic()
|
||||
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||
@@ -512,6 +565,52 @@ export function announceRoundHype() {
|
||||
voices[Math.floor(Math.random() * voices.length)](line)
|
||||
}
|
||||
|
||||
// === TTS: Questions, Answers & Narration ===
|
||||
// Distinct voice roles so players can always tell who's speaking.
|
||||
// Question = clear smooth reader, Answers = unique per-bot (intelligible subset),
|
||||
// Narration = dramatic judge.
|
||||
|
||||
// Only voices with rate >= 0.6 and rate <= 1.3, and pitch between 0.5-1.5
|
||||
// so every voice is actually understandable by humans
|
||||
const INTELLIGIBLE_VOICES = [
|
||||
'smooth', 'robot', 'drill', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v',
|
||||
'posh', 'aussie', 'scottish', 'french', 'texan', 'wrestler_v', 'ai_core',
|
||||
'android_v', 'siri', 'professor', 'npc', 'news', 'crotchety', 'mech',
|
||||
'wizard_v', 'echo_v', 'boss_taunt', 'angel', 'punk', 'valley',
|
||||
]
|
||||
|
||||
function hashName(name: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
|
||||
return Math.abs(h)
|
||||
}
|
||||
function botVoiceKey(name: string): string {
|
||||
return INTELLIGIBLE_VOICES[hashName(name) % INTELLIGIBLE_VOICES.length]
|
||||
}
|
||||
|
||||
/** Speak the challenge question — premium voice, clear and brisk.
|
||||
* Does NOT cancel previous speech so intro/hype lines finish naturally.
|
||||
* Returns promise that resolves when the question finishes reading. */
|
||||
export function speakQuestion(text: string): Promise<void> {
|
||||
const trimmed = text.length > 140 ? text.slice(0, 137) + '...' : text
|
||||
return speakAsync(trimmed, 'question_reader', false)
|
||||
}
|
||||
|
||||
/** Speak a bot's answer in their assigned character voice at brisk pace.
|
||||
* Returns promise that resolves when finished. */
|
||||
export function speakAnswer(botName: string, answer: string): Promise<void> {
|
||||
const trimmed = answer.length > 80 ? answer.slice(0, 77) + '...' : answer
|
||||
// Use speakAsync with a rate boost: override profile rate to min 1.1
|
||||
return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15)
|
||||
}
|
||||
|
||||
/** Speak the judge's narration — brisk sportscaster energy.
|
||||
* Returns promise that resolves when finished. */
|
||||
export function speakNarration(text: string): Promise<void> {
|
||||
const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text
|
||||
return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
|
||||
}
|
||||
|
||||
// === FANFARES (8-bit melodic announcements) ===
|
||||
|
||||
export function fanfareRound(roundNum: number) {
|
||||
|
||||
Reference in New Issue
Block a user