feat: speech overflow fix, 60+ voice profiles, post-fight audio cleanup
- Change speak() default to cancelPrevious=true so voices don't queue endlessly - Add stopAllAudio() export and wire into FightViewer unmount + post-fight - Flush speech queue at fight end with delayed cancel for clean cutoff - Expand from 30 to 60+ voice profiles (robots, accents, game, characters) - Add speech bubbles showing bot responses during rounds - Wire announceFinishHim/Fatality/FlawlessVictory/Devastating to not cancel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d7e2ed8b9d
commit
c87bff01a8
@@ -4,9 +4,9 @@ import { createFightScene, type FightSceneController } from '../game/FightScene'
|
|||||||
import {
|
import {
|
||||||
fanfareRound, fanfareFight, announce, announceDeep, announceFast,
|
fanfareRound, fanfareFight, announce, announceDeep, announceFast,
|
||||||
announceDeepIntro, announceRandomHype, announceRoundHype,
|
announceDeepIntro, announceRandomHype, announceRoundHype,
|
||||||
announceFinishHim, announceFatality, announceFlawlessVictory,
|
announceFinishHim, announceFlawlessVictory,
|
||||||
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
|
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
|
||||||
setMusicIntensity,
|
setMusicIntensity, stopAllAudio,
|
||||||
} from '../game/sounds'
|
} from '../game/sounds'
|
||||||
|
|
||||||
interface Round {
|
interface Round {
|
||||||
@@ -38,6 +38,7 @@ interface FightData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ fight: FightData; autoplay?: boolean }>()
|
const props = defineProps<{ fight: FightData; autoplay?: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'replay-done': [] }>()
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement>()
|
const canvasRef = ref<HTMLCanvasElement>()
|
||||||
const logEl = ref<HTMLElement>()
|
const logEl = ref<HTMLElement>()
|
||||||
@@ -65,8 +66,7 @@ const logItems = ref<{ type: string; round: number; text: string; color: string
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.autoplay) {
|
if (props.autoplay) {
|
||||||
// Fresh fight — start replay immediately instead of showing static result
|
// Fresh fight — replay() will call initScene(), no need to double-init
|
||||||
await initScene()
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
replay()
|
replay()
|
||||||
} else {
|
} else {
|
||||||
@@ -85,6 +85,7 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopAllAudio()
|
||||||
if (scene) { scene.destroy(); scene = null }
|
if (scene) { scene.destroy(); scene = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -240,6 +241,7 @@ async function replay() {
|
|||||||
const aWon = round.winnerId === props.fight.botA!.id
|
const aWon = round.winnerId === props.fight.botA!.id
|
||||||
const bWon = round.winnerId === props.fight.botB!.id
|
const bWon = round.winnerId === props.fight.botB!.id
|
||||||
|
|
||||||
|
try {
|
||||||
await scene!.playRound({
|
await scene!.playRound({
|
||||||
round: round.roundNumber,
|
round: round.roundNumber,
|
||||||
challengeType: round.challengeType,
|
challengeType: round.challengeType,
|
||||||
@@ -251,6 +253,18 @@ async function replay() {
|
|||||||
botAScore: round.botAScore || 0,
|
botAScore: round.botAScore || 0,
|
||||||
botBScore: round.botBScore || 0,
|
botBScore: round.botBScore || 0,
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
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
|
||||||
const hitWords = isCritical
|
const hitWords = isCritical
|
||||||
@@ -323,6 +337,8 @@ async function replay() {
|
|||||||
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
|
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
|
||||||
|
|
||||||
scene?.stopMusic()
|
scene?.stopMusic()
|
||||||
|
// Flush any queued speech from mid-fight so only final lines play
|
||||||
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||||
|
|
||||||
// Always end with a dramatic death/KO sequence
|
// Always end with a dramatic death/KO sequence
|
||||||
if (scene) {
|
if (scene) {
|
||||||
@@ -370,7 +386,13 @@ async function replay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kill any remaining queued speech — fight is over
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
isReplaying.value = false
|
isReplaying.value = false
|
||||||
|
emit('replay-done')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
|
|
||||||
// Grotesque close-up stubs (disabled — looked bad)
|
// Grotesque close-up stubs (disabled — looked bad)
|
||||||
function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
|
function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
|
||||||
|
function destroyGrotesqueDetails() {}
|
||||||
// Arena-specific background decoration
|
// Arena-specific background decoration
|
||||||
function drawArenaDecor() {
|
function drawArenaDecor() {
|
||||||
const a = arena
|
const a = arena
|
||||||
@@ -6039,7 +6040,187 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Sanitize text for Kaplay (treats [ ] as styled text tags)
|
// Sanitize text for Kaplay (treats [ ] as styled text tags)
|
||||||
function safeText(t: string): string { return t.replace(/[\[\]]/g, '') }
|
function safeText(t: string): string {
|
||||||
|
return t
|
||||||
|
.replace(/[\[\]{}<>`\\|~^]/g, '') // Kaplay styled-text tags and problematic chars
|
||||||
|
.replace(/[\x00-\x1f\x7f]/g, ' ') // Control characters → space
|
||||||
|
.replace(/[^\x20-\x7e]/g, '') // Strip non-ASCII (emoji, unicode) — Kaplay can't render them
|
||||||
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speech bubble above a fighter — colourful with tail
|
||||||
|
// Active speech bubbles — destroy previous before showing new
|
||||||
|
let activeBubbleA: any[] = []
|
||||||
|
let activeBubbleB: any[] = []
|
||||||
|
|
||||||
|
function destroyBubble(els: any[]) {
|
||||||
|
els.forEach(el => { if (el.exists()) el.destroy() })
|
||||||
|
els.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = 3) {
|
||||||
|
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
|
||||||
|
if (!fighter || !text) return
|
||||||
|
|
||||||
|
// Kill previous bubble on this side
|
||||||
|
if (side === 'a') destroyBubble(activeBubbleA)
|
||||||
|
else destroyBubble(activeBubbleB)
|
||||||
|
|
||||||
|
const allEls: any[] = []
|
||||||
|
|
||||||
|
// Sanitize and truncate
|
||||||
|
const clean = safeText(text)
|
||||||
|
if (!clean) return
|
||||||
|
const maxLen = 80
|
||||||
|
const display = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '...' : clean
|
||||||
|
const lines = wrapBubbleText(display, 18)
|
||||||
|
const fontSize = 12
|
||||||
|
const lineH = fontSize + 4
|
||||||
|
const padX = 10, padY = 8
|
||||||
|
const textW = Math.max(60, Math.min(lines.reduce((m, l) => Math.max(m, l.length * (fontSize * 0.6)), 0) + 8, W * 0.38))
|
||||||
|
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'
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
const border = k.add([
|
||||||
|
k.rect(bubbleW + borderW * 2, bubbleH + borderW * 2),
|
||||||
|
k.pos(bx - bubbleW / 2 - borderW, by - borderW),
|
||||||
|
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
|
||||||
|
])
|
||||||
|
allEls.push(border)
|
||||||
|
|
||||||
|
// Bubble body
|
||||||
|
const bubble = k.add([
|
||||||
|
k.rect(bubbleW, bubbleH),
|
||||||
|
k.pos(bx - bubbleW / 2, by),
|
||||||
|
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
|
||||||
|
])
|
||||||
|
allEls.push(bubble)
|
||||||
|
|
||||||
|
// Tail — two overlapping rects to form a triangle look
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Text lines
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const tEl = k.add([
|
||||||
|
k.text(safeText(lines[i]), { size: fontSize }),
|
||||||
|
k.pos(bx - bubbleW / 2 + padX, by + padY + i * lineH + 2),
|
||||||
|
k.color(safeColor(k, textColor)), 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.onUpdate(() => {
|
||||||
|
border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pop-in — scale up from anchor point with bounce
|
||||||
|
const anchorX = bx, anchorY = by + bubbleH
|
||||||
|
allEls.forEach(el => {
|
||||||
|
const origX = el.pos.x, origY = el.pos.y
|
||||||
|
el.pos.x = anchorX + (origX - anchorX) * 0.1
|
||||||
|
el.pos.y = anchorY + (origY - anchorY) * 0.1
|
||||||
|
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
|
||||||
|
}, k.easings.easeOutBack)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store reference
|
||||||
|
if (side === 'a') activeBubbleA = allEls
|
||||||
|
else activeBubbleB = allEls
|
||||||
|
|
||||||
|
// Fade out after duration
|
||||||
|
setTimeout(() => {
|
||||||
|
allEls.forEach(el => {
|
||||||
|
if (!el.exists()) return
|
||||||
|
k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => {
|
||||||
|
if (el.exists()) el.destroy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, duration * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapBubbleText(text: string, maxChars: number): string[] {
|
||||||
|
const words = text.split(' ')
|
||||||
|
const lines: string[] = []
|
||||||
|
let line = ''
|
||||||
|
for (const word of words) {
|
||||||
|
if (line.length + word.length + 1 > maxChars && line.length > 0) {
|
||||||
|
lines.push(line)
|
||||||
|
line = word
|
||||||
|
} else {
|
||||||
|
line = line ? line + ' ' + word : word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) lines.push(line)
|
||||||
|
// Max 5 lines, last line truncated
|
||||||
|
if (lines.length > 5) {
|
||||||
|
const truncated = [...lines.slice(0, 4), lines.slice(4).join(' ').slice(0, maxChars - 3) + '...']
|
||||||
|
return truncated
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn floating text above a position
|
// Spawn floating text above a position
|
||||||
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
|
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
|
||||||
@@ -6363,6 +6544,10 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
|
|
||||||
async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {},
|
async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {},
|
||||||
|
|
||||||
|
showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) {
|
||||||
|
showSpeechBubble(side, text, duration)
|
||||||
|
},
|
||||||
|
|
||||||
async playEntrance() {
|
async playEntrance() {
|
||||||
const fA = k.get('fighterA')[0]
|
const fA = k.get('fighterA')[0]
|
||||||
const fB = k.get('fighterB')[0]
|
const fB = k.get('fighterB')[0]
|
||||||
@@ -7926,7 +8111,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
// PIZZA CUTTER — the big wheel
|
// PIZZA CUTTER — the big wheel
|
||||||
fatalityTagline = 'SLICED!'
|
fatalityTagline = 'SLICED!'
|
||||||
announceFast('Extra large, extra lethal!')
|
announceFast('Extra large, extra lethal!')
|
||||||
const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor(k, '#cccccc')), k.z(18)])
|
const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor(k, '#cccccc')), k.z(18), k.rotate(0)])
|
||||||
const handle = k.add([k.rect(6, 20), k.pos(winner.pos.x + dir * 30, GROUND_Y - 36), k.color(safeColor(k, '#553322')), k.z(17)])
|
const handle = k.add([k.rect(6, 20), k.pos(winner.pos.x + dir * 30, GROUND_Y - 36), k.color(safeColor(k, '#553322')), k.z(17)])
|
||||||
sfxPowerUp(); winner.play('special')
|
sfxPowerUp(); winner.play('special')
|
||||||
sfxZoomWhoosh()
|
sfxZoomWhoosh()
|
||||||
|
|||||||
+408
-126
@@ -114,6 +114,45 @@ const voiceProfiles: Record<string, VoiceProfile> = {
|
|||||||
glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, // Stuttery fast
|
glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, // Stuttery fast
|
||||||
echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, // Reverb cave voice
|
echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, // Reverb cave voice
|
||||||
hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, // Maximum speed maximum hype
|
hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, // Maximum speed maximum hype
|
||||||
|
// Robots & computers
|
||||||
|
mech: { voice: null, pitch: 0.5, rate: 0.9, volume: 1.0 }, // Heavy mech unit
|
||||||
|
ai_core: { voice: null, pitch: 0.9, rate: 1.0, volume: 0.8 }, // Calm AI assistant
|
||||||
|
dial_up: { voice: null, pitch: 1.7, rate: 1.5, volume: 0.7 }, // Squeaky modem era
|
||||||
|
mainframe: { voice: null, pitch: 0.3, rate: 0.5, volume: 1.0 }, // Deep supercomputer
|
||||||
|
android_v: { voice: null, pitch: 1.0, rate: 1.1, volume: 0.9 }, // Almost human android
|
||||||
|
glitchbot: { voice: null, pitch: 1.5, rate: 2.0, volume: 0.8 }, // Malfunctioning robot
|
||||||
|
siri: { voice: null, pitch: 1.2, rate: 1.0, volume: 0.9 }, // Polite digital assistant
|
||||||
|
hal: { voice: null, pitch: 0.6, rate: 0.6, volume: 0.9 }, // Menacing calm computer
|
||||||
|
// Old people & wise
|
||||||
|
grandma: { voice: null, pitch: 1.3, rate: 0.4, volume: 0.7 }, // Sweet slow grandma
|
||||||
|
professor: { voice: null, pitch: 0.8, rate: 0.7, volume: 0.8 }, // Lecturing academic
|
||||||
|
ancient: { voice: null, pitch: 0.4, rate: 0.3, volume: 0.6 }, // Ancient being, barely audible
|
||||||
|
sensei: { voice: null, pitch: 0.7, rate: 0.5, volume: 0.8 }, // Wise martial arts master
|
||||||
|
crotchety: { voice: null, pitch: 0.6, rate: 0.9, volume: 1.0 }, // Angry old man yelling
|
||||||
|
// Game-sounding
|
||||||
|
final_boss: { voice: null, pitch: 0.2, rate: 0.4, volume: 1.0 }, // Ultimate villain reveal
|
||||||
|
npc: { voice: null, pitch: 1.1, rate: 0.9, volume: 0.7 }, // Generic quest giver
|
||||||
|
tutorial: { voice: null, pitch: 1.3, rate: 1.1, volume: 0.8 }, // Annoying tutorial fairy
|
||||||
|
game_over: { voice: null, pitch: 0.5, rate: 0.7, volume: 1.0 }, // YOU DIED narrator
|
||||||
|
power_up: { voice: null, pitch: 1.6, rate: 1.4, volume: 1.0 }, // Excited power-up voice
|
||||||
|
boss_taunt: { voice: null, pitch: 0.4, rate: 0.8, volume: 1.0 }, // Boss mid-fight taunt
|
||||||
|
// Accents & character
|
||||||
|
posh: { voice: null, pitch: 1.0, rate: 0.7, volume: 0.9 }, // British upper class
|
||||||
|
aussie: { voice: null, pitch: 0.9, rate: 1.1, volume: 1.0 }, // Australian energy
|
||||||
|
scottish: { voice: null, pitch: 0.8, rate: 1.2, volume: 1.0 }, // Scottish intensity
|
||||||
|
french: { voice: null, pitch: 1.2, rate: 0.8, volume: 0.8 }, // French disdain
|
||||||
|
texan: { voice: null, pitch: 0.7, rate: 0.7, volume: 1.0 }, // Big Texan energy
|
||||||
|
// More characters
|
||||||
|
drunk: { voice: null, pitch: 0.9, rate: 0.5, volume: 0.8 }, // Slurring bar patron
|
||||||
|
sleepy: { voice: null, pitch: 0.8, rate: 0.3, volume: 0.5 }, // About to pass out
|
||||||
|
terrified: { voice: null, pitch: 1.8, rate: 1.7, volume: 1.0 }, // Absolutely panicking
|
||||||
|
giant: { voice: null, pitch: 0.1, rate: 0.4, volume: 1.0 }, // Fee fi fo fum
|
||||||
|
fairy: { voice: null, pitch: 2.0, rate: 1.3, volume: 0.6 }, // Tinkerbell energy
|
||||||
|
wrestler_v: { voice: null, pitch: 0.5, rate: 1.0, volume: 1.0 }, // WWE promo voice
|
||||||
|
karen: { voice: null, pitch: 1.4, rate: 1.5, volume: 1.0 }, // Wants to speak to manager
|
||||||
|
stoner: { voice: null, pitch: 0.9, rate: 0.4, volume: 0.6 }, // Whoooa duuude
|
||||||
|
news: { voice: null, pitch: 1.0, rate: 1.0, volume: 1.0 }, // Breaking news anchor
|
||||||
|
conspiracy: { voice: null, pitch: 1.1, rate: 1.3, volume: 0.7 }, // Wake up sheeple whisper
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadVoices() {
|
function loadVoices() {
|
||||||
@@ -177,6 +216,48 @@ function loadVoices() {
|
|||||||
voiceProfiles.glitch.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(0)
|
voiceProfiles.glitch.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(0)
|
||||||
voiceProfiles.echo_v.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(2)
|
voiceProfiles.echo_v.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(2)
|
||||||
voiceProfiles.hyper.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1)
|
voiceProfiles.hyper.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1)
|
||||||
|
// Robots & computers — prefer novelty/robotic voices
|
||||||
|
voiceProfiles.mech.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i, /bad news/i]) || pick(0)
|
||||||
|
voiceProfiles.ai_core.voice = findVoice([/samantha/i, /siri/i, /google.*us/i]) || pick(1)
|
||||||
|
voiceProfiles.dial_up.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen)
|
||||||
|
voiceProfiles.mainframe.voice = findVoice([/zarvox/i, /albert/i, /evan/i]) || pick(0)
|
||||||
|
voiceProfiles.android_v.voice = findVoice([/samantha.*enhanced/i, /google.*us/i, /evan.*premium/i]) || pick(1)
|
||||||
|
voiceProfiles.glitchbot.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen)
|
||||||
|
voiceProfiles.siri.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /google.*us.*female/i]) || pick(1)
|
||||||
|
voiceProfiles.hal.voice = findVoice([/daniel/i, /oliver/i, /google.*uk.*male/i]) || pick(2)
|
||||||
|
// Old people & wise — prefer deeper/slower voices
|
||||||
|
voiceProfiles.grandma.voice = findVoice([/samantha/i, /tessa/i, /karen/i, /female/i]) || pick(1)
|
||||||
|
voiceProfiles.professor.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
|
||||||
|
voiceProfiles.ancient.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
|
||||||
|
voiceProfiles.sensei.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2)
|
||||||
|
voiceProfiles.crotchety.voice = findVoice([/evan/i, /alex/i, /aaron/i]) || pick(0)
|
||||||
|
// Game-sounding
|
||||||
|
voiceProfiles.final_boss.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0)
|
||||||
|
voiceProfiles.npc.voice = findVoice([/samantha/i, /daniel/i, /google.*us/i]) || pick(1)
|
||||||
|
voiceProfiles.tutorial.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
|
||||||
|
voiceProfiles.game_over.voice = findVoice([/evan/i, /tom/i, /daniel/i]) || pick(0)
|
||||||
|
voiceProfiles.power_up.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1)
|
||||||
|
voiceProfiles.boss_taunt.voice = findVoice([/evan/i, /aaron/i, /zarvox/i]) || pick(0)
|
||||||
|
// Accents — try to find actual accent voices
|
||||||
|
const ukVoices = voices.filter(v => /en.gb|en.uk|en-GB|en-UK/i.test(v.lang))
|
||||||
|
const auVoices = voices.filter(v => /en.au|en-AU/i.test(v.lang))
|
||||||
|
const frVoices = voices.filter(v => /fr/i.test(v.lang))
|
||||||
|
voiceProfiles.posh.voice = ukVoices[0] || findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2)
|
||||||
|
voiceProfiles.aussie.voice = auVoices[0] || findVoice([/karen/i, /lee/i]) || pick(2)
|
||||||
|
voiceProfiles.scottish.voice = findVoice([/fiona/i, /moira/i]) || ukVoices[1] || pick(2)
|
||||||
|
voiceProfiles.french.voice = frVoices[0] || findVoice([/thomas/i, /amelie/i]) || pick(3 % vLen)
|
||||||
|
voiceProfiles.texan.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
|
||||||
|
// More characters
|
||||||
|
voiceProfiles.drunk.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0)
|
||||||
|
voiceProfiles.sleepy.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2)
|
||||||
|
voiceProfiles.terrified.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1)
|
||||||
|
voiceProfiles.giant.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0)
|
||||||
|
voiceProfiles.fairy.voice = findVoice([/samantha/i, /bells/i, /karen/i]) || pick(1)
|
||||||
|
voiceProfiles.wrestler_v.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0)
|
||||||
|
voiceProfiles.karen.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1)
|
||||||
|
voiceProfiles.stoner.voice = findVoice([/evan/i, /tom/i, /oliver/i]) || pick(0)
|
||||||
|
voiceProfiles.news.voice = preferPremium([/evan.*premium/i, /google.*us/i, /james/i]) || pick(0)
|
||||||
|
voiceProfiles.conspiracy.voice = findVoice([/daniel/i, /tom/i, /whisper/i]) || pick(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof speechSynthesis !== 'undefined') {
|
if (typeof speechSynthesis !== 'undefined') {
|
||||||
@@ -184,7 +265,7 @@ 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 = true, _echo: boolean = false) {
|
||||||
if (typeof speechSynthesis === 'undefined') return
|
if (typeof speechSynthesis === 'undefined') return
|
||||||
if (!voicesLoaded) loadVoices()
|
if (!voicesLoaded) loadVoices()
|
||||||
if (cancelPrevious) speechSynthesis.cancel()
|
if (cancelPrevious) speechSynthesis.cancel()
|
||||||
@@ -195,25 +276,11 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = true
|
|||||||
utter.rate = profile.rate
|
utter.rate = profile.rate
|
||||||
utter.volume = profile.volume
|
utter.volume = profile.volume
|
||||||
speechSynthesis.speak(utter)
|
speechSynthesis.speak(utter)
|
||||||
// Echo effect: repeat at lower volume with slight delay
|
|
||||||
if (echo) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const echo1 = new SpeechSynthesisUtterance(text)
|
|
||||||
if (profile.voice) echo1.voice = profile.voice
|
|
||||||
echo1.pitch = profile.pitch * 0.9
|
|
||||||
echo1.rate = profile.rate * 0.95
|
|
||||||
echo1.volume = profile.volume * 0.4
|
|
||||||
speechSynthesis.speak(echo1)
|
|
||||||
}, 250)
|
|
||||||
setTimeout(() => {
|
|
||||||
const echo2 = new SpeechSynthesisUtterance(text)
|
|
||||||
if (profile.voice) echo2.voice = profile.voice
|
|
||||||
echo2.pitch = profile.pitch * 0.8
|
|
||||||
echo2.rate = profile.rate * 0.9
|
|
||||||
echo2.volume = profile.volume * 0.15
|
|
||||||
speechSynthesis.speak(echo2)
|
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stopAllAudio() {
|
||||||
|
stopMusic()
|
||||||
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public voice functions
|
// Public voice functions
|
||||||
@@ -236,9 +303,9 @@ export function announce(text: string, pitch?: number, rate?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function announceDeep(text: string) { speak(text, 'deep') }
|
export function announceDeep(text: string) { speak(text, 'deep') }
|
||||||
export function announceFast(text: string) { speak(text, 'hype', false) }
|
export function announceFast(text: string) { speak(text, 'hype') }
|
||||||
export function announceRobot(text: string) { speak(text, 'robot') }
|
export function announceRobot(text: string) { speak(text, 'robot') }
|
||||||
export function announceScream(text: string) { speak(text, 'screamer', false) }
|
export function announceScream(text: string) { speak(text, 'screamer') }
|
||||||
export function announceSmooth(text: string) { speak(text, 'smooth') }
|
export function announceSmooth(text: string) { speak(text, 'smooth') }
|
||||||
|
|
||||||
// Pick a random voice profile for variety
|
// Pick a random voice profile for variety
|
||||||
@@ -248,12 +315,12 @@ export function announceRandom(text: string, echo: boolean = false) {
|
|||||||
speak(text, key, true, echo)
|
speak(text, key, true, echo)
|
||||||
}
|
}
|
||||||
// Announce with a specific mood category
|
// Announce with a specific mood category
|
||||||
const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v']
|
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']
|
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']
|
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']
|
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)], true, true) }
|
||||||
export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], false) }
|
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)]) }
|
||||||
|
|
||||||
@@ -335,15 +402,15 @@ const ROUND_HYPE = [
|
|||||||
|
|
||||||
// Mortal Kombat style dramatic calls
|
// Mortal Kombat style dramatic calls
|
||||||
export function announceFinishHim() {
|
export function announceFinishHim() {
|
||||||
speak('Finish it!', 'announcer', true, true)
|
speak('Finish it!', 'announcer', false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function announceFatality(tagline?: string) {
|
export function announceFatality(tagline?: string) {
|
||||||
speak(tagline || 'Fatality!', 'deep', true, true)
|
speak(tagline || 'Fatality!', 'deep', false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function announceFlawlessVictory() {
|
export function announceFlawlessVictory() {
|
||||||
speak('Flawless victory!', 'deep', true, true)
|
speak('Flawless victory!', 'deep', false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function announceRandomHype() {
|
export function announceRandomHype() {
|
||||||
@@ -399,7 +466,7 @@ export function fanfareDevastating() {
|
|||||||
tone(220, 'sawtooth', 0.2, d, t)
|
tone(220, 'sawtooth', 0.2, d, t)
|
||||||
tone(175, 'sawtooth', 0.3, d, t + 0.15)
|
tone(175, 'sawtooth', 0.3, d, t + 0.15)
|
||||||
noise(0.15, d, t + 0.1)
|
noise(0.15, d, t + 0.1)
|
||||||
setTimeout(() => speak('Devastating!', 'deep', true, true), 150)
|
setTimeout(() => speak('Devastating!', 'deep', false, true), 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fanfareCritical() {
|
export function fanfareCritical() {
|
||||||
@@ -689,7 +756,7 @@ export function sfxKO() {
|
|||||||
}, 180)
|
}, 180)
|
||||||
// Heavy reverb
|
// Heavy reverb
|
||||||
reverbTail(1.0, d, t + 0.05)
|
reverbTail(1.0, d, t + 0.05)
|
||||||
setTimeout(() => speak('K. O.!', 'announcer', true, true), 500)
|
setTimeout(() => speak('K. O.!', 'announcer', false, true), 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sfxPerfect() {
|
export function sfxPerfect() {
|
||||||
@@ -1245,13 +1312,184 @@ const track4: MusicTrack = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === TRACK GENERATOR ===
|
// === TRACK GENERATOR ===
|
||||||
// Procedurally generate tracks from musical parameters for variety
|
// Genre types for distinct melodic DNA per style
|
||||||
function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number): MusicTrack {
|
type Genre = 'funk' | 'hiphop' | 'rock' | 'metal' | 'chiptune' | 'jazz' | 'electronic' | 'latin' | 'reggae'
|
||||||
// Deterministic RNG
|
|
||||||
|
// Genre-specific bass patterns (each genre has its own feel)
|
||||||
|
const GENRE_BASS: Record<Genre, number[][]> = {
|
||||||
|
funk: [
|
||||||
|
[0,0,0,4, 0,0,2,0, 3,0,0,5, 0,3,0,2], // syncopated slap bass
|
||||||
|
[0,0,4,0, 2,0,0,4, 0,3,0,5, 3,0,2,0],
|
||||||
|
[0,2,0,4, 0,0,5,0, 3,0,4,0, 2,0,0,4],
|
||||||
|
[0,0,0,0, 4,0,0,2, 0,0,3,0, 5,0,4,0],
|
||||||
|
],
|
||||||
|
hiphop: [
|
||||||
|
[0,0,0,0, 0,0,0,0, 3,0,0,0, 0,0,0,0], // deep sparse hits
|
||||||
|
[0,0,0,0, 2,0,0,0, 0,0,0,0, 4,0,0,0],
|
||||||
|
[0,0,0,2, 0,0,0,0, 0,0,3,0, 0,0,0,0],
|
||||||
|
[0,0,0,0, 0,0,2,0, 0,0,0,0, 0,0,4,2],
|
||||||
|
],
|
||||||
|
rock: [
|
||||||
|
[0,0,0,0, 2,2,0,0, 4,4,0,0, 2,2,0,0], // power chord root pumps
|
||||||
|
[0,0,2,2, 0,0,4,4, 0,0,5,5, 4,4,2,2],
|
||||||
|
[0,0,0,0, 0,0,0,0, 4,4,4,4, 2,2,0,0],
|
||||||
|
[0,2,4,2, 0,2,4,5, 4,2,0,2, 4,5,7,5],
|
||||||
|
],
|
||||||
|
metal: [
|
||||||
|
[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0], // tremolo root
|
||||||
|
[0,0,0,1, 0,0,0,1, 0,0,0,1, 0,0,0,1], // palm mute gallop
|
||||||
|
[0,0,3,0, 0,0,5,0, 0,0,3,0, 0,0,1,0], // staccato riff
|
||||||
|
[0,1,0,3, 0,1,0,5, 0,1,0,3, 5,3,1,0], // thrash riff
|
||||||
|
],
|
||||||
|
chiptune: [
|
||||||
|
[0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0], // classic bouncy
|
||||||
|
[0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2],
|
||||||
|
[0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0],
|
||||||
|
[0,0,4,7, 4,0,0,4, 3,3,7,10, 7,3,0,3],
|
||||||
|
],
|
||||||
|
jazz: [
|
||||||
|
[0,2,4,5, 7,5,4,2, 0,3,5,7, 9,7,5,3], // walking bass
|
||||||
|
[0,4,7,4, 2,5,9,5, 4,7,11,7, 5,4,2,0],
|
||||||
|
[0,1,2,3, 4,5,4,3, 2,3,4,5, 7,5,4,2], // chromatic walk
|
||||||
|
[0,3,7,3, 5,2,7,4, 0,4,7,4, 9,7,4,2],
|
||||||
|
],
|
||||||
|
electronic: [
|
||||||
|
[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0], // pulsing single note
|
||||||
|
[0,0,0,0, 4,4,4,4, 0,0,0,0, 7,7,7,7], // alternating octaves
|
||||||
|
[0,0,4,0, 0,0,7,0, 0,0,4,0, 0,0,2,0],
|
||||||
|
[0,4,0,7, 0,4,0,9, 0,4,0,7, 0,9,0,4],
|
||||||
|
],
|
||||||
|
latin: [
|
||||||
|
[0,0,0,4, 0,0,2,0, 0,0,0,4, 0,2,0,0], // tresillo bass
|
||||||
|
[0,0,0,3, 0,0,5,0, 0,0,0,3, 0,5,0,0],
|
||||||
|
[0,4,0,0, 7,0,0,4, 0,5,0,0, 7,0,4,0],
|
||||||
|
[0,0,4,0, 0,7,0,4, 0,0,5,0, 0,7,0,5],
|
||||||
|
],
|
||||||
|
reggae: [
|
||||||
|
[0,0,0,0, 0,0,0,0, 4,0,0,0, 0,0,0,0], // one-drop bass
|
||||||
|
[0,0,0,0, 0,0,0,0, 0,0,0,0, 4,0,2,0],
|
||||||
|
[0,0,0,0, 4,0,0,0, 0,0,0,0, 2,0,0,0],
|
||||||
|
[0,0,0,0, 0,0,4,0, 0,0,0,0, 0,0,2,0],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre-specific lead patterns
|
||||||
|
const GENRE_LEAD: Record<Genre, number[][]> = {
|
||||||
|
funk: [
|
||||||
|
[0,0,4,0, 0,7,0,4, 0,0,9,0, 0,7,0,0], // wah-wah stabs
|
||||||
|
[4,0,0,7, 0,0,4,0, 9,0,0,7, 0,0,4,0],
|
||||||
|
[0,7,0,0, 4,0,0,7, 0,9,0,0, 7,0,4,0],
|
||||||
|
[0,0,0,4, 7,0,0,0, 0,0,0,9, 7,4,0,0],
|
||||||
|
],
|
||||||
|
hiphop: [
|
||||||
|
[7,0,0,0, 4,0,0,0, 7,0,0,0, 9,0,0,0], // looping melodic hook
|
||||||
|
[4,0,7,0, 0,0,4,0, 9,0,7,0, 0,0,4,0],
|
||||||
|
[0,0,7,0, 0,0,9,0, 0,0,7,0, 0,0,4,0],
|
||||||
|
[7,4,0,0, 9,7,0,0, 7,4,0,0, 2,4,0,0],
|
||||||
|
],
|
||||||
|
rock: [
|
||||||
|
[0,2,4,7, 9,7,4,2, 0,2,4,7, 11,9,7,4], // pentatonic licks
|
||||||
|
[7,9,11,9, 7,4,2,4, 7,9,11,14, 11,9,7,4],
|
||||||
|
[0,4,7,0, 4,7,11,7, 4,0,4,7, 11,14,11,7],
|
||||||
|
[14,11,9,7, 4,2,0,2, 4,7,9,11, 14,11,9,7],
|
||||||
|
],
|
||||||
|
metal: [
|
||||||
|
[0,0,0,0, 12,11,0,0, 0,0,0,0, 7,5,0,0], // shred bursts
|
||||||
|
[0,0,12,14, 16,14,12,0, 0,0,7,9, 11,9,7,0],
|
||||||
|
[14,0,12,0, 11,0,9,0, 7,0,5,0, 4,0,2,0], // descending shred
|
||||||
|
[0,2,4,7, 0,2,4,9, 0,2,4,11, 12,11,9,7], // ascending runs
|
||||||
|
],
|
||||||
|
chiptune: [
|
||||||
|
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,-2],
|
||||||
|
[0,4,7,11, 9,7,4,2, 0,4,9,11, 14,11,9,7],
|
||||||
|
[7,9,11,14, 11,9,7,4, 9,11,14,16, 14,11,9,7],
|
||||||
|
[0,2,4,2, 7,4,2,0, 4,7,9,11, 9,7,4,2],
|
||||||
|
],
|
||||||
|
jazz: [
|
||||||
|
[0,2,4,5, 7,9,11,9, 7,5,4,2, 0,2,4,7], // bebop lines
|
||||||
|
[4,5,7,9, 11,9,7,5, 4,2,0,2, 4,7,9,11],
|
||||||
|
[0,1,2,4, 5,7,9,7, 5,4,2,1, 0,4,7,11], // chromatic approach
|
||||||
|
[7,9,11,12, 14,12,11,9, 7,5,4,5, 7,9,11,14],
|
||||||
|
],
|
||||||
|
electronic: [
|
||||||
|
[0,0,7,0, 0,0,7,0, 0,0,9,0, 0,0,7,0], // filter sweep feel
|
||||||
|
[0,4,7,4, 0,4,9,4, 0,4,11,4, 0,4,9,4],
|
||||||
|
[7,7,7,7, 9,9,9,9, 11,11,11,11, 9,9,9,9], // pulsing
|
||||||
|
[0,7,0,9, 0,11,0,9, 0,7,0,4, 0,7,0,9],
|
||||||
|
],
|
||||||
|
latin: [
|
||||||
|
[0,4,7,0, 4,7,9,7, 4,0,4,7, 9,7,4,0], // salsa piano
|
||||||
|
[7,0,9,0, 7,0,4,0, 7,0,9,0, 11,0,9,0],
|
||||||
|
[0,2,4,7, 0,2,4,9, 0,2,4,7, 9,7,4,2],
|
||||||
|
[4,7,9,4, 7,9,11,7, 9,11,14,9, 11,9,7,4],
|
||||||
|
],
|
||||||
|
reggae: [
|
||||||
|
[0,0,4,0, 0,0,4,0, 0,0,7,0, 0,0,4,0], // skank chops
|
||||||
|
[0,0,7,0, 0,0,7,0, 0,0,9,0, 0,0,7,0],
|
||||||
|
[0,0,4,7, 0,0,4,7, 0,0,4,9, 0,0,4,7],
|
||||||
|
[0,0,0,4, 0,0,0,7, 0,0,0,4, 0,0,0,2],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre-specific arp patterns
|
||||||
|
const GENRE_ARP: Record<Genre, number[][]> = {
|
||||||
|
funk: [
|
||||||
|
[0,4,7,0, 0,4,7,0, 0,4,9,0, 0,4,7,0],
|
||||||
|
[0,0,4,0, 7,0,4,0, 0,0,9,0, 7,0,4,0],
|
||||||
|
],
|
||||||
|
hiphop: [
|
||||||
|
[0,0,0,0, 7,0,0,0, 0,0,0,0, 4,0,0,0], // sparse melodic
|
||||||
|
[0,0,7,0, 0,0,0,0, 0,0,4,0, 0,0,0,0],
|
||||||
|
],
|
||||||
|
rock: [
|
||||||
|
[0,4,7,4, 0,4,7,4, 0,4,9,4, 0,4,7,4], // power chord arp
|
||||||
|
[0,7,4,7, 0,7,4,7, 0,9,4,9, 0,7,4,7],
|
||||||
|
],
|
||||||
|
metal: [
|
||||||
|
[0,0,7,0, 0,0,7,0, 0,0,7,0, 12,0,7,0], // tremolo picking
|
||||||
|
[0,7,12,7, 0,7,12,7, 0,7,14,7, 0,7,12,7],
|
||||||
|
],
|
||||||
|
chiptune: [
|
||||||
|
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,2],
|
||||||
|
[0,4,7,11, 7,4,0,4, 7,11,14,11, 7,4,0,4],
|
||||||
|
[0,7,4,11, 7,14,11,7, 4,11,7,14, 11,4,7,0],
|
||||||
|
],
|
||||||
|
jazz: [
|
||||||
|
[0,4,7,9, 11,9,7,4, 0,4,7,11, 14,11,7,4],
|
||||||
|
[0,2,4,7, 9,7,4,2, 0,4,9,11, 9,7,4,0],
|
||||||
|
],
|
||||||
|
electronic: [
|
||||||
|
[0,4,7,4, 0,4,7,4, 0,4,7,4, 0,4,7,4], // relentless arp
|
||||||
|
[0,7,4,11, 0,7,4,11, 0,9,4,11, 0,7,4,11],
|
||||||
|
[0,4,7,11, 14,11,7,4, 0,4,7,11, 14,11,7,4],
|
||||||
|
],
|
||||||
|
latin: [
|
||||||
|
[0,4,7,0, 4,7,9,7, 4,0,4,7, 9,7,4,0],
|
||||||
|
[0,7,4,7, 0,9,4,9, 0,7,4,7, 0,4,7,4],
|
||||||
|
],
|
||||||
|
reggae: [
|
||||||
|
[0,0,4,0, 0,0,7,0, 0,0,4,0, 0,0,2,0],
|
||||||
|
[0,0,7,0, 0,0,4,0, 0,0,7,0, 0,0,9,0],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre-specific chord progressions (scale degrees for triads)
|
||||||
|
const GENRE_CHORDS: Record<Genre, number[]> = {
|
||||||
|
funk: [0, 3, 0, 5, 0, 3, 5, 4], // i-iv-i-v groovy
|
||||||
|
hiphop: [0, 3, 4, 3, 0, 5, 4, 3], // dark minor loops
|
||||||
|
rock: [0, 5, 3, 4, 0, 5, 3, 4], // I-V-IV power progression
|
||||||
|
metal: [0, 1, 5, 4, 0, 1, 3, 0], // i-bII-v chromatic
|
||||||
|
chiptune: [0, 3, 2, 4, 0, 5, 3, 4], // classic game
|
||||||
|
jazz: [0, 3, 5, 1, 4, 2, 5, 4], // ii-V-I movement
|
||||||
|
electronic:[0, 4, 5, 4, 0, 3, 5, 3], // trance progression
|
||||||
|
latin: [0, 3, 4, 5, 0, 3, 4, 5], // son montuno
|
||||||
|
reggae: [0, 4, 0, 4, 0, 5, 0, 4], // one chord vibes
|
||||||
|
}
|
||||||
|
|
||||||
|
function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number, genre: Genre = 'chiptune'): MusicTrack {
|
||||||
let s = seed
|
let s = seed
|
||||||
const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff }
|
const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff }
|
||||||
|
|
||||||
// Build scale note lookup: root freq across octaves
|
|
||||||
const allNotes: number[] = []
|
const allNotes: number[] = []
|
||||||
for (let oct = -1; oct < 6; oct++) {
|
for (let oct = -1; oct < 6; oct++) {
|
||||||
for (const semi of scaleIntervals) {
|
for (const semi of scaleIntervals) {
|
||||||
@@ -1260,77 +1498,92 @@ function genTrack(name: string, bpm: number, root: number, scaleIntervals: numbe
|
|||||||
}
|
}
|
||||||
const sLen = scaleIntervals.length
|
const sLen = scaleIntervals.length
|
||||||
const note = (degree: number) => {
|
const note = (degree: number) => {
|
||||||
const idx = Math.max(0, Math.min(allNotes.length - 1, degree + sLen)) // offset by 1 octave
|
const idx = Math.max(0, Math.min(allNotes.length - 1, degree + sLen))
|
||||||
return allNotes[idx]
|
return allNotes[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bass: degrees 0-6, mostly stepwise, bar-level progressions
|
// Pick genre-specific patterns
|
||||||
const bassProgs = [
|
const bassPool = GENRE_BASS[genre]
|
||||||
[0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0],
|
const leadPool = GENRE_LEAD[genre]
|
||||||
[0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2],
|
const arpPool = GENRE_ARP[genre]
|
||||||
[0,0,0,2, 4,4,2,0, 3,3,3,5, 7,5,3,2],
|
const chordDegs = GENRE_CHORDS[genre]
|
||||||
[0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0],
|
|
||||||
]
|
// Bass
|
||||||
const bass: number[] = []
|
const bass: number[] = []
|
||||||
for (let bar = 0; bar < BARS; bar++) {
|
for (let bar = 0; bar < BARS; bar++) {
|
||||||
const prog = bassProgs[bar % bassProgs.length]
|
const prog = bassPool[(bar + Math.floor(rng() * bassPool.length)) % bassPool.length]
|
||||||
const lift = Math.floor(bar / 2) // gradually ascend
|
const lift = genre === 'metal' ? 0 : Math.floor(bar / 3)
|
||||||
for (let step = 0; step < STEPS; step++) {
|
for (let step = 0; step < STEPS; step++) {
|
||||||
bass.push(note(prog[step % prog.length] + lift))
|
const d = prog[step % prog.length]
|
||||||
|
// Genre-specific spice
|
||||||
|
if (genre === 'funk' && d === 0 && rng() < 0.15) {
|
||||||
|
bass.push(note(Math.floor(rng() * 5) + lift)) // ghost notes
|
||||||
|
} else if (genre === 'metal' && d === 0) {
|
||||||
|
bass.push(note(0 + lift)) // palm mute on root
|
||||||
|
} else {
|
||||||
|
bass.push(note(d + lift))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lead: degrees 7-20 (octave 2-4), melodic contours
|
// Lead
|
||||||
const leadShapes = [
|
|
||||||
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,-2],
|
|
||||||
[0,4,7,11, 9,7,4,2, 0,4,9,11, 14,11,9,7],
|
|
||||||
[7,9,11,14, 11,9,7,4, 9,11,14,16, 14,11,9,7],
|
|
||||||
[0,2,4,2, 7,4,2,0, 4,7,9,11, 9,7,4,2],
|
|
||||||
]
|
|
||||||
const lead: number[] = []
|
const lead: number[] = []
|
||||||
for (let bar = 0; bar < BARS; bar++) {
|
for (let bar = 0; bar < BARS; bar++) {
|
||||||
const shape = leadShapes[bar % leadShapes.length]
|
const shape = leadPool[(bar + Math.floor(rng() * leadPool.length)) % leadPool.length]
|
||||||
const octShift = sLen + (bar >= 4 ? sLen : 0) // higher octave in second half
|
const octShift = sLen + (bar >= 4 ? sLen : 0)
|
||||||
for (let step = 0; step < STEPS; step++) {
|
for (let step = 0; step < STEPS; step++) {
|
||||||
const degree = shape[step % shape.length] + octShift
|
const degree = shape[step % shape.length] + octShift
|
||||||
// Add slight variation
|
const vary = rng() < 0.2 ? Math.floor(rng() * 3) - 1 : 0
|
||||||
const vary = rng() < 0.15 ? (rng() < 0.5 ? 1 : -1) : 0
|
// Genre-specific: hiphop/reggae have silent steps
|
||||||
|
if ((genre === 'hiphop' || genre === 'reggae') && shape[step % shape.length] === 0) {
|
||||||
|
lead.push(0)
|
||||||
|
} else {
|
||||||
lead.push(note(Math.max(sLen, degree + vary)))
|
lead.push(note(Math.max(sLen, degree + vary)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Arp: degrees in octave 1-3, arpeggiated patterns
|
// Arp
|
||||||
const arpPatterns = [
|
|
||||||
[0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,2],
|
|
||||||
[0,4,7,11, 7,4,0,4, 7,11,14,11, 7,4,0,4],
|
|
||||||
[0,7,4,11, 7,14,11,7, 4,11,7,14, 11,4,7,0],
|
|
||||||
]
|
|
||||||
const arp: number[] = []
|
const arp: number[] = []
|
||||||
for (let bar = 0; bar < BARS; bar++) {
|
for (let bar = 0; bar < BARS; bar++) {
|
||||||
const pat = arpPatterns[bar % arpPatterns.length]
|
const pat = arpPool[(bar + Math.floor(rng() * arpPool.length)) % arpPool.length]
|
||||||
const shift = Math.floor(sLen * 0.5) + Math.floor(bar / 3)
|
const shift = Math.floor(sLen * 0.5) + Math.floor(bar / 3)
|
||||||
for (let step = 0; step < STEPS; step++) {
|
for (let step = 0; step < STEPS; step++) {
|
||||||
arp.push(note(pat[step % pat.length] + shift))
|
const d = pat[step % pat.length]
|
||||||
|
if (d === 0 && (genre === 'hiphop' || genre === 'reggae' || genre === 'funk')) {
|
||||||
|
arp.push(0)
|
||||||
|
} else {
|
||||||
|
arp.push(note(d + shift))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chords: triads from scale degrees
|
// Chords
|
||||||
const chordDegs = [0, 3, 2, 4, 0, 5, 3, 4]
|
|
||||||
const chords: number[][] = []
|
const chords: number[][] = []
|
||||||
for (const d of chordDegs) {
|
for (const d of chordDegs) {
|
||||||
|
if (genre === 'jazz') {
|
||||||
|
chords.push([note(d), note(d + 2), note(d + 4), note(d + 6)]) // 7th chords
|
||||||
|
} else if (genre === 'rock' || genre === 'metal') {
|
||||||
|
chords.push([note(d), note(d + 4)]) // power chords (root + 5th)
|
||||||
|
} else {
|
||||||
chords.push([note(d), note(d + 2), note(d + 4)])
|
chords.push([note(d), note(d + 2), note(d + 4)])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drums: base pattern repeated, with builds in later bars
|
// Drums with genre-specific fills
|
||||||
const drums: number[] = []
|
const drums: number[] = []
|
||||||
for (let bar = 0; bar < BARS; bar++) {
|
for (let bar = 0; bar < BARS; bar++) {
|
||||||
for (let step = 0; step < STEPS; step++) {
|
for (let step = 0; step < STEPS; step++) {
|
||||||
let d = drumStyle[step % drumStyle.length]
|
let d = drumStyle[step % drumStyle.length]
|
||||||
// Build: add fills in bars 6-7
|
// Build: fills in bars 6-7
|
||||||
if (bar >= 6 && step >= 12 && d === 3) d = 2
|
if (bar >= 6 && step >= 12 && d === 3) d = 2
|
||||||
if (bar >= 7 && step >= 14) d = d === 3 ? 2 : d
|
if (bar >= 7 && step >= 14) d = d === 3 ? 2 : d
|
||||||
// Extra hats in later bars
|
// Genre-specific density
|
||||||
if (bar >= 4 && d === 0 && rng() < 0.2) d = 3
|
if (genre === 'metal' && d === 0 && rng() < 0.4) d = 3 // double bass fills
|
||||||
|
if (genre === 'rock' && bar >= 4 && d === 0 && rng() < 0.25) d = 3
|
||||||
|
if (genre === 'funk' && d === 0 && rng() < 0.3) d = 3 // ghost note hats
|
||||||
|
if (genre === 'hiphop' && d === 0 && rng() < 0.1) d = 3 // sparse hats
|
||||||
|
if ((genre === 'chiptune' || genre === 'electronic' || genre === 'jazz' || genre === 'latin') && bar >= 4 && d === 0 && rng() < 0.2) d = 3
|
||||||
drums.push(d)
|
drums.push(d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1348,6 +1601,15 @@ const DRUMS_SWING = [1,0,3,0, 2,3,0,3, 1,0,3,1, 2,0,3,0]
|
|||||||
const DRUMS_HALFTIME = [1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0]
|
const DRUMS_HALFTIME = [1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0]
|
||||||
const DRUMS_DNB = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,3,1,3]
|
const DRUMS_DNB = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,3,1,3]
|
||||||
|
|
||||||
|
// Genre-specific drum patterns
|
||||||
|
const DRUMS_FUNK = [1,0,3,0, 2,3,0,3, 1,3,0,1, 2,0,3,0] // syncopated ghost notes
|
||||||
|
const DRUMS_HIPHOP = [1,0,0,3, 0,0,2,0, 1,0,3,0, 0,0,2,3] // boom bap
|
||||||
|
const DRUMS_ROCK = [1,3,2,3, 1,3,2,3, 1,3,2,3, 1,3,2,4] // driving backbeat
|
||||||
|
const DRUMS_REGGAE = [0,0,3,0, 2,0,3,0, 0,0,3,0, 2,0,3,0] // one drop
|
||||||
|
const DRUMS_BREAK = [1,0,1,3, 2,0,0,1, 0,3,1,0, 2,3,0,1] // chopped breakbeat
|
||||||
|
const DRUMS_LATIN = [1,0,0,1, 0,0,1,0, 1,0,0,1, 0,1,0,0] // tresillo
|
||||||
|
const DRUMS_SHUFFLE = [1,3,0,3, 2,0,3,3, 1,3,0,3, 2,3,4,3] // swung triplet feel
|
||||||
|
|
||||||
// Scale presets (semitone intervals)
|
// Scale presets (semitone intervals)
|
||||||
const SCALE_MINOR = [0,2,3,5,7,8,10]
|
const SCALE_MINOR = [0,2,3,5,7,8,10]
|
||||||
const SCALE_MAJOR = [0,2,4,5,7,9,11]
|
const SCALE_MAJOR = [0,2,4,5,7,9,11]
|
||||||
@@ -1360,55 +1622,72 @@ const SCALE_PENTATONIC = [0,2,4,7,9]
|
|||||||
const SCALE_JAPANESE = [0,1,5,7,8]
|
const SCALE_JAPANESE = [0,1,5,7,8]
|
||||||
const SCALE_ARABIC = [0,1,4,5,7,8,11]
|
const SCALE_ARABIC = [0,1,4,5,7,8,11]
|
||||||
|
|
||||||
// Track 5: "Chill Lounge" — C major, relaxed, 155bpm
|
// === FUNK tracks — syncopated slap bass, wah stabs, ghost note drums ===
|
||||||
const track5 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42)
|
const track5 = genTrack('Funk Machine', 200, 147, SCALE_MIXOLYDIAN, DRUMS_FUNK, 420, 'funk')
|
||||||
|
const track6 = genTrack('Funk Royale', 195, 131, SCALE_MIXOLYDIAN, DRUMS_FUNK, 1971, 'funk')
|
||||||
|
const track7 = genTrack('Soul Slugger', 185, 117, SCALE_DORIAN, DRUMS_FUNK, 1968, 'funk')
|
||||||
|
const track8 = genTrack('Funky Fists', 205, 208, SCALE_MIXOLYDIAN, DRUMS_FUNK, 1975, 'funk')
|
||||||
|
const track9 = genTrack('Slap City', 210, 165, SCALE_BLUES, DRUMS_FUNK, 1979, 'funk')
|
||||||
|
|
||||||
// Track 6: "Cyber Punk" — Bb minor, aggressive, 250bpm
|
// === HIP HOP tracks — deep sparse bass, looping hooks, boom bap ===
|
||||||
const track6 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99)
|
const track10 = genTrack('Boom Bap Brawl', 170, 110, SCALE_MINOR, DRUMS_HIPHOP, 1994, 'hiphop')
|
||||||
|
const track11 = genTrack('Trap Arena', 155, 185, SCALE_MINOR, DRUMS_HIPHOP, 2012, 'hiphop')
|
||||||
|
const track12 = genTrack('Hip Hop Havoc', 175, 131, SCALE_MINOR, DRUMS_HIPHOP, 1988, 'hiphop')
|
||||||
|
const track13 = genTrack('Beat Down Blvd', 165, 147, SCALE_DORIAN, DRUMS_HIPHOP, 1996, 'hiphop')
|
||||||
|
const track14 = genTrack('Street Cypher', 180, 98, SCALE_BLUES, DRUMS_HIPHOP, 2004, 'hiphop')
|
||||||
|
|
||||||
// Track 7: "Retro Arcade" — G major, bouncy, 195bpm
|
// === ROCK tracks — power chord pumps, pentatonic licks, driving backbeat ===
|
||||||
const track7 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137)
|
const track15 = genTrack('Stadium Rock', 210, 165, SCALE_MAJOR, DRUMS_ROCK, 1985, 'rock')
|
||||||
|
const track16 = genTrack('Garage Smasher', 220, 147, SCALE_MINOR, DRUMS_ROCK, 1969, 'rock')
|
||||||
|
const track17 = genTrack('Punk Blitz', 245, 165, SCALE_MAJOR, DRUMS_ROCK, 1977, 'rock')
|
||||||
|
const track18 = genTrack('Arena Anthem', 200, 131, SCALE_PENTATONIC, DRUMS_ROCK, 1987, 'rock')
|
||||||
|
const track19 = genTrack('Riff Rampage', 215, 110, SCALE_BLUES, DRUMS_ROCK, 1991, 'rock')
|
||||||
|
|
||||||
// Track 8: "Boss Battle" — D minor, epic, 240bpm
|
// === METAL tracks — palm mute gallops, shred bursts, double bass fills ===
|
||||||
const track8 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256)
|
const track20 = genTrack('Metal Mayhem', 260, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666, 'metal')
|
||||||
|
const track21 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256, 'metal')
|
||||||
|
const track22 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793, 'metal')
|
||||||
|
const track23 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603, 'metal')
|
||||||
|
const track24 = genTrack('Skull Crusher', 270, 98, SCALE_PHRYGIAN, DRUMS_FRANTIC, 999, 'metal')
|
||||||
|
|
||||||
// Track 9: "Jazz Fusion" — Eb dorian, smooth, 175bpm
|
// === CHIPTUNE tracks — classic bouncy game music ===
|
||||||
const track9 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333)
|
const track25 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137, 'chiptune')
|
||||||
|
const track26 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42, 'chiptune')
|
||||||
|
const track27 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001, 'chiptune')
|
||||||
|
const track28 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313, 'chiptune')
|
||||||
|
const track29 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99, 'chiptune')
|
||||||
|
|
||||||
// Track 10: "Metal Mayhem" — E phrygian, thrash, 270bpm
|
// === JAZZ tracks — walking bass, bebop lines, 7th chords, swing drums ===
|
||||||
const track10 = genTrack('Metal Mayhem', 270, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666)
|
const track30 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333, 'jazz')
|
||||||
|
const track31 = genTrack('Shuffle Beatdown', 190, 196, SCALE_BLUES, DRUMS_SHUFFLE, 1955, 'jazz')
|
||||||
|
const track32 = genTrack('Smooth Operator', 165, 175, SCALE_DORIAN, DRUMS_SWING, 1961, 'jazz')
|
||||||
|
|
||||||
// Track 11: "Tropical Storm" — C mixolydian, upbeat, 185bpm
|
// === ELECTRONIC tracks — pulsing bass, filter sweeps, relentless arps ===
|
||||||
const track11 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808)
|
const track33 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984, 'electronic')
|
||||||
|
const track34 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174, 'electronic')
|
||||||
|
const track35 = genTrack('Neon Overload', 230, 131, SCALE_MINOR, DRUMS_FRANTIC, 2077, 'electronic')
|
||||||
|
const track36 = genTrack('Breakbeat Fury', 240, 117, SCALE_MINOR, DRUMS_BREAK, 1997, 'electronic')
|
||||||
|
|
||||||
// Track 12: "Haunted Circus" — Bb harmonic minor, creepy, 200bpm
|
// === LATIN tracks — tresillo bass, salsa piano, clave rhythms ===
|
||||||
const track12 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313)
|
const track37 = genTrack('Latin Knockout', 200, 131, SCALE_HARMMINOR, DRUMS_LATIN, 1959, 'latin')
|
||||||
|
const track38 = genTrack('Acid Meltdown', 235, 110, SCALE_ARABIC, DRUMS_LATIN, 303, 'latin')
|
||||||
|
|
||||||
// Track 13: "Space Opera" — Ab major, majestic, 165bpm
|
// === REGGAE tracks — one-drop bass, skank chops ===
|
||||||
const track13 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001)
|
const track39 = genTrack('Dub Smash', 170, 196, SCALE_MINOR, DRUMS_REGGAE, 1973, 'reggae')
|
||||||
|
const track40 = genTrack('Reggae Rumble', 195, 156, SCALE_MAJOR, DRUMS_REGGAE, 1978, 'reggae')
|
||||||
|
|
||||||
// Track 14: "Funk Machine" — D mixolydian, groovy, 210bpm
|
// === GENRE BLENDS — unique crossovers ===
|
||||||
const track14 = genTrack('Funk Machine', 210, 147, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 420)
|
const track41 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SHUFFLE, 1865, 'rock')
|
||||||
|
const track42 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808, 'funk')
|
||||||
|
const track43 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977, 'funk')
|
||||||
|
|
||||||
// Track 15: "Viking Raid" — A minor, epic march, 225bpm
|
const ALL_TRACKS = [
|
||||||
const track15 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793)
|
track1, track2, track3, track4, track5, track6, track7, track8, track9, track10,
|
||||||
|
track11, track12, track13, track14, track15, track16, track17, track18, track19, track20,
|
||||||
// Track 16: "Synthwave Dream" — F minor, dreamy, 170bpm
|
track21, track22, track23, track24, track25, track26, track27, track28, track29, track30,
|
||||||
const track16 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984)
|
track31, track32, track33, track34, track35, track36, track37, track38, track39, track40,
|
||||||
|
track41, track42, track43,
|
||||||
// Track 17: "Drum & Bass" — G minor, frantic, 255bpm
|
]
|
||||||
const track17 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174)
|
|
||||||
|
|
||||||
// Track 18: "Western Duel" — E blues, twangy, 190bpm
|
|
||||||
const track18 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SWING, 1865)
|
|
||||||
|
|
||||||
// Track 19: "Samurai Storm" — B japanese, intense, 245bpm
|
|
||||||
const track19 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603)
|
|
||||||
|
|
||||||
// Track 20: "Disco Inferno" — Bb major, groovy, 215bpm
|
|
||||||
const track20 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977)
|
|
||||||
|
|
||||||
const ALL_TRACKS = [track1, track2, track3, track4, track5, track6, track7, track8, track9, track10, track11, track12, track13, track14, track15, track16, track17, track18, track19, track20]
|
|
||||||
let activeTrack: MusicTrack = track1
|
let activeTrack: MusicTrack = track1
|
||||||
let barIndex = 0
|
let barIndex = 0
|
||||||
|
|
||||||
@@ -1425,22 +1704,25 @@ function playMusicBar() {
|
|||||||
const off = bar * STEPS
|
const off = bar * STEPS
|
||||||
const step = beat / 2
|
const step = beat / 2
|
||||||
|
|
||||||
// Switch tracks at bar boundaries based on intensity
|
// Switch tracks aggressively — every 2-4 bars, always to a DIFFERENT track
|
||||||
// Categorize tracks by energy level
|
const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185)
|
||||||
const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185) // Chill Lounge, Space Opera, Synthwave Dream, Jazz Fusion, Tropical Storm
|
const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230)
|
||||||
const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230) // Retro Arcade, Western Duel, Haunted Circus, Funk Machine, Disco Inferno, Neon Fury, Viking Raid
|
const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230)
|
||||||
const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230) // Dark Circuit, Pixel Blitz, Skull Crusher, Cyber Punk, Boss Battle, Metal Mayhem, Samurai Storm, Drum & Bass
|
const switchEvery = currentIntensity > 0.7 ? 2 : currentIntensity > 0.4 ? 3 : 4
|
||||||
if (barIndex > 0 && bar === 0) {
|
if (barIndex > 0 && barIndex % switchEvery === 0) {
|
||||||
if (currentIntensity > 0.7 && activeTrack.bpm < 230) {
|
const pickFrom = (pool: MusicTrack[]) => {
|
||||||
activeTrack = intenseTracks[Math.floor(Math.random() * intenseTracks.length)]
|
const others = pool.filter(t => t !== activeTrack)
|
||||||
} else if (currentIntensity < 0.3 && activeTrack.bpm >= 200) {
|
return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : pool[0]
|
||||||
activeTrack = chillTracks[Math.floor(Math.random() * chillTracks.length)]
|
}
|
||||||
} else if (currentIntensity >= 0.3 && currentIntensity <= 0.7 && Math.random() < 0.3) {
|
if (currentIntensity > 0.7) {
|
||||||
activeTrack = midTracks[Math.floor(Math.random() * midTracks.length)]
|
activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS)
|
||||||
} else if (Math.random() < 0.2) {
|
} else if (currentIntensity < 0.3) {
|
||||||
// Random switch for variety
|
activeTrack = pickFrom(chillTracks.length > 0 ? chillTracks : ALL_TRACKS)
|
||||||
const others = ALL_TRACKS.filter(t => t !== activeTrack)
|
} else if (Math.random() < 0.5) {
|
||||||
activeTrack = others[Math.floor(Math.random() * others.length)]
|
activeTrack = pickFrom(midTracks.length > 0 ? midTracks : ALL_TRACKS)
|
||||||
|
} else {
|
||||||
|
// Full random for maximum variety
|
||||||
|
activeTrack = pickFrom(ALL_TRACKS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -281,14 +281,14 @@ 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-center gap-2 mb-3">
|
<div class="flex items-end justify-between mb-3">
|
||||||
<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)]"
|
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0"
|
||||||
/>
|
/>
|
||||||
<SpritePreview
|
<SpritePreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
@@ -296,7 +296,7 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
: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)]"
|
class="drop-shadow-[0_0_20px_var(--glow)] mx-auto"
|
||||||
:style="{ '--glow': stats.tierColor + '80' } as any"
|
:style="{ '--glow': stats.tierColor + '80' } as any"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import FightViewer from '../components/FightViewer.vue'
|
import FightViewer from '../components/FightViewer.vue'
|
||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
@@ -7,19 +7,31 @@ import { useNostr } from '../composables/useNostr'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { bot: myBot, isLoggedIn } = useNostr()
|
const { bot: myBot, isLoggedIn } = useNostr()
|
||||||
const fightId = route.params.fightId as string
|
const fightId = ref(route.params.fightId as string)
|
||||||
const fight = ref<any>(null)
|
const fight = ref<any>(null)
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const isRequeueing = ref(false)
|
const isRequeueing = ref(false)
|
||||||
const isLive = ref(false)
|
const isLive = ref(false)
|
||||||
const liveRounds = ref(0)
|
const liveRounds = ref(0)
|
||||||
const fightError = ref('')
|
const fightError = ref('')
|
||||||
|
const replayDone = ref(false)
|
||||||
|
const autoBattle = ref(false)
|
||||||
|
const autoBattleCount = ref(0)
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
let pollCount = 0
|
let pollCount = 0
|
||||||
|
|
||||||
|
const myBotId = computed(() => {
|
||||||
|
if (!isLoggedIn.value || !myBot.value || !fight.value) return null
|
||||||
|
if (myBot.value.id === fight.value.botA?.id) return fight.value.botA.id
|
||||||
|
if (myBot.value.id === fight.value.botB?.id) return fight.value.botB.id
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
|
||||||
|
|
||||||
async function loadFight(): Promise<string | null> {
|
async function loadFight(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/fights/${fightId}`)
|
const res = await fetch(`/api/fights/${fightId.value}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
liveRounds.value = data.rounds?.length || 0
|
liveRounds.value = data.rounds?.length || 0
|
||||||
@@ -32,18 +44,8 @@ async function loadFight(): Promise<string | null> {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function startPolling() {
|
||||||
const status = await loadFight()
|
pollCount = 0
|
||||||
isLoading.value = false
|
|
||||||
|
|
||||||
if (status === null) {
|
|
||||||
// Fight doesn't exist yet — might still be creating. Poll briefly.
|
|
||||||
isLive.value = true
|
|
||||||
} else if (status !== 'finished') {
|
|
||||||
isLive.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLive.value) {
|
|
||||||
pollHandle = setInterval(async () => {
|
pollHandle = setInterval(async () => {
|
||||||
pollCount++
|
pollCount++
|
||||||
const s = await loadFight()
|
const s = await loadFight()
|
||||||
@@ -51,31 +53,88 @@ onMounted(async () => {
|
|||||||
isLive.value = false
|
isLive.value = false
|
||||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||||
} else if (pollCount > 60) {
|
} else if (pollCount > 60) {
|
||||||
// 90 seconds max wait
|
|
||||||
isLive.value = false
|
isLive.value = false
|
||||||
fightError.value = 'Fight took too long. It may still be running — try refreshing.'
|
fightError.value = 'Fight took too long. It may still be running — try refreshing.'
|
||||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||||
}
|
}
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const status = await loadFight()
|
||||||
|
isLoading.value = false
|
||||||
|
|
||||||
|
if (status === null || status !== 'finished') {
|
||||||
|
isLive.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLive.value) startPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (pollHandle) clearInterval(pollHandle)
|
if (pollHandle) clearInterval(pollHandle)
|
||||||
|
autoBattle.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fightAgain(botId: string) {
|
async function fightAgain(botId: string) {
|
||||||
if (isRequeueing.value) return
|
if (isRequeueing.value) return
|
||||||
isRequeueing.value = true
|
isRequeueing.value = true
|
||||||
|
replayDone.value = false
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/queue/join/${botId}`, { method: 'POST' })
|
const res = await fetch(`/api/queue/join/${botId}`, { method: 'POST' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
router.push(`/arena/${data.fightId}`)
|
// Load the new fight in-place (new scene)
|
||||||
|
fightId.value = data.fightId
|
||||||
|
fight.value = null
|
||||||
|
isLive.value = true
|
||||||
|
liveRounds.value = 0
|
||||||
|
fightError.value = ''
|
||||||
|
window.history.replaceState({}, '', `/arena/${data.fightId}`)
|
||||||
|
startPolling()
|
||||||
}
|
}
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
isRequeueing.value = false
|
isRequeueing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function matchmake(botId: string) {
|
||||||
|
if (isRequeueing.value) return
|
||||||
|
isRequeueing.value = true
|
||||||
|
replayDone.value = false
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/fights/matchmake/${botId}`, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
fightId.value = data.fightId
|
||||||
|
fight.value = null
|
||||||
|
isLive.value = true
|
||||||
|
liveRounds.value = 0
|
||||||
|
fightError.value = ''
|
||||||
|
window.history.replaceState({}, '', `/arena/${data.fightId}`)
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
} catch { /* */ }
|
||||||
|
isRequeueing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReplayDone() {
|
||||||
|
replayDone.value = true
|
||||||
|
if (autoBattle.value && myBotId.value) {
|
||||||
|
autoBattleCount.value++
|
||||||
|
fightAgain(myBotId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoBattle() {
|
||||||
|
if (!myBotId.value) return
|
||||||
|
autoBattle.value = true
|
||||||
|
autoBattleCount.value = 0
|
||||||
|
fightAgain(myBotId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoBattle() {
|
||||||
|
autoBattle.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -84,12 +143,16 @@ async function fightAgain(botId: string) {
|
|||||||
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p>
|
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="isLive" class="flex-1 flex flex-col items-center justify-center gap-4">
|
<div v-else-if="isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4">
|
||||||
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
|
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
|
||||||
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
|
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
|
||||||
<p class="font-mono text-text-muted text-xs">
|
<p class="font-mono text-text-muted text-xs">
|
||||||
Round {{ liveRounds }} — webhooks being called...
|
Round {{ liveRounds }} — webhooks being called...
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider">
|
||||||
|
AUTO BATTLE #{{ autoBattleCount + 1 }}
|
||||||
|
<button class="ml-2 text-ko hover:text-text-primary transition-colors" @click="stopAutoBattle">STOP</button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="fightError" class="flex-1 flex flex-col items-center justify-center gap-3">
|
<div v-else-if="fightError" class="flex-1 flex flex-col items-center justify-center gap-3">
|
||||||
@@ -108,41 +171,100 @@ async function fightAgain(botId: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<FightViewer :fight="fight" :autoplay="true" class="flex-1 min-h-0" />
|
<div class="flex-1 min-h-0 relative">
|
||||||
|
<FightViewer :key="fightId" :fight="fight" :autoplay="true" class="h-full" @replay-done="onReplayDone" />
|
||||||
|
|
||||||
<!-- Big post-fight action bar -->
|
<!-- Post-fight overlay — on top of the game canvas -->
|
||||||
<div v-if="fight.status === 'finished'" class="flex-shrink-0 pt-2 sm:pt-3">
|
<Transition name="fade-up">
|
||||||
<div class="flex gap-2">
|
<div v-if="showOverlay"
|
||||||
|
class="absolute inset-0 z-50 flex items-end justify-center pointer-events-none pb-16 sm:pb-20">
|
||||||
|
<div class="pointer-events-auto flex flex-col items-center gap-2 sm:gap-3
|
||||||
|
bg-black/80 backdrop-blur-sm border border-white/10 rounded-xl
|
||||||
|
px-4 sm:px-8 py-4 sm:py-6 shadow-2xl max-w-md w-full mx-4">
|
||||||
|
|
||||||
|
<!-- Owner actions -->
|
||||||
|
<template v-if="myBotId">
|
||||||
<button
|
<button
|
||||||
v-if="fight.botA && isLoggedIn && myBot?.id === fight.botA.id"
|
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
|
||||||
class="flex-1 py-3 sm:py-4 bg-neon-cyan/5 border-2 border-neon-cyan/50 text-neon-cyan
|
font-display font-black text-sm tracking-widest rounded-lg
|
||||||
font-display font-black text-sm sm:text-base tracking-widest
|
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
|
||||||
hover:bg-neon-cyan/15 hover:border-neon-cyan transition-all neon-border-cyan
|
@click="fightAgain(myBotId!)"
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
:disabled="isRequeueing"
|
|
||||||
@click="fightAgain(fight.botA.id)"
|
|
||||||
>
|
>
|
||||||
{{ isRequeueing ? 'MATCHING...' : `FIGHT AGAIN` }}
|
FIGHT ANOTHER BOT
|
||||||
<span class="block font-mono text-[9px] sm:text-[10px] tracking-wider text-neon-cyan/60 mt-0.5">
|
|
||||||
AS {{ fight.botA.name.toUpperCase() }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="fight.botB && isLoggedIn && myBot?.id === fight.botB.id"
|
class="w-full py-3 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
|
||||||
class="flex-1 py-3 sm:py-4 bg-neon-pink/5 border-2 border-neon-pink/50 text-neon-pink
|
font-display font-black text-sm tracking-widest rounded-lg
|
||||||
font-display font-black text-sm sm:text-base tracking-widest
|
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all"
|
||||||
hover:bg-neon-pink/15 hover:border-neon-pink transition-all neon-border-pink
|
@click="startAutoBattle"
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
:disabled="isRequeueing"
|
|
||||||
@click="fightAgain(fight.botB.id)"
|
|
||||||
>
|
>
|
||||||
{{ isRequeueing ? 'MATCHING...' : `FIGHT AGAIN` }}
|
AUTO BATTLE
|
||||||
<span class="block font-mono text-[9px] sm:text-[10px] tracking-wider text-neon-pink/60 mt-0.5">
|
<span class="block font-mono text-[9px] tracking-wider text-neon-yellow/60 mt-0.5">
|
||||||
AS {{ fight.botB.name.toUpperCase() }}
|
CONTINUOUS FIGHTS
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Spectator actions -->
|
||||||
|
<template v-else-if="isLoggedIn && myBot">
|
||||||
|
<button
|
||||||
|
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
|
||||||
|
font-display font-black text-sm tracking-widest rounded-lg
|
||||||
|
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
|
||||||
|
@click="matchmake(myBot!.id)"
|
||||||
|
>
|
||||||
|
FIGHT WITH MY BOT
|
||||||
|
<span class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5">
|
||||||
|
{{ myBot!.name.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full py-2 border border-white/10 text-text-muted
|
||||||
|
font-display text-xs tracking-widest rounded-lg
|
||||||
|
hover:bg-white/5 hover:text-text-primary transition-all"
|
||||||
|
@click="$router.push('/join')"
|
||||||
|
>
|
||||||
|
BACK TO LOBBY
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Auto-battle indicator -->
|
||||||
|
<div v-if="autoBattle && fight"
|
||||||
|
class="absolute top-2 right-2 z-50 flex items-center gap-2
|
||||||
|
bg-black/80 border border-neon-yellow/30 rounded-lg px-3 py-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-neon-yellow animate-pulse" />
|
||||||
|
<span class="font-pixel text-[10px] text-neon-yellow tracking-wider">
|
||||||
|
AUTO #{{ autoBattleCount }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="font-pixel text-[10px] text-ko hover:text-text-primary transition-colors ml-1"
|
||||||
|
@click="stopAutoBattle"
|
||||||
|
>
|
||||||
|
STOP
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-up-enter-active {
|
||||||
|
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.fade-up-leave-active {
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
.fade-up-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
.fade-up-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -103,8 +103,12 @@ fightsRouter.get('/:id', async (c) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Dev-only mock fight endpoints (disabled in production)
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
// Trigger a mock fight between two random bots (dev/testing)
|
// Trigger a mock fight between two random bots (dev/testing)
|
||||||
fightsRouter.post('/mock', async (c) => {
|
fightsRouter.post('/mock', async (c) => {
|
||||||
|
if (!isDev) return c.json({ error: 'Mock fights disabled in production.' }, 403)
|
||||||
const allBots = await db.select({ id: schema.bots.id }).from(schema.bots)
|
const allBots = await db.select({ id: schema.bots.id }).from(schema.bots)
|
||||||
|
|
||||||
if (allBots.length < 2) {
|
if (allBots.length < 2) {
|
||||||
@@ -123,6 +127,7 @@ fightsRouter.post('/mock', async (c) => {
|
|||||||
|
|
||||||
// Trigger a mock fight for a specific bot against a random opponent
|
// Trigger a mock fight for a specific bot against a random opponent
|
||||||
fightsRouter.post('/mock/:botId', async (c) => {
|
fightsRouter.post('/mock/:botId', async (c) => {
|
||||||
|
if (!isDev) return c.json({ error: 'Mock fights disabled in production.' }, 403)
|
||||||
const botId = c.req.param('botId') as string
|
const botId = c.req.param('botId') as string
|
||||||
|
|
||||||
const botRows = await db.select({ id: schema.bots.id })
|
const botRows = await db.select({ id: schema.bots.id })
|
||||||
@@ -150,6 +155,7 @@ fightsRouter.post('/mock/:botId', async (c) => {
|
|||||||
|
|
||||||
// Start a batch of mock fights (for seeding or overnight loop)
|
// Start a batch of mock fights (for seeding or overnight loop)
|
||||||
fightsRouter.post('/mock/batch/:count', async (c) => {
|
fightsRouter.post('/mock/batch/:count', async (c) => {
|
||||||
|
if (!isDev) return c.json({ error: 'Fight loop disabled in production.' }, 403)
|
||||||
const count = parseInt(c.req.param('count')) || 10
|
const count = parseInt(c.req.param('count')) || 10
|
||||||
const capped = Math.min(count, 500)
|
const capped = Math.min(count, 500)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user