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
+20
View File
@@ -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
}
+234
View File
@@ -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()
}