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:
Dorian
2026-03-07 11:42:32 +00:00
co-authored by Claude Opus 4.6
parent d7e2ed8b9d
commit c87bff01a8
6 changed files with 819 additions and 202 deletions
+187 -2
View File
@@ -566,6 +566,7 @@ export async function createFightScene(config: FightSceneConfig) {
// Grotesque close-up stubs (disabled — looked bad)
function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
function destroyGrotesqueDetails() {}
// Arena-specific background decoration
function drawArenaDecor() {
const a = arena
@@ -6039,7 +6040,187 @@ export async function createFightScene(config: FightSceneConfig) {
]
// 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
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) {},
showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) {
showSpeechBubble(side, text, duration)
},
async playEntrance() {
const fA = k.get('fighterA')[0]
const fB = k.get('fighterB')[0]
@@ -7926,7 +8111,7 @@ export async function createFightScene(config: FightSceneConfig) {
// PIZZA CUTTER — the big wheel
fatalityTagline = 'SLICED!'
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)])
sfxPowerUp(); winner.play('special')
sfxZoomWhoosh()