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:
co-authored by
Claude Opus 4.6
parent
ccf4196647
commit
f6eb7d2845
+44
-24
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -540,3 +540,23 @@ export function generateSpriteSheet(
|
||||
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
|
||||
/** Load a sprite sheet data URL into a canvas (async for reliable image decode) */
|
||||
export async function loadSpriteSheetCanvas(
|
||||
seed: string, tier: number, primaryColor: string, secondaryColor: string,
|
||||
archetypeOverride?: string, customization?: SpriteCustomization,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const dataUrl = generateSpriteSheet(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization)
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image()
|
||||
i.onload = () => resolve(i)
|
||||
i.onerror = reject
|
||||
i.src = dataUrl
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = FRAME_SIZE * MAX_FRAMES
|
||||
canvas.height = FRAME_SIZE * TOTAL_ROWS
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
return canvas
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { FRAME_SIZE, ANIMATIONS } from './constants'
|
||||
import { loadSpriteSheetCanvas } from './index'
|
||||
import { getBotColors } from './palette'
|
||||
import type { SpriteCustomization } from './index'
|
||||
|
||||
type PoseKey = keyof typeof ANIMATIONS
|
||||
|
||||
/**
|
||||
* Generate a high-quality poster frame for a single bot.
|
||||
* Takes the normal 96x96 sprite frame and renders it to a large canvas
|
||||
* with pixel-art-aware enhancements: 3D bevel, glow outline, energy aura, sparkles.
|
||||
*/
|
||||
export async function generatePosterFrame(
|
||||
seed: string,
|
||||
tier: number,
|
||||
archetype?: string,
|
||||
pose: PoseKey = 'attack',
|
||||
outputSize = 480,
|
||||
customization?: SpriteCustomization,
|
||||
glowColor: 'cyan' | 'pink' | 'purple' | 'yellow' = 'cyan',
|
||||
): Promise<string> {
|
||||
const colors = getBotColors(seed)
|
||||
|
||||
// 1. Get the sprite sheet as a canvas
|
||||
const sheetCanvas = await loadSpriteSheetCanvas(seed, tier, colors.primary, colors.secondary, archetype, customization)
|
||||
const sheetCtx = sheetCanvas.getContext('2d')!
|
||||
|
||||
// 2. Extract a single mid-animation frame
|
||||
const anim = ANIMATIONS[pose]
|
||||
// Use early frames to avoid arm/leg clipping at frame edges
|
||||
const frameIdx = pose === 'attack' ? 1 : pose === 'kick' ? 1 : Math.min(Math.floor(anim.frames / 2), anim.frames - 1)
|
||||
const srcX = frameIdx * FRAME_SIZE
|
||||
const srcY = anim.row * FRAME_SIZE
|
||||
|
||||
const frameCanvas = document.createElement('canvas')
|
||||
frameCanvas.width = FRAME_SIZE
|
||||
frameCanvas.height = FRAME_SIZE
|
||||
const frameCtx = frameCanvas.getContext('2d')!
|
||||
frameCtx.imageSmoothingEnabled = false
|
||||
frameCtx.drawImage(sheetCanvas, srcX, srcY, FRAME_SIZE, FRAME_SIZE, 0, 0, FRAME_SIZE, FRAME_SIZE)
|
||||
|
||||
// 3. Read pixel data
|
||||
const frameData = frameCtx.getImageData(0, 0, FRAME_SIZE, FRAME_SIZE)
|
||||
const px = frameData.data
|
||||
|
||||
// 4. Create output canvas
|
||||
const out = document.createElement('canvas')
|
||||
out.width = outputSize
|
||||
out.height = outputSize
|
||||
const ctx = out.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
const scale = outputSize / FRAME_SIZE
|
||||
|
||||
// Helpers
|
||||
function getPixel(x: number, y: number): [number, number, number, number] {
|
||||
if (x < 0 || x >= FRAME_SIZE || y < 0 || y >= FRAME_SIZE) return [0, 0, 0, 0]
|
||||
const i = (y * FRAME_SIZE + x) * 4
|
||||
return [px[i], px[i + 1], px[i + 2], px[i + 3]]
|
||||
}
|
||||
|
||||
function isOpaque(x: number, y: number): boolean {
|
||||
return getPixel(x, y)[3] > 20
|
||||
}
|
||||
|
||||
function isDark(x: number, y: number): boolean {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
return a > 20 && r < 30 && g < 30 && b < 30
|
||||
}
|
||||
|
||||
// 5. Build edge map (silhouette border)
|
||||
const edgeMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!isOpaque(x, y)) continue
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (dx === 0 && dy === 0) continue
|
||||
if (!isOpaque(x + dx, y + dy)) { edgeMap[y * FRAME_SIZE + x] = 1; break }
|
||||
}
|
||||
if (edgeMap[y * FRAME_SIZE + x]) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Glow color RGB
|
||||
const GLOW: Record<string, number[]> = {
|
||||
cyan: [0, 240, 255],
|
||||
pink: [255, 45, 120],
|
||||
purple: [168, 85, 247],
|
||||
yellow: [255, 215, 0],
|
||||
}
|
||||
const gc = GLOW[glowColor]
|
||||
|
||||
// 6. Find character bounding box for aura
|
||||
let bMinX = FRAME_SIZE, bMaxX = 0, bMinY = FRAME_SIZE, bMaxY = 0
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (isOpaque(x, y)) {
|
||||
bMinX = Math.min(bMinX, x)
|
||||
bMaxX = Math.max(bMaxX, x)
|
||||
bMinY = Math.min(bMinY, y)
|
||||
bMaxY = Math.max(bMaxY, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
const cx = ((bMinX + bMaxX) / 2) * scale
|
||||
const cy = ((bMinY + bMaxY) / 2) * scale
|
||||
const charR = Math.max(bMaxX - bMinX, bMaxY - bMinY) * scale * 0.65
|
||||
|
||||
// ═══ RENDER PASSES ═══
|
||||
|
||||
// Pass 1: Background energy aura
|
||||
const auraGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR)
|
||||
const auraIntensity = tier >= 3 ? 0.2 : tier >= 1 ? 0.1 : 0.05
|
||||
auraGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity})`)
|
||||
auraGrad.addColorStop(0.4, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity * 0.4})`)
|
||||
auraGrad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = auraGrad
|
||||
ctx.fillRect(0, 0, outputSize, outputSize)
|
||||
|
||||
// Second aura layer (tighter, brighter)
|
||||
if (tier >= 2) {
|
||||
const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR * 0.5)
|
||||
innerGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`)
|
||||
innerGrad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = innerGrad
|
||||
ctx.fillRect(0, 0, outputSize, outputSize)
|
||||
}
|
||||
|
||||
// Pass 2: Outer glow (wide, soft)
|
||||
const outerR = Math.ceil(scale * 1.5)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!edgeMap[y * FRAME_SIZE + x]) continue
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`
|
||||
ctx.fillRect(dx - outerR, dy - outerR, scale + outerR * 2, scale + outerR * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Inner glow (tight, brighter)
|
||||
const innerR = Math.ceil(scale * 0.6)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!edgeMap[y * FRAME_SIZE + x]) continue
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.2)`
|
||||
ctx.fillRect(dx - innerR, dy - innerR, scale + innerR * 2, scale + innerR * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Main pixel render with bevel
|
||||
const bevel = Math.max(1, Math.floor(scale / 5))
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
if (a < 20) continue
|
||||
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
const dark = isDark(x, y)
|
||||
|
||||
// Base pixel
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`
|
||||
ctx.fillRect(dx, dy, scale, scale)
|
||||
|
||||
// 3D bevel on colored pixels (not outlines)
|
||||
if (!dark && scale >= 3) {
|
||||
// Top edge highlight
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.14)'
|
||||
ctx.fillRect(dx, dy, scale, bevel)
|
||||
// Left edge highlight
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.07)'
|
||||
ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2)
|
||||
// Bottom edge shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)'
|
||||
ctx.fillRect(dx, dy + scale - bevel, scale, bevel)
|
||||
// Right edge shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.10)'
|
||||
ctx.fillRect(dx + scale - bevel, dy + bevel, bevel, scale - bevel * 2)
|
||||
}
|
||||
|
||||
// Subtle glow tint on edge pixels
|
||||
if (edgeMap[y * FRAME_SIZE + x] && !dark) {
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.06)`
|
||||
ctx.fillRect(dx, dy, scale, scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 5: Specular highlights on large colored regions
|
||||
for (let y = 1; y < FRAME_SIZE - 1; y++) {
|
||||
for (let x = 1; x < FRAME_SIZE - 1; x++) {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
if (a < 20 || isDark(x, y)) continue
|
||||
// Only add specular if surrounded by similar pixels (interior of a region)
|
||||
if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) {
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
// Tiny specular dot in the upper-left of the pixel
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.fillRect(dx + 1, dy + 1, Math.ceil(scale / 3), Math.ceil(scale / 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 6: Tier-gated energy particles
|
||||
if (tier >= 3) {
|
||||
const count = 4 + tier * 2
|
||||
const seedHash = Array.from(seed).reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const h = Math.abs((seedHash * 1337 + i * 997) | 0)
|
||||
const angle = (h % 360) * Math.PI / 180
|
||||
const dist = charR * 0.3 + (h % 100) / 100 * charR * 0.5
|
||||
const ppx = cx + Math.cos(angle) * dist
|
||||
const ppy = cy + Math.sin(angle) * dist
|
||||
const sz = 1 + (h % 3)
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${0.4 + (h % 3) * 0.15})`
|
||||
ctx.fillRect(ppx, ppy, sz, sz)
|
||||
// Cross sparkle for tier 4+
|
||||
if (tier >= 4) {
|
||||
const sparkLen = 2 + (h % 3)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'
|
||||
ctx.fillRect(ppx - sparkLen, ppy, sparkLen * 2 + sz, 1)
|
||||
ctx.fillRect(ppx + Math.floor(sz / 2), ppy - sparkLen, 1, sparkLen * 2 + sz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out.toDataURL()
|
||||
}
|
||||
Reference in New Issue
Block a user