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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user