From 2d8cdcc60a3e9e60f940116df624aceb3f3d84ca Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 10:47:40 +0000 Subject: [PATCH] feat: enhanced poster renderer with 10-pass pixel art pipeline Adds emissive glow bloom, visible pixel grid, cross-hatch background, scan lines, 3D bevel, and tier-gated energy particles to poster sprites. Co-Authored-By: Claude Opus 4.6 --- frontend/src/game/sprites/poster.ts | 252 ++++++++++++++++++++++------ 1 file changed, 201 insertions(+), 51 deletions(-) diff --git a/frontend/src/game/sprites/poster.ts b/frontend/src/game/sprites/poster.ts index 5f4e301..4332506 100644 --- a/frontend/src/game/sprites/poster.ts +++ b/frontend/src/game/sprites/poster.ts @@ -1,4 +1,4 @@ -import { FRAME_SIZE, ANIMATIONS } from './constants' +import { FRAME_SIZE, SCALE, ANIMATIONS } from './constants' import { loadSpriteSheetCanvas } from './index' import { getBotColors } from './palette' import type { SpriteCustomization } from './index' @@ -7,8 +7,9 @@ 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. + * Takes the normal 96x96 sprite frame and upscales it with pixel-art-aware + * enhancements: visible pixel grid, 3D bevel, emissive glow, scan lines, + * cross-hatch background, energy aura, and tier-gated particles. */ export async function generatePosterFrame( seed: string, @@ -23,11 +24,9 @@ export async function generatePosterFrame( // 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 @@ -52,7 +51,8 @@ export async function generatePosterFrame( const scale = outputSize / FRAME_SIZE - // Helpers + // ═══ 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 @@ -68,11 +68,53 @@ export async function generatePosterFrame( return a > 20 && r < 30 && g < 30 && b < 30 } - // 5. Build edge map (silhouette border) + function pixelBrightness(r: number, g: number, b: number): number { + return (r * 0.299 + g * 0.587 + b * 0.114) / 255 + } + + function pixelSaturation(r: number, g: number, b: number): number { + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + if (max === 0) return 0 + return (max - min) / max + } + + // Known emissive colors (eyes, antenna tips, energy effects) + const EMISSIVE_COLORS = new Set([ + '0,255,65', '0,255,0', '0,204,51', // green eyes + '255,255,0', '255,170,0', '255,204,0', // yellow glow + '0,238,255', '68,255,255', '136,255,255', // cyan energy + '255,68,0', '255,102,0', // fire + '255,255,255', // white sparkle + ]) + + function isEmissive(r: number, g: number, b: number, a: number): boolean { + if (a < 20) return false + // Check known glow colors + if (EMISSIVE_COLORS.has(`${r},${g},${b}`)) return true + // High brightness + high saturation = emissive + const bright = pixelBrightness(r, g, b) + const sat = pixelSaturation(r, g, b) + if (bright > 0.7 && sat > 0.6) return true + return false + } + + // ═══ BUILD MAPS ═══ + + // Edge map + emissive map in one pass const edgeMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE) + const emissiveMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE) + + // Get the bot's primary/secondary colors to exclude from emissive detection + const primaryRGB = parseColorToRGB(colors.primary) + const secondaryRGB = parseColorToRGB(colors.secondary) + for (let y = 0; y < FRAME_SIZE; y++) { for (let x = 0; x < FRAME_SIZE; x++) { - if (!isOpaque(x, y)) continue + const [r, g, b, a] = getPixel(x, y) + if (a < 20) continue + + // Edge detection for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue @@ -80,6 +122,15 @@ export async function generatePosterFrame( } if (edgeMap[y * FRAME_SIZE + x]) break } + + // Emissive detection (exclude primary/secondary body colors) + if (isEmissive(r, g, b, a)) { + const distPrimary = colorDistance(r, g, b, primaryRGB) + const distSecondary = colorDistance(r, g, b, secondaryRGB) + if (distPrimary > 80 && distSecondary > 80) { + emissiveMap[y * FRAME_SIZE + x] = 1 + } + } } } @@ -92,7 +143,7 @@ export async function generatePosterFrame( } const gc = GLOW[glowColor] - // 6. Find character bounding box for aura + // Character bounding box 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++) { @@ -108,52 +159,70 @@ export async function generatePosterFrame( const cy = ((bMinY + bMaxY) / 2) * scale const charR = Math.max(bMaxX - bMinX, bMaxY - bMinY) * scale * 0.65 - // ═══ RENDER PASSES ═══ + // ═══════════════════════════════════════ + // RENDER PASSES + // ═══════════════════════════════════════ - // Pass 1: Background energy aura + // Pass 0: Dark background fill + ctx.fillStyle = '#0a0812' + ctx.fillRect(0, 0, outputSize, outputSize) + + // Pass 1: Cross-hatch background pattern + ctx.strokeStyle = 'rgba(255, 255, 255, 0.035)' + ctx.lineWidth = 1 + const hatchSpacing = 24 + // Diagonal lines at 45 degrees + for (let i = -outputSize; i < outputSize * 2; i += hatchSpacing) { + ctx.beginPath() + ctx.moveTo(i, 0) + ctx.lineTo(i + outputSize, outputSize) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(i + outputSize, 0) + ctx.lineTo(i, outputSize) + ctx.stroke() + } + + // Pass 2: Background energy aura + const auraIntensity = tier >= 3 ? 0.25 : tier >= 1 ? 0.14 : 0.07 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) + // Inner aura ring (tier 2+) 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(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.1)`) 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) + // Pass 3: Outer glow (wide, soft) + const outerR = Math.ceil(scale * 2) 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) + ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.10)` + ctx.fillRect(x * scale - outerR, y * scale - outerR, scale + outerR * 2, scale + outerR * 2) } } - // Pass 3: Inner glow (tight, brighter) + // Pass 4: 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) + ctx.fillRect(x * scale - innerR, y * scale - innerR, scale + innerR * 2, scale + innerR * 2) } } - // Pass 4: Main pixel render with bevel - const bevel = Math.max(1, Math.floor(scale / 5)) + // Pass 5: Main pixel render with enhanced bevel + pixel grid + const bevel = Math.max(1, Math.ceil(scale / 3.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) @@ -167,47 +236,101 @@ export async function generatePosterFrame( ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})` ctx.fillRect(dx, dy, scale, scale) - // 3D bevel on colored pixels (not outlines) + // Enhanced 3D bevel on colored pixels (not outlines) if (!dark && scale >= 3) { - // Top edge highlight - ctx.fillStyle = 'rgba(255,255,255,0.14)' + // Top highlight + ctx.fillStyle = 'rgba(255,255,255,0.20)' ctx.fillRect(dx, dy, scale, bevel) - // Left edge highlight - ctx.fillStyle = 'rgba(255,255,255,0.07)' + // Left highlight + ctx.fillStyle = 'rgba(255,255,255,0.10)' ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2) - // Bottom edge shadow - ctx.fillStyle = 'rgba(0,0,0,0.18)' + // Bottom shadow + ctx.fillStyle = 'rgba(0,0,0,0.25)' ctx.fillRect(dx, dy + scale - bevel, scale, bevel) - // Right edge shadow - ctx.fillStyle = 'rgba(0,0,0,0.10)' + // Right shadow + ctx.fillStyle = 'rgba(0,0,0,0.15)' ctx.fillRect(dx + scale - bevel, dy + bevel, bevel, scale - bevel * 2) } - // Subtle glow tint on edge pixels + // 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)) + // Pixel grid lines (only between adjacent opaque pixels) + ctx.fillStyle = 'rgba(0,0,0,0.10)' + if (isOpaque(x + 1, y)) { + ctx.fillRect(dx + scale - 1, dy, 1, scale) // right edge + } + if (isOpaque(x, y + 1)) { + ctx.fillRect(dx, dy + scale - 1, scale, 1) // bottom edge + } + + // Logical pixel grid emphasis (every SCALE=2 source pixels = 1 logical pixel) + if (x % SCALE === SCALE - 1 && isOpaque(x + 1, y)) { + ctx.fillStyle = 'rgba(0,0,0,0.06)' + ctx.fillRect(dx + scale - 1, dy, 1, scale) + } + if (y % SCALE === SCALE - 1 && isOpaque(x, y + 1)) { + ctx.fillStyle = 'rgba(0,0,0,0.06)' + ctx.fillRect(dx, dy + scale - 1, scale, 1) } } } - // Pass 6: Tier-gated energy particles + // Pass 6: Emissive pixel glow (bloom effect) + ctx.globalCompositeOperation = 'lighter' + for (let y = 0; y < FRAME_SIZE; y++) { + for (let x = 0; x < FRAME_SIZE; x++) { + if (!emissiveMap[y * FRAME_SIZE + x]) continue + const [r, g, b] = getPixel(x, y) + const dx = x * scale + scale / 2 + const dy = y * scale + scale / 2 + + // 3-ring bloom outward from pixel center + const rings = [ + { r: scale * 1.2, a: 0.15 }, + { r: scale * 2.2, a: 0.07 }, + { r: scale * 3.5, a: 0.03 }, + ] + for (const ring of rings) { + const grad = ctx.createRadialGradient(dx, dy, 0, dx, dy, ring.r) + grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${ring.a})`) + grad.addColorStop(1, 'rgba(0,0,0,0)') + ctx.fillStyle = grad + ctx.fillRect(dx - ring.r, dy - ring.r, ring.r * 2, ring.r * 2) + } + } + } + ctx.globalCompositeOperation = 'source-over' + + // Pass 7: Specular highlights on interior 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 + if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) { + const specSize = Math.ceil(scale / 2.5) + ctx.fillStyle = 'rgba(255,255,255,0.08)' + ctx.fillRect(x * scale + 1, y * scale + 1, specSize, specSize) + } + } + } + + // Pass 8: Horizontal scan lines + for (let sy = 0; sy < outputSize; sy += 2) { + // Check if this scanline row passes through a dark "screen" region + const srcY = Math.floor(sy / scale) + let isScreenRow = false + for (let sx = bMinX; sx <= bMaxX; sx++) { + if (isDark(sx, srcY)) { isScreenRow = true; break } + } + ctx.fillStyle = isScreenRow ? 'rgba(0,0,0,0.12)' : 'rgba(0,0,0,0.04)' + ctx.fillRect(0, sy, outputSize, 1) + } + + // Pass 9: 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) @@ -232,3 +355,30 @@ export async function generatePosterFrame( return out.toDataURL() } + +// ═══ UTILITY ═══ + +function parseColorToRGB(color: string): [number, number, number] { + const m = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/) + if (!m) return [128, 128, 128] + const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100 + if (s === 0) { const v = Math.round(l * 255); return [v, v, v] } + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + return [ + Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + Math.round(hue2rgb(p, q, h) * 255), + Math.round(hue2rgb(p, q, h - 1 / 3) * 255), + ] +} + +function colorDistance(r: number, g: number, b: number, ref: [number, number, number]): number { + return Math.sqrt((r - ref[0]) ** 2 + (g - ref[1]) ** 2 + (b - ref[2]) ** 2) +}