feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots

- Fight Card page: dramatic poster background with cross-hatch, spotlights,
  vignettes, corner brackets, scan lines; 3D VS orb with punch animation;
  selectable undercard with main event always pinned at top
- PosterSprite: high-quality 480px poster frame with 6-pass renderer
  (aura, glow, bevel, specular, particles); PixelGlove component
- 12-char bot name limit across all forms and server validation
- Mock bots: all 100 now have diverse archetypes (25 types), 25% human
  fighters; seedMockBots updates existing bots on restart
- Leaderboard: inline SpritePreview next to each bot name
- Nostr auth: persistent login, nsec copy button
- Wallet: NWC + Lightning Address, ranked fight flow
- Server: payments, ranked queue, customization endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 10:33:30 +00:00
co-authored by Claude Opus 4.6
parent ccf4196647
commit f6eb7d2845
32 changed files with 1743 additions and 467 deletions
+44 -24
View File
@@ -5,23 +5,27 @@ let sfxGain: GainNode | null = null
let musicPlaying = false
let musicTimeout: number | null = null
// Lazily create AudioContext + gain nodes on first use.
// Chrome may warn about autoplay policy but the context will resume once ensureAudioContext()
// is called from a user gesture. All SFX calls before that are queued/silent.
// AudioContext is ONLY created inside ensureAudioContext() (called from user gesture).
// Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings.
let _audioUnlocked = false
function initCtx() {
if (ctx) return
ctx = new AudioContext()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : 0.12
musicGain.connect(ctx.destination)
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : 0.25
sfxGain.connect(ctx.destination)
}
function getCtx(): AudioContext {
if (!ctx) {
ctx = new AudioContext()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : 0.12
musicGain.connect(ctx.destination)
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : 0.25
sfxGain.connect(ctx.destination)
if (!ctx) initCtx()
if (ctx!.state === 'suspended') {
ctx!.resume().catch(() => {})
}
if (ctx.state === 'suspended') {
ctx.resume().catch(() => {})
}
return ctx
return ctx!
}
// Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid)
@@ -340,13 +344,21 @@ export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
_speechQueueDepth = 0
// Disconnect sfxGain to instantly kill all in-flight oscillators/buffers,
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
// then reconnect so future sounds still work
if (sfxGain && ctx) {
sfxGain.disconnect()
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : 0.25
sfxGain.connect(ctx.destination)
if (ctx) {
if (musicGain) {
musicGain.disconnect()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : MUSIC_VOL
musicGain.connect(ctx.destination)
}
if (sfxGain) {
sfxGain.disconnect()
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : SFX_VOL
sfxGain.connect(ctx.destination)
}
}
}
@@ -1891,14 +1903,22 @@ export function isMasterMuted(): boolean {
return masterMuted
}
let _speechUnlocked = false
export async function ensureAudioContext() {
const c = getCtx()
if (c.state === 'suspended') {
try { await c.resume() } catch {}
}
// Prime speech synthesis on user gesture — some browsers need this
if (typeof speechSynthesis !== 'undefined' && !voicesLoaded) {
loadVoices()
// Prime speech synthesis on user gesture — mobile browsers require
// a speak() call inside a user gesture to unlock speechSynthesis
if (typeof speechSynthesis !== 'undefined') {
if (!voicesLoaded) loadVoices()
if (!_speechUnlocked) {
_speechUnlocked = true
const unlock = new SpeechSynthesisUtterance('')
unlock.volume = 0
speechSynthesis.speak(unlock)
}
}
}