feat: production deployment — Dockerfile, docker-compose, SPA serving, classic bot fights

- Add Dockerfile (multi-stage: build frontend + server, serve from single container)
- Add docker-compose.yml for Portainer stack deployment
- Server serves frontend SPA in production (static assets + SPA fallback)
- Auto-run migrations and seed mock bots on server startup
- DB path configurable via DB_PATH env var
- Add "Fight a Classic Bot" button for instant mock bot matches
- FIGHT button queues for real AI opponents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 10:42:18 +00:00
co-authored by Claude Opus 4.6
parent 120250c01a
commit 6bf9fe27b3
17 changed files with 2321 additions and 298 deletions
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -335,11 +335,11 @@ const ROUND_HYPE = [
// Mortal Kombat style dramatic calls
export function announceFinishHim() {
speak('Finish him!', 'announcer', true, true)
speak('Finish it!', 'announcer', true, true)
}
export function announceFatality() {
speak('Fatality!', 'deep', true, true)
export function announceFatality(tagline?: string) {
speak(tagline || 'Fatality!', 'deep', true, true)
}
export function announceFlawlessVictory() {
+866
View File
@@ -0,0 +1,866 @@
// Human sprite — 3x resolution for detailed pixel art of the bot's owner
// Renders at 144x144 internal grid (vs 48x48 for bots) for crisp detail
export const HUMAN_FRAME_SIZE = 288
export const HUMAN_INTERNAL = 144
export const HUMAN_SCALE = HUMAN_FRAME_SIZE / HUMAN_INTERNAL
export const HUMAN_ANIMATIONS = {
idle: { frames: 4, row: 0 },
cheer: { frames: 4, row: 1 },
panic: { frames: 4, row: 2 },
run: { frames: 4, row: 3 },
coach: { frames: 4, row: 4 },
}
export const HUMAN_ROWS = Object.keys(HUMAN_ANIMATIONS).length
export const HUMAN_MAX_FRAMES = 4
// Costume system
type CostumeType = 'animal' | 'armor' | 'hat' | 'tech' | 'monster' | 'food' | 'silly' | 'generic'
interface CostumeConfig {
type: CostumeType
hat?: string
hatShape: 'none' | 'ears' | 'helmet' | 'pointy' | 'wide' | 'tall' | 'horns' | 'antenna' | 'crown' | 'cone'
prop?: string
propShape: 'none' | 'sword' | 'wand' | 'phone' | 'sign' | 'shield' | 'food' | 'remote'
onesie: boolean
facePaint?: string
tail: boolean
}
const COSTUME_MAP: Record<string, Partial<CostumeConfig>> = {
dog: { type: 'animal', hatShape: 'ears', hat: '#aa7744', onesie: true, tail: true },
cat: { type: 'animal', hatShape: 'ears', hat: '#ff8844', onesie: true, tail: true },
frog: { type: 'animal', hatShape: 'ears', hat: '#44aa22', onesie: true },
shark: { type: 'animal', hatShape: 'tall', hat: '#4477aa', onesie: true },
penguin: { type: 'animal', hatShape: 'none', hat: '#222222', onesie: true },
octopus: { type: 'animal', hatShape: 'none', hat: '#cc44aa', onesie: true },
bee: { type: 'animal', hatShape: 'antenna', hat: '#ffcc00', onesie: true },
snail: { type: 'animal', hatShape: 'antenna', hat: '#88aa44', onesie: true },
lobster: { type: 'animal', hatShape: 'antenna', hat: '#cc2222', onesie: true },
sheep: { type: 'animal', hatShape: 'ears', hat: '#eeeeee', onesie: true },
elephant: { type: 'animal', hatShape: 'ears', hat: '#888899', onesie: true },
giraffe: { type: 'animal', hatShape: 'horns', hat: '#ddaa44', onesie: true },
hippo: { type: 'animal', hatShape: 'ears', hat: '#8877aa', onesie: true },
lion: { type: 'animal', hatShape: 'ears', hat: '#cc8822', onesie: true },
monkey: { type: 'animal', hatShape: 'ears', hat: '#884422', onesie: true, tail: true },
parrot: { type: 'animal', hatShape: 'tall', hat: '#ff4422', onesie: true, tail: true },
raccoon: { type: 'animal', hatShape: 'ears', hat: '#666666', onesie: true, tail: true },
snake: { type: 'animal', hatShape: 'none', hat: '#448822', onesie: true, tail: true },
turtle: { type: 'animal', hatShape: 'none', hat: '#44aa44', onesie: true },
whale: { type: 'animal', hatShape: 'none', hat: '#4466bb', onesie: true },
crocodile: { type: 'animal', hatShape: 'none', hat: '#447722', onesie: true, tail: true },
flamingo: { type: 'animal', hatShape: 'tall', hat: '#ff66aa', onesie: true },
hedgehog: { type: 'animal', hatShape: 'tall', hat: '#886644', onesie: true },
panda: { type: 'animal', hatShape: 'ears', hat: '#222222', onesie: true },
hamster: { type: 'animal', hatShape: 'ears', hat: '#ddaa77', onesie: true },
dinosaur: { type: 'animal', hatShape: 'horns', hat: '#44aa44', onesie: true, tail: true },
knight: { type: 'armor', hatShape: 'helmet', hat: '#aaaaaa', propShape: 'sword', prop: '#cccccc' },
gladiator: { type: 'armor', hatShape: 'helmet', hat: '#cc8833', propShape: 'sword', prop: '#dddddd' },
samurai: { type: 'armor', hatShape: 'helmet', hat: '#882222', propShape: 'sword', prop: '#cccccc' },
viking: { type: 'armor', hatShape: 'horns', hat: '#886633', propShape: 'shield', prop: '#aa8844' },
boxer: { type: 'armor', hatShape: 'none', propShape: 'none', facePaint: '#cc2222' },
wrestler: { type: 'armor', hatShape: 'none', propShape: 'none', facePaint: '#ffcc00' },
pirate: { type: 'hat', hatShape: 'wide', hat: '#222222', propShape: 'sword', prop: '#dddddd' },
cowboy: { type: 'hat', hatShape: 'wide', hat: '#aa7744' },
wizard: { type: 'hat', hatShape: 'pointy', hat: '#4422aa', propShape: 'wand', prop: '#ffcc44' },
witch: { type: 'hat', hatShape: 'pointy', hat: '#222222', propShape: 'wand', prop: '#44ff44' },
chef: { type: 'hat', hatShape: 'tall', hat: '#ffffff', propShape: 'food', prop: '#ff8844' },
detective: { type: 'hat', hatShape: 'wide', hat: '#554433' },
nurse: { type: 'hat', hatShape: 'none', hat: '#ffffff' },
firefighter:{ type: 'hat', hatShape: 'helmet', hat: '#ff2222' },
astronaut: { type: 'hat', hatShape: 'helmet', hat: '#eeeeee' },
lumberjack: { type: 'hat', hatShape: 'none', hat: '#cc4422', propShape: 'sword', prop: '#886633' },
scientist: { type: 'hat', hatShape: 'none', propShape: 'remote', prop: '#44cc44' },
clown: { type: 'hat', hatShape: 'tall', hat: '#ff2222', facePaint: '#ffffff' },
robot: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
android: { type: 'tech', hatShape: 'antenna', propShape: 'phone' },
drone_bug: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
toaster: { type: 'tech', hatShape: 'none', propShape: 'remote' },
tv_head: { type: 'tech', hatShape: 'none', propShape: 'phone', facePaint: '#4488ff' },
calculator: { type: 'tech', hatShape: 'none', propShape: 'phone' },
satellite: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
mech: { type: 'tech', hatShape: 'helmet', hat: '#888888', propShape: 'remote' },
led_cube: { type: 'tech', hatShape: 'none', propShape: 'phone' },
circuit: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
antenna_bug:{ type: 'tech', hatShape: 'antenna', propShape: 'phone' },
microwave: { type: 'tech', hatShape: 'none', propShape: 'remote' },
cyberdog: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
robocat: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
ufo_bot: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
cyborg: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
vampire: { type: 'monster', hatShape: 'none', facePaint: '#ffffff' },
werewolf: { type: 'monster', hatShape: 'ears', hat: '#664422', facePaint: '#664422' },
zombie: { type: 'monster', hatShape: 'none', facePaint: '#668844' },
demon: { type: 'monster', hatShape: 'horns', hat: '#cc2222', facePaint: '#cc2222' },
skeleton: { type: 'monster', hatShape: 'none', facePaint: '#ffffff' },
ghost: { type: 'monster', hatShape: 'none', onesie: true, hat: '#ffffff' },
alien: { type: 'monster', hatShape: 'antenna', hat: '#44ff44', facePaint: '#44ff44' },
minotaur: { type: 'monster', hatShape: 'horns', hat: '#884422' },
unicorn: { type: 'monster', hatShape: 'horns', hat: '#ff88cc' },
phoenix: { type: 'monster', hatShape: 'tall', hat: '#ff4400', onesie: true },
dragon: { type: 'monster', hatShape: 'horns', hat: '#44aa22', onesie: true, tail: true },
mermaid: { type: 'monster', hatShape: 'none', hat: '#44ccaa', onesie: true },
griffin: { type: 'monster', hatShape: 'ears', hat: '#cc8822', onesie: true },
cyclops: { type: 'monster', hatShape: 'none', facePaint: '#8877aa' },
gargoyle: { type: 'monster', hatShape: 'horns', hat: '#666666', facePaint: '#888888' },
golem: { type: 'monster', hatShape: 'none', facePaint: '#886644' },
pizza: { type: 'food', hatShape: 'cone', hat: '#ffcc44' },
mushroom: { type: 'food', hatShape: 'tall', hat: '#cc2222' },
cactus: { type: 'food', hatShape: 'tall', hat: '#44aa22' },
sock_puppet: { type: 'silly', hatShape: 'none', onesie: true, hat: '#ff8844' },
traffic_cone: { type: 'silly', hatShape: 'cone', hat: '#ff6600' },
toilet_man: { type: 'silly', hatShape: 'none', propShape: 'sign', prop: '#ffffff' },
potato: { type: 'silly', hatShape: 'none', hat: '#aa8844', onesie: true },
cloud_man: { type: 'silly', hatShape: 'none', hat: '#ffffff', onesie: true },
rock_man: { type: 'silly', hatShape: 'none', hat: '#888888', onesie: true },
balloon_man: { type: 'silly', hatShape: 'none', propShape: 'sign', prop: '#ff4488' },
trash_can: { type: 'silly', hatShape: 'none', hat: '#888888', onesie: true },
rubber_duck: { type: 'silly', hatShape: 'none', hat: '#ffcc00', onesie: true },
snowman: { type: 'silly', hatShape: 'tall', hat: '#222222', onesie: true },
scarecrow: { type: 'silly', hatShape: 'wide', hat: '#ddbb66', onesie: true },
jack_o_lantern:{ type: 'silly', hatShape: 'none', hat: '#ff8800', facePaint: '#ff8800' },
garden_gnome: { type: 'silly', hatShape: 'pointy', hat: '#ff0000' },
lamp_post: { type: 'silly', hatShape: 'cone', hat: '#ffee88' },
broom_man: { type: 'silly', hatShape: 'none', propShape: 'sword', prop: '#886633' },
ninja: { type: 'hat', hatShape: 'none', facePaint: '#222222', onesie: true, hat: '#222222' },
}
const SKIN_TONES = [
'#ffdbb4', '#f1c27d', '#e0ac69', '#c68642', '#8d5524', '#6b3a1f',
'#ffe0bd', '#ffcd94', '#eac086', '#deb887', '#d2a679', '#a0785a',
]
const HAIR_COLORS = [
'#2c1b0e', '#4a3728', '#8b6914', '#c4a35a', '#e6c87f',
'#1a1a1a', '#333333', '#cc2222', '#ff6633', '#ff88cc', '#4488ff',
]
type PxFn = (x: number, y: number, color: string, ox: number, oy: number) => void
function seededRng(seed: string) {
let sh = 0
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
sh = (sh * 31337) | 0
return () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 }
}
/**
* Generate human sprite at 3x resolution. winRate (0-1) controls physique:
* 0.0 = fat slobby mess with cheetos, 0.5 = normal, 1.0 = swole superhero with cape
*/
export function generateHumanSpriteSheet(
seed: string,
archetype: string,
primaryColor: string,
secondaryColor: string,
winRate: number = 0.5,
): string {
const canvas = document.createElement('canvas')
canvas.width = HUMAN_FRAME_SIZE * HUMAN_MAX_FRAMES
canvas.height = HUMAN_FRAME_SIZE * HUMAN_ROWS
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
const I = HUMAN_INTERNAL
const S = HUMAN_SCALE
const rng = seededRng(seed)
const skinTone = SKIN_TONES[Math.floor(rng() * SKIN_TONES.length)]
const skinMid = darken(skinTone, 12)
const skinDark = darken(skinTone, 30)
const skinLight = lighten(skinTone, 10)
const hairColor = HAIR_COLORS[Math.floor(rng() * HAIR_COLORS.length)]
const hairDark = darken(hairColor, 20)
const shirtColor = primaryColor
const shirtDark = darken(primaryColor, 15)
const pantsColor = secondaryColor
const pantsDark = darken(secondaryColor, 15)
const wr = Math.max(0, Math.min(1, winRate))
const isFat = wr < 0.3 ? true : wr < 0.5 ? rng() > 0.4 : false
const isShort = wr < 0.25
const isSwole = wr > 0.75
const isSuperHero = wr > 0.85
const hasBeard = wr < 0.35 ? true : rng() > 0.6
const hasGlasses = wr < 0.4 ? true : rng() > 0.5
const hasStains = wr < 0.3
const hasCheetos = wr < 0.2
const hasCape = isSuperHero
const hasHeadband = wr > 0.7
const hasSweatDrops = wr < 0.4
const costume = getCostume(archetype)
const out = '#0a0a0a'
function px(x: number, y: number, color: string, ox: number, oy: number) {
if (x < 0 || x >= I || y < 0 || y >= I) return
ctx.fillStyle = color
ctx.fillRect(ox + x * S, oy + y * S, S, S)
}
function fill(x: number, y: number, w: number, h: number, color: string, ox: number, oy: number) {
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, color, ox, oy)
}
function box(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) {
for (let i = x - 1; i <= x + w; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) }
for (let i = y; i < y + h; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) }
fill(x, y, w, h, fc, ox, oy)
}
// Rounded box for softer shapes
function roundBox(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) {
fill(x + 1, y, w - 2, h, fc, ox, oy)
fill(x, y + 1, w, h - 2, fc, ox, oy)
// Outline
for (let i = x + 1; i < x + w - 1; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) }
for (let i = y + 1; i < y + h - 1; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) }
px(x, y, out, ox, oy); px(x + w - 1, y, out, ox, oy)
px(x, y + h - 1, out, ox, oy); px(x + w - 1, y + h - 1, out, ox, oy)
}
function drawHuman(fx: number, fy: number, pose: string, frame: number, total: number) {
const ox = fx * HUMAN_FRAME_SIZE
const oy = fy * HUMAN_FRAME_SIZE
const t = frame / Math.max(1, total - 1)
const bounce = Math.round(Math.sin(t * Math.PI * 2) * 2)
const isIdle = pose === 'idle'
const isCheer = pose === 'cheer'
const isPanic = pose === 'panic'
const isRun = pose === 'run'
const isCoach = pose === 'coach'
const cx = 72 // center x
const ground = 126 // ground line
// Body dimensions (3x base, adjusted by physique)
const bodyW = isFat ? 42 : isSwole ? 36 : 27
const bodyH = isShort ? 24 : isSwole ? 33 : 30
const headW = isFat ? 30 : isSwole ? 28 : 27
const headH = isFat ? 27 : 24
const legH = isShort ? 18 : isSwole ? 30 : 27
const legW = isFat ? 14 : isSwole ? 12 : 9
const armW = isSwole ? 9 : 6
const armH = isShort ? 15 : isSwole ? 24 : 21
const neckH = 3
const feetY = ground - 4
const legsTop = feetY - legH
const bodyTop = legsTop - bodyH
const neckTop = bodyTop - neckH
const headTop = neckTop - headH
// Pose offsets
const cheerJump = isCheer ? Math.round(Math.abs(Math.sin(t * Math.PI)) * 18) : 0
const panicShake = isPanic ? Math.round(Math.sin(t * Math.PI * 6) * 4) : 0
const runBob = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 4) : 0
const yOff = -(cheerJump + runBob) + (isIdle ? bounce : 0)
const xOff = panicShake
// Shadow
const shadowW = isFat ? 22 : isSwole ? 18 : 14
for (let sx = cx - shadowW; sx <= cx + shadowW; sx++) {
px(sx, ground, 'rgba(0,0,0,0.15)', ox, oy)
px(sx, ground + 1, 'rgba(0,0,0,0.08)', ox, oy)
}
// ===== CAPE (drawn behind everything) =====
if (hasCape) {
const capeX = cx - Math.floor(bodyW / 2) - 4 + xOff
const capeW2 = bodyW + 8
for (let iy = bodyTop + yOff; iy < feetY + yOff + 12; iy++) {
const flutter = isRun ? Math.round(Math.sin(t * Math.PI * 3 + iy * 0.15) * 4) : Math.round(Math.sin(iy * 0.1) * 1)
for (let ix = capeX - flutter; ix < capeX + capeW2 + flutter; ix++) {
const shade = (iy + ix) % 5 === 0 ? '#881111' : (iy + ix) % 3 === 0 ? '#aa1818' : '#cc2222'
px(ix, iy, shade, ox, oy)
}
}
// Cape clasp at neck
fill(cx - 3 + xOff, bodyTop + yOff - 2, 6, 3, '#ffd700', ox, oy)
}
// ===== LEGS =====
const legGap = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 8) : isPanic ? 6 : 3
const ll = cx - legGap - Math.floor(legW / 2) + xOff
const rl = cx + legGap - Math.floor(legW / 2) + xOff
const legColor = costume.onesie ? (costume.hat || shirtColor) : pantsColor
const legColorDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark
// Left leg
roundBox(ll, legsTop + yOff, legW, legH, legColor, ox, oy)
fill(ll + legW - 2, legsTop + yOff + 2, 2, legH - 4, legColorDk, ox, oy) // shading
// Knee highlight
px(ll + 2, legsTop + Math.floor(legH / 2) + yOff, lighten(legColor, 10), ox, oy)
// Right leg
roundBox(rl, legsTop + yOff, legW, legH, legColor, ox, oy)
fill(rl + legW - 2, legsTop + yOff + 2, 2, legH - 4, legColorDk, ox, oy)
px(rl + 2, legsTop + Math.floor(legH / 2) + yOff, lighten(legColor, 10), ox, oy)
// Socks visible (between pants and shoes)
if (!costume.onesie) {
fill(ll, feetY + yOff - 3, legW, 3, '#ffffff', ox, oy)
fill(rl, feetY + yOff - 3, legW, 3, '#ffffff', ox, oy)
}
// Shoes (chunky)
roundBox(ll - 2, feetY + yOff, legW + 5, 5, '#333333', ox, oy)
px(ll + legW + 2, feetY + yOff + 1, '#555555', ox, oy) // shoe highlight
fill(ll - 1, feetY + yOff + 4, legW + 4, 1, '#222222', ox, oy) // sole
roundBox(rl - 2, feetY + yOff, legW + 5, 5, '#333333', ox, oy)
px(rl + legW + 2, feetY + yOff + 1, '#555555', ox, oy)
fill(rl - 1, feetY + yOff + 4, legW + 4, 1, '#222222', ox, oy)
// Superhero boots
if (isSuperHero) {
fill(ll - 2, feetY + yOff - 6, legW + 5, 6, '#cc2222', ox, oy)
fill(rl - 2, feetY + yOff - 6, legW + 5, 6, '#cc2222', ox, oy)
fill(ll, feetY + yOff - 7, legW + 1, 1, '#ffd700', ox, oy) // gold trim
fill(rl, feetY + yOff - 7, legW + 1, 1, '#ffd700', ox, oy)
}
// ===== BODY =====
const bx = cx - Math.floor(bodyW / 2) + xOff
const by = bodyTop + yOff
const torsoColor = costume.onesie ? (costume.hat || shirtColor) : shirtColor
const torsoDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : shirtDark
roundBox(bx, by, bodyW, bodyH, torsoColor, ox, oy)
// Body shading (right side darker)
for (let iy = by + 2; iy < by + bodyH - 2; iy++) {
px(bx + bodyW - 2, iy, torsoDk, ox, oy)
px(bx + bodyW - 3, iy, torsoDk, ox, oy)
px(bx + 1, iy, lighten(torsoColor, 8), ox, oy) // left highlight
}
// Zipper for onesies
if (costume.onesie) {
for (let iy = by + 2; iy < by + bodyH - 2; iy++) {
px(cx + xOff, iy, darken(torsoColor, 25), ox, oy)
px(cx + 1 + xOff, iy, darken(torsoColor, 20), ox, oy)
}
} else {
// Collar
fill(bx + 3, by, bodyW - 6, 3, darken(shirtColor, 10), ox, oy)
fill(bx + Math.floor(bodyW / 2) - 2, by, 4, 4, skinTone, ox, oy) // V-neck
// Shirt pattern (bigger at 3x)
const patCx = cx + xOff
const patCy = by + Math.floor(bodyH / 2) + 2
// Draw a big emblem
fill(patCx - 4, patCy - 4, 8, 8, darken(torsoColor, 8), ox, oy)
fill(patCx - 3, patCy - 3, 6, 6, secondaryColor, ox, oy)
fill(patCx - 2, patCy - 2, 4, 4, lighten(secondaryColor, 15), ox, oy)
px(patCx, patCy, '#ffffff', ox, oy)
}
// Belt
fill(bx, by + bodyH - 3, bodyW, 3, '#443322', ox, oy)
fill(cx - 2 + xOff, by + bodyH - 3, 5, 3, '#ffcc00', ox, oy) // buckle
px(cx + xOff, by + bodyH - 2, '#ffffff', ox, oy) // buckle highlight
// Food stains (losers)
if (hasStains) {
fill(bx + 4, by + 6, 3, 2, '#cc8822', ox, oy)
fill(bx + bodyW - 8, by + 10, 2, 3, '#cc2222', ox, oy)
fill(bx + 10, by + 4, 2, 2, '#ff8844', ox, oy)
px(bx + bodyW - 5, by + 8, '#884400', ox, oy)
}
// Belly hanging over belt
if (isFat && wr < 0.25) {
fill(bx + 4, by + bodyH, bodyW - 8, 4, skinTone, ox, oy)
fill(bx + 5, by + bodyH + 3, bodyW - 10, 2, skinDark, ox, oy)
}
// ===== NECK =====
fill(cx - 4 + xOff, neckTop + yOff, 8, neckH, skinTone, ox, oy)
px(cx + 3 + xOff, neckTop + yOff + 1, skinDark, ox, oy)
// ===== ARMS =====
const armAttach = by + 3
const armLx = bx - armW - 2
const armRx = bx + bodyW + 2
const armSkin = costume.onesie ? (costume.hat || shirtColor) : skinTone
const armSkinDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : skinDark
if (isCheer) {
const armUpH = Math.round(Math.abs(Math.sin(t * Math.PI)) * 12) + 6
// Arms reaching up
roundBox(armLx, armAttach - armUpH, armW, armUpH, armSkin, ox, oy)
roundBox(armRx, armAttach - armUpH, armW, armUpH, armSkin, ox, oy)
// Hands (open, fingers spread)
fill(armLx - 2, armAttach - armUpH - 5, armW + 3, 4, skinTone, ox, oy)
fill(armRx - 1, armAttach - armUpH - 5, armW + 3, 4, skinTone, ox, oy)
// Individual fingers
for (let f = 0; f < 4; f++) {
px(armLx - 2 + f * 2, armAttach - armUpH - 6, skinTone, ox, oy)
px(armRx - 1 + f * 2, armAttach - armUpH - 6, skinTone, ox, oy)
}
} else if (isPanic) {
const flapL = Math.round(Math.sin(t * Math.PI * 4) * 12)
const flapR = Math.round(Math.cos(t * Math.PI * 4) * 12)
roundBox(armLx + flapL, armAttach - 8, armW, armH, armSkin, ox, oy)
roundBox(armRx - flapR, armAttach - 10, armW, armH, armSkin, ox, oy)
// Blurred hands (motion)
fill(armLx + flapL - 1, armAttach - 10, armW + 2, 3, skinTone, ox, oy)
fill(armRx - flapR - 1, armAttach - 12, armW + 2, 3, skinTone, ox, oy)
} else if (isCoach) {
const pointExt = Math.round(Math.sin(t * Math.PI) * 8)
// Pointing arm
roundBox(armRx, armAttach, armW + 8 + pointExt, armW, armSkin, ox, oy)
// Pointing finger
fill(armRx + armW + 8 + pointExt, armAttach, 4, armW - 2, skinTone, ox, oy)
px(armRx + armW + 12 + pointExt, armAttach + 1, skinTone, ox, oy)
// Other arm on hip
roundBox(armLx, armAttach + 4, armW, 8, armSkin, ox, oy)
fill(armLx + 2, armAttach + 11, armW - 2, 3, skinTone, ox, oy) // hand on hip
} else if (isRun) {
const pump = Math.round(Math.sin(t * Math.PI * 2) * 8)
roundBox(armLx, armAttach + pump, armW, armH - 6, armSkin, ox, oy)
roundBox(armRx, armAttach - pump, armW, armH - 6, armSkin, ox, oy)
// Fists
fill(armLx, armAttach + pump + armH - 8, armW + 1, 3, skinTone, ox, oy)
fill(armRx, armAttach - pump + armH - 8, armW + 1, 3, skinTone, ox, oy)
} else {
// Idle: arms hanging
roundBox(armLx, armAttach, armW, armH, armSkin, ox, oy)
roundBox(armRx, armAttach, armW, armH, armSkin, ox, oy)
// Arm shading
for (let iy = armAttach + 2; iy < armAttach + armH - 2; iy++) {
px(armLx + armW - 1, iy, armSkinDk, ox, oy)
px(armRx + armW - 1, iy, armSkinDk, ox, oy)
}
// Hands
fill(armLx, armAttach + armH - 4, armW + 1, 4, skinTone, ox, oy)
fill(armRx - 1, armAttach + armH - 4, armW + 1, 4, skinTone, ox, oy)
// Fingers (3 visible)
for (let f = 0; f < 3; f++) {
px(armLx + f * 2, armAttach + armH, skinTone, ox, oy)
px(armRx - 1 + f * 2, armAttach + armH, skinTone, ox, oy)
}
// Phone or cheetos in right hand
if (hasCheetos) {
roundBox(armRx - 1, armAttach + armH + 1, 8, 12, '#ff8800', ox, oy)
fill(armRx, armAttach + armH + 2, 6, 3, '#ffcc00', ox, oy) // label
fill(armRx + 1, armAttach + armH + 6, 4, 2, '#ffaa00', ox, oy) // crumbs
// Cheese dust on fingers
px(armRx, armAttach + armH - 1, '#ff8844', ox, oy)
px(armRx + 2, armAttach + armH - 1, '#ff8844', ox, oy)
} else {
// Gamepad controller
const gpX = armRx - 3
const gpY = armAttach + armH - 2
roundBox(gpX, gpY, 14, 8, '#333344', ox, oy) // body
fill(gpX + 1, gpY + 1, 12, 6, '#2a2a3a', ox, oy) // inset
// Grips
roundBox(gpX - 2, gpY + 2, 3, 6, '#333344', ox, oy)
roundBox(gpX + 13, gpY + 2, 3, 6, '#333344', ox, oy)
// D-pad (left side)
fill(gpX + 2, gpY + 3, 3, 1, '#555566', ox, oy)
fill(gpX + 3, gpY + 2, 1, 3, '#555566', ox, oy)
// Buttons (right side)
px(gpX + 9, gpY + 2, '#ff4444', ox, oy)
px(gpX + 11, gpY + 2, '#4488ff', ox, oy)
px(gpX + 10, gpY + 1, '#ffcc00', ox, oy)
px(gpX + 10, gpY + 3, '#44cc44', ox, oy)
// Wire coming out the top
const wireX = gpX + 7
for (let w = 0; w < 14; w++) {
const wobble = Math.round(Math.sin(w * 0.6 + t * Math.PI * 2) * 2)
px(wireX + wobble, gpY - 1 - w, '#444444', ox, oy)
}
}
}
// ===== PROP (costume-specific, bigger) =====
if (costume.propShape !== 'none' && costume.prop && !isCheer && !isPanic) {
const propX = armRx + armW + 3
const propY = armAttach + 3
if (costume.propShape === 'sword') {
// Blade
for (let i = 0; i < 22; i++) { px(propX + i, propY - i, costume.prop, ox, oy); px(propX + i, propY - i - 1, lighten(costume.prop, 15), ox, oy) }
// Guard
fill(propX - 1, propY + 1, 5, 2, '#886633', ox, oy)
fill(propX - 2, propY + 3, 2, 6, '#664422', ox, oy) // handle
px(propX - 1, propY + 8, '#ffcc44', ox, oy) // pommel
} else if (costume.propShape === 'wand') {
for (let i = 0; i < 16; i++) px(propX, propY - i, '#886633', ox, oy)
// Sparkle at tip
const sparkle = ['#ffcc44', '#ffffff', '#ffee88']
for (let dx = -2; dx <= 2; dx++) for (let dy = -2; dy <= 2; dy++) {
if (Math.abs(dx) + Math.abs(dy) <= 2) px(propX + dx, propY - 17 + dy, sparkle[(dx + dy + 4) % 3], ox, oy)
}
} else if (costume.propShape === 'shield') {
roundBox(propX, propY - 6, 12, 15, costume.prop, ox, oy)
fill(propX + 3, propY - 3, 6, 9, lighten(costume.prop, 15), ox, oy)
fill(propX + 5, propY - 1, 2, 5, '#ffffff', ox, oy) // emblem
} else if (costume.propShape === 'sign') {
for (let i = 0; i < 20; i++) px(propX + 2, propY - i, '#886633', ox, oy) // pole
roundBox(propX - 6, propY - 30, 18, 12, costume.prop, ox, oy) // sign board
} else if (costume.propShape === 'phone' || costume.propShape === 'remote') {
roundBox(propX, propY, 5, 8, '#333333', ox, oy)
fill(propX + 1, propY + 1, 3, 5, '#44ff44', ox, oy)
px(propX + 2, propY + 2, '#88ff88', ox, oy)
} else if (costume.propShape === 'food') {
roundBox(propX, propY - 2, 8, 6, costume.prop, ox, oy)
fill(propX + 1, propY - 4, 6, 2, '#44aa22', ox, oy) // lettuce
px(propX + 3, propY - 5, '#ff4444', ox, oy) // cherry on top
}
}
// ===== HEAD =====
const hx = cx - Math.floor(headW / 2) + xOff
const hy = headTop + yOff
const faceColor = costume.facePaint || skinTone
const faceDark = costume.facePaint ? darken(costume.facePaint, 15) : skinDark
const faceLight = costume.facePaint ? lighten(costume.facePaint, 10) : skinLight
roundBox(hx, hy, headW, headH, faceColor, ox, oy)
// Head shading
for (let iy = hy + 2; iy < hy + headH - 2; iy++) {
px(hx + headW - 2, iy, faceDark, ox, oy)
px(hx + headW - 3, iy, faceDark, ox, oy)
px(hx + 1, iy, faceLight, ox, oy)
}
// Ears
if (!costume.facePaint) {
fill(hx - 2, hy + Math.floor(headH * 0.3), 2, 5, skinTone, ox, oy)
px(hx - 2, hy + Math.floor(headH * 0.3) + 1, skinDark, ox, oy) // inner ear
fill(hx + headW, hy + Math.floor(headH * 0.3), 2, 5, skinTone, ox, oy)
px(hx + headW + 1, hy + Math.floor(headH * 0.3) + 1, skinDark, ox, oy)
}
// Hair
if (!costume.onesie || costume.type !== 'animal') {
// Top hair (fluffy)
for (let ix = hx; ix < hx + headW; ix++) {
px(ix, hy, hairColor, ox, oy)
px(ix, hy + 1, hairColor, ox, oy)
px(ix, hy + 2, hairColor, ox, oy)
}
for (let ix = hx + 1; ix < hx + headW - 1; ix++) {
px(ix, hy - 1, hairColor, ox, oy)
}
// Hair volume on sides
fill(hx - 1, hy, 2, 5, hairColor, ox, oy)
fill(hx + headW - 1, hy, 2, 5, hairColor, ox, oy)
// Hair highlights
px(hx + 3, hy, lighten(hairColor, 15), ox, oy)
px(hx + 4, hy, lighten(hairColor, 15), ox, oy)
px(hx + 5, hy - 1, lighten(hairColor, 10), ox, oy)
// Messy hair for losers
if (wr < 0.3) {
px(hx + 2, hy - 2, hairColor, ox, oy)
px(hx + headW - 4, hy - 3, hairColor, ox, oy)
px(hx - 1, hy - 1, hairColor, ox, oy)
}
}
// ===== EYES =====
const eyeY = hy + Math.floor(headH * 0.35)
const leX = hx + 5
const reX = hx + headW - 11
if (isPanic) {
// Wide terrified eyes
fill(leX - 1, eyeY - 2, 8, 7, '#ffffff', ox, oy)
fill(reX - 1, eyeY - 2, 8, 7, '#ffffff', ox, oy)
// Tiny pupils (fear)
fill(leX + 2, eyeY + 1, 2, 2, '#000000', ox, oy)
fill(reX + 2, eyeY + 1, 2, 2, '#000000', ox, oy)
// Eyebrow (raised)
fill(leX - 1, eyeY - 4, 7, 2, hairColor, ox, oy)
fill(reX - 1, eyeY - 4, 7, 2, hairColor, ox, oy)
} else if (isCheer) {
// Happy squinted eyes
fill(leX, eyeY, 6, 2, '#000000', ox, oy)
fill(reX, eyeY, 6, 2, '#000000', ox, oy)
fill(leX, eyeY - 1, 6, 1, '#ffffff', ox, oy)
fill(reX, eyeY - 1, 6, 1, '#ffffff', ox, oy)
// Raised cheeks
fill(leX - 1, eyeY + 2, 3, 2, '#ff8888', ox, oy)
fill(reX + 4, eyeY + 2, 3, 2, '#ff8888', ox, oy)
} else {
// Normal eyes with iris detail
fill(leX, eyeY, 6, 5, '#ffffff', ox, oy)
fill(reX, eyeY, 6, 5, '#ffffff', ox, oy)
// Iris
fill(leX + 2, eyeY + 1, 3, 3, '#4466aa', ox, oy)
fill(reX + 2, eyeY + 1, 3, 3, '#4466aa', ox, oy)
// Pupil
fill(leX + 3, eyeY + 2, 2, 2, '#000000', ox, oy)
fill(reX + 3, eyeY + 2, 2, 2, '#000000', ox, oy)
// Eye highlight
px(leX + 2, eyeY + 1, '#ffffff', ox, oy)
px(reX + 2, eyeY + 1, '#ffffff', ox, oy)
// Eyebrows
fill(leX, eyeY - 2, 6, 2, hairColor, ox, oy)
fill(reX, eyeY - 2, 6, 2, hairColor, ox, oy)
// Angry eyebrows for coach
if (isCoach) {
px(leX, eyeY - 3, hairColor, ox, oy)
px(reX + 5, eyeY - 3, hairColor, ox, oy)
}
}
// Glasses
if (hasGlasses) {
// Frames
for (let ix = leX - 2; ix <= reX + 7; ix++) px(ix, eyeY - 1, '#444444', ox, oy)
// Left lens
for (let iy = eyeY - 1; iy <= eyeY + 4; iy++) { px(leX - 2, iy, '#444444', ox, oy); px(leX + 6, iy, '#444444', ox, oy) }
px(leX - 2, eyeY + 4, '#444444', ox, oy); px(leX + 6, eyeY + 4, '#444444', ox, oy)
for (let ix = leX - 1; ix <= leX + 5; ix++) px(ix, eyeY + 4, '#444444', ox, oy)
// Right lens
for (let iy = eyeY - 1; iy <= eyeY + 4; iy++) { px(reX - 2, iy, '#444444', ox, oy); px(reX + 6, iy, '#444444', ox, oy) }
for (let ix = reX - 1; ix <= reX + 5; ix++) px(ix, eyeY + 4, '#444444', ox, oy)
// Lens reflection
px(leX, eyeY, '#aaccff', ox, oy)
px(reX, eyeY, '#aaccff', ox, oy)
}
// Nose
const noseY = hy + Math.floor(headH * 0.55)
px(cx + xOff, noseY, skinDark, ox, oy)
px(cx + 1 + xOff, noseY, skinDark, ox, oy)
px(cx + xOff, noseY + 1, skinDark, ox, oy)
// Red nose for clown
if (costume.type === 'hat' && archetype === 'clown') {
fill(cx - 1 + xOff, noseY - 1, 4, 4, '#ff0000', ox, oy)
}
// Mouth
const mY = hy + Math.floor(headH * 0.72)
if (isCheer) {
// Big grin with teeth
fill(cx - 5 + xOff, mY, 10, 4, '#000000', ox, oy)
fill(cx - 4 + xOff, mY, 8, 2, '#ffffff', ox, oy) // teeth
fill(cx - 4 + xOff, mY + 2, 8, 2, '#cc4444', ox, oy) // tongue
} else if (isPanic) {
// Screaming
roundBox(cx - 4 + xOff, mY - 2, 8, 8, '#000000', ox, oy)
fill(cx - 3 + xOff, mY + 2, 6, 2, '#cc4444', ox, oy) // tongue
// Teeth
px(cx - 3 + xOff, mY - 1, '#ffffff', ox, oy)
px(cx + 2 + xOff, mY - 1, '#ffffff', ox, oy)
} else if (isCoach) {
// Yelling
fill(cx - 4 + xOff, mY, 8, 5, '#000000', ox, oy)
fill(cx - 3 + xOff, mY, 6, 2, '#ffffff', ox, oy)
} else {
// Neutral / slight smile
fill(cx - 3 + xOff, mY, 6, 2, '#0a0a0a', ox, oy)
px(cx - 4 + xOff, mY + 1, '#0a0a0a', ox, oy)
px(cx + 3 + xOff, mY + 1, '#0a0a0a', ox, oy)
// Drool for very low win rate
if (wr < 0.15) {
px(cx + 3 + xOff, mY + 2, '#88ccff', ox, oy)
px(cx + 3 + xOff, mY + 3, '#88ccff', ox, oy)
px(cx + 3 + xOff, mY + 4, '#66aadd', ox, oy)
}
}
// Sweat drops
if (hasSweatDrops && (isIdle || isPanic || isCoach)) {
fill(hx + headW + 2, eyeY + 2, 2, 4, '#88ccff', ox, oy)
px(hx + headW + 2, eyeY + 6, '#66aadd', ox, oy)
if (wr < 0.2) {
fill(hx - 3, eyeY + 4, 2, 3, '#88ccff', ox, oy)
// Puddle forming
fill(hx + headW + 1, eyeY + 7, 4, 1, '#aaddff', ox, oy)
}
}
// Double chin
if (isFat && wr < 0.25) {
fill(hx + 4, hy + headH, headW - 8, 4, skinTone, ox, oy)
fill(hx + 5, hy + headH + 3, headW - 10, 2, skinDark, ox, oy)
fill(hx + 6, hy + headH + 4, headW - 12, 2, skinDark, ox, oy)
}
// Beard
if (hasBeard && !costume.facePaint) {
// Scrappy neckbeard for losers, cool beard for winners
if (wr < 0.35) {
// Patchy neckbeard
for (let ix = hx + 3; ix < hx + headW - 3; ix++) {
if ((ix + hy) % 3 !== 0) px(ix, hy + headH - 2, hairDark, ox, oy)
if ((ix + hy) % 2 !== 0) px(ix, hy + headH - 1, hairDark, ox, oy)
if ((ix + hy) % 3 === 0) px(ix, hy + headH, hairDark, ox, oy)
}
} else {
// Full beard
for (let ix = hx + 3; ix < hx + headW - 3; ix++) {
fill(ix, hy + headH - 3, 1, 5, hairColor, ox, oy)
}
fill(cx - 2 + xOff, hy + headH + 1, 4, 2, hairDark, ox, oy)
}
}
// Headband (winners)
if (hasHeadband && costume.hatShape === 'none') {
const hbColor = wr > 0.85 ? '#ffd700' : '#ff2222'
fill(hx - 2, hy + 3, headW + 4, 3, hbColor, ox, oy)
// Trailing ribbons
px(hx - 3, hy + 4, hbColor, ox, oy)
px(hx - 4, hy + 5, hbColor, ox, oy)
px(hx - 5, hy + 5 + (isIdle ? bounce : 0), hbColor, ox, oy)
px(hx - 6, hy + 6 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy)
px(hx - 7, hy + 5 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy)
}
// Winner glow aura
if (isSuperHero && !isPanic) {
for (let a = 0; a < 16; a++) {
const angle = a * Math.PI * 2 / 16 + t * Math.PI * 0.5
const r = 40 + Math.sin(t * Math.PI * 4 + a * 0.8) * 8
const gx = cx + xOff + Math.round(Math.cos(angle) * r)
const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(angle) * r)
fill(gx - 1, gy - 1, 3, 3, '#ffcc4430', ox, oy)
px(gx, gy, '#ffcc4460', ox, oy)
}
}
// ===== COSTUME HAT/ACCESSORIES =====
const hatColor = costume.hat || hairColor
const hatDk = darken(hatColor, 15)
const hatLt = lighten(hatColor, 15)
if (costume.hatShape === 'ears') {
// Big floppy costume ears
fill(hx - 1, hy - 5, 5, 6, hatColor, ox, oy)
fill(hx + headW - 4, hy - 5, 5, 6, hatColor, ox, oy)
fill(hx, hy - 4, 3, 4, hatLt, ox, oy) // inner
fill(hx + headW - 3, hy - 4, 3, 4, hatLt, ox, oy)
} else if (costume.hatShape === 'helmet') {
for (let ix = hx - 3; ix < hx + headW + 3; ix++) {
px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 2, hatColor, ox, oy)
px(ix, hy - 1, hatColor, ox, oy); px(ix, hy, hatColor, ox, oy)
}
for (let ix = hx - 1; ix < hx + headW + 1; ix++) {
px(ix, hy - 4, hatColor, ox, oy); px(ix, hy - 5, hatColor, ox, oy)
}
fill(hx + 2, hy - 4, 4, 2, hatLt, ox, oy) // highlight
// Visor
fill(hx + 2, hy + 1, headW - 4, 3, '#33333388', ox, oy)
} else if (costume.hatShape === 'pointy') {
for (let h = 0; h < 18; h++) {
const w = Math.max(1, Math.floor((headW + 2) * (1 - h / 18)))
const sx2 = cx + xOff - Math.floor(w / 2)
for (let ix = sx2; ix < sx2 + w; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
}
// Stars and moons on the hat
px(cx - 3 + xOff, hy - 8, '#ffcc44', ox, oy)
px(cx + 4 + xOff, hy - 12, '#ffcc44', ox, oy)
px(cx + xOff, hy - 16, '#ffffff', ox, oy) // star tip
fill(cx - 1 + xOff, hy - 18, 3, 3, '#ffcc44', ox, oy)
} else if (costume.hatShape === 'wide') {
for (let ix = hx - 8; ix < hx + headW + 8; ix++) {
px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 2, hatColor, ox, oy)
}
for (let ix = hx - 3; ix < hx + headW + 3; ix++) {
px(ix, hy - 4, hatColor, ox, oy); px(ix, hy - 5, hatColor, ox, oy)
px(ix, hy - 6, hatColor, ox, oy); px(ix, hy - 7, hatColor, ox, oy)
}
// Hat band
fill(hx - 2, hy - 4, headW + 4, 2, hatDk, ox, oy)
} else if (costume.hatShape === 'tall') {
for (let h = 0; h < 16; h++) {
for (let ix = hx + 2; ix < hx + headW - 2; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
}
fill(hx + 3, hy - 14, headW - 8, 2, hatLt, ox, oy) // puff/highlight
} else if (costume.hatShape === 'horns') {
// Chunky horns
for (let h = 0; h < 8; h++) {
px(hx - h, hy - 1 - h, hatColor, ox, oy); px(hx - h - 1, hy - 1 - h, hatColor, ox, oy)
px(hx + headW - 1 + h, hy - 1 - h, hatColor, ox, oy); px(hx + headW + h, hy - 1 - h, hatColor, ox, oy)
}
px(hx - 8, hy - 9, hatLt, ox, oy); px(hx + headW + 8, hy - 9, hatLt, ox, oy) // tips
} else if (costume.hatShape === 'antenna') {
// Bouncy antenna headband
fill(hx + 2, hy - 2, headW - 4, 2, '#888888', ox, oy) // headband
const bobX = isIdle ? Math.round(Math.sin(t * Math.PI * 3) * 2) : 0
// Left antenna
for (let h = 0; h < 10; h++) px(hx + 4 + bobX, hy - 3 - h, '#888888', ox, oy)
fill(hx + 2 + bobX, hy - 14, 5, 4, '#ff2222', ox, oy) // left ball
px(hx + 3 + bobX, hy - 13, '#ff6666', ox, oy) // highlight
// Right antenna
for (let h = 0; h < 10; h++) px(hx + headW - 5 - bobX, hy - 3 - h, '#888888', ox, oy)
fill(hx + headW - 7 - bobX, hy - 14, 5, 4, '#ff2222', ox, oy)
px(hx + headW - 6 - bobX, hy - 13, '#ff6666', ox, oy)
} else if (costume.hatShape === 'cone') {
for (let h = 0; h < 20; h++) {
const w = Math.max(2, 18 - h)
const sx2 = cx + xOff - Math.floor(w / 2)
const stripe = (h % 6 < 3) ? '#ffffff' : hatColor
for (let ix = sx2; ix < sx2 + w; ix++) px(ix, hy - 1 - h, stripe, ox, oy)
}
}
// Tail (costume)
if (costume.tail) {
const tailColor = costume.hat || shirtColor
const tx = bx - 4
for (let i = 0; i < 8; i++) {
px(tx - i, by + bodyH - 4 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2) * 2), tailColor, ox, oy)
px(tx - i, by + bodyH - 3 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2) * 2), tailColor, ox, oy)
}
px(tx - 8, by + bodyH - 4, darken(tailColor, 20), ox, oy) // tail tip
}
}
// Render all frames
const entries = Object.entries(HUMAN_ANIMATIONS) as [string, { frames: number; row: number }][]
for (let row = 0; row < entries.length; row++) {
const [pose, cfg] = entries[row]
for (let f = 0; f < cfg.frames; f++) drawHuman(f, row, pose, f, cfg.frames)
}
return canvas.toDataURL()
}
function getCostume(archetype: string): CostumeConfig {
const raw = COSTUME_MAP[archetype] || {}
return {
type: raw.type || 'generic',
hat: raw.hat,
hatShape: raw.hatShape || 'none',
prop: raw.prop,
propShape: raw.propShape || 'none',
onesie: raw.onesie || false,
facePaint: raw.facePaint,
tail: raw.tail || false,
}
}
function darken(hex: string, amount: number): string {
if (hex.startsWith('hsl')) {
const m = hex.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
if (m) return `hsl(${m[1]}, ${m[2]}%, ${Math.max(0, +m[3] - amount)}%)`
}
if (hex.length < 7) return hex
const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount * 2.55)
const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - amount * 2.55)
const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - amount * 2.55)
return `#${Math.round(r).toString(16).padStart(2, '0')}${Math.round(g).toString(16).padStart(2, '0')}${Math.round(b).toString(16).padStart(2, '0')}`
}
function lighten(hex: string, amount: number): string {
if (hex.startsWith('hsl')) {
const m = hex.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
if (m) return `hsl(${m[1]}, ${m[2]}%, ${Math.min(95, +m[3] + amount)}%)`
}
if (hex.length < 7) return hex
const r = Math.min(255, parseInt(hex.slice(1, 3), 16) + amount * 2.55)
const g = Math.min(255, parseInt(hex.slice(3, 5), 16) + amount * 2.55)
const b = Math.min(255, parseInt(hex.slice(5, 7), 16) + amount * 2.55)
return `#${Math.round(r).toString(16).padStart(2, '0')}${Math.round(g).toString(16).padStart(2, '0')}${Math.round(b).toString(16).padStart(2, '0')}`
}
+1
View File
@@ -7,6 +7,7 @@ export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } from './constants'
export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants'
export { getBotColors } from './palette'
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
export { generateHumanSpriteSheet, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, HUMAN_FRAME_SIZE } from './human'
export { archetypes, rollArchetype } from './archetypes'
export interface SpriteCustomization {