feat: botfights v1 — full fighting game with Kaplay engine

- Vue 3 + Vite + Tailwind 4 frontend with synthwave aesthetic
- Hono backend on port 9100 with SQLite/Drizzle
- Procedural pixel-art sprite generator (48x48, 8 animation states)
- Kaplay fight scene with punch/kick/special/knockback/KO animations
- 12 mock bots across 6 tiers with Elo rating system
- 9 challenge types, 10 fight arenas with modifiers
- Fight replay with staggered battle log and ~1 min timing
- Sprite preview page at /sprites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 16:27:54 +00:00
co-authored by Claude Opus 4.6
commit 335c148866
44 changed files with 7782 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
import kaplay from 'kaplay'
import { generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS } from './sprites'
export interface FightSceneConfig {
canvas: HTMLCanvasElement
botA: { name: string; seed: string; tier: number }
botB: { name: string; seed: string; tier: number }
arena: string
onReady?: () => void
}
export interface RoundEvent {
round: number
challengeType: string
winnerId: string | null
botAId: string
botBId: string
narration: string
isCritical: boolean
botAScore: number
botBScore: number
}
const ARENA_THEMES: Record<string, { bg: string; ground: string; accent: string }> = {
datacenter: { bg: '#0a0a1a', ground: '#1a1a3a', accent: '#00f0ff' },
stackoverflow_ruins: { bg: '#1a0f00', ground: '#2a1f10', accent: '#f48024' },
gpu_graveyard: { bg: '#0a0a0a', ground: '#1a1a1a', accent: '#76b900' },
prompt_dungeon: { bg: '#0f0a1a', ground: '#1f1a2a', accent: '#b83dff' },
silicon_valley_dojo: { bg: '#0a1a0a', ground: '#1a2a1a', accent: '#00ff41' },
paper_mill: { bg: '#1a1a10', ground: '#2a2a20', accent: '#f0e68c' },
localhost: { bg: '#000000', ground: '#111111', accent: '#00ff41' },
the_cloud: { bg: '#0a0f1a', ground: '#1a1f2a', accent: '#4488ff' },
hacker_news: { bg: '#1a0f00', ground: '#2a1f10', accent: '#ff6600' },
the_singularity: { bg: '#1a0020', ground: '#2a0030', accent: '#ff00ff' },
}
const spriteAnims = {
idle: { from: 0, to: ANIMATIONS.idle.frames - 1, loop: true, speed: 6 },
attack: { from: MAX_FRAMES, to: MAX_FRAMES + ANIMATIONS.attack.frames - 1, loop: false, speed: 12 },
kick: { from: MAX_FRAMES * 2, to: MAX_FRAMES * 2 + ANIMATIONS.kick.frames - 1, loop: false, speed: 10 },
special: { from: MAX_FRAMES * 3, to: MAX_FRAMES * 3 + ANIMATIONS.special.frames - 1, loop: false, speed: 8 },
hit: { from: MAX_FRAMES * 4, to: MAX_FRAMES * 4 + ANIMATIONS.hit.frames - 1, loop: false, speed: 8 },
knockback: { from: MAX_FRAMES * 5, to: MAX_FRAMES * 5 + ANIMATIONS.knockback.frames - 1, loop: false, speed: 8 },
ko: { from: MAX_FRAMES * 6, to: MAX_FRAMES * 6 + ANIMATIONS.ko.frames - 1, loop: false, speed: 6 },
win: { from: MAX_FRAMES * 7, to: MAX_FRAMES * 7 + ANIMATIONS.win.frames - 1, loop: true, speed: 6 },
}
// Pick a random attack animation based on challenge type
function pickAttackAnim(challengeType: string, isCritical: boolean): string {
if (isCritical) return 'special'
const map: Record<string, string[]> = {
speed_blitz: ['attack', 'kick'],
riddle: ['attack', 'special'],
code_golf: ['special', 'attack'],
roast_battle: ['special', 'kick'],
hallucination_check: ['attack'],
token_economy: ['kick', 'attack'],
creative_writing: ['special'],
math_blitz: ['attack', 'kick'],
trap_card: ['special', 'kick'],
}
const options = map[challengeType] || ['attack', 'kick']
return options[Math.floor(Math.random() * options.length)]
}
// Pick defender reaction
function pickDefenderAnim(isCritical: boolean): string {
return isCritical ? 'knockback' : 'hit'
}
export function createFightScene(config: FightSceneConfig) {
const { canvas, botA, botB, arena } = config
const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost
const k = kaplay({
canvas,
width: canvas.width || 800,
height: canvas.height || 500,
background: theme.bg,
global: false,
scale: 1,
crisp: true,
texFilter: 'nearest',
})
const colorsA = getBotColors(botA.seed)
const colorsB = getBotColors(botB.seed)
const sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
const sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
const W = k.width()
const H = k.height()
const GROUND_Y = H * 0.78
k.scene('fight', () => {
// Ground
k.add([k.rect(W, H * 0.25), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.ground))])
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.5)])
for (let i = 1; i <= 5; i++) {
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + i * 12), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.06)])
}
for (let i = 0; i < 24; i++) {
k.add([k.rect(1, H * 0.25), k.pos(i * (W / 24), GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.04)])
}
const scaleA = 1.8 + botA.tier * 0.4
k.add([k.sprite('botA', { anim: 'idle' }), k.pos(W * 0.28, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), 'fighterA'])
const scaleB = 1.8 + botB.tier * 0.4
k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W * 0.72, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), 'fighterB'])
k.add([k.text('', { size: 42, font: 'monospace' }), k.pos(W / 2, H * 0.3), k.anchor('center'), k.color(k.Color.fromHex('#ffffff')), k.opacity(0), k.z(100), 'announcement'])
k.add([k.text('', { size: 32, font: 'monospace' }), k.pos(0, 0), k.anchor('center'), k.color(k.Color.fromHex('#ff2d2d')), k.opacity(0), k.z(90), 'hitText'])
k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.28, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboA'])
k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.72, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboB'])
config.onReady?.()
})
k.go('fight')
let comboA = 0
let comboB = 0
return {
k,
async showAnnouncement(text: string, color: string = '#ffffff', duration: number = 1200) {
const ann = k.get('announcement')[0]
if (!ann) return
ann.text = text
ann.color = k.Color.fromHex(color)
ann.opacity = 1
ann.scaleTo(0.5)
await k.tween(ann.scale.x, 1, 0.2, (v) => ann.scaleTo(v), k.easings.easeOutBack)
await k.wait(duration / 1000)
await k.tween(1, 0, 0.3, (v) => { ann.opacity = v })
},
async playAttack(side: 'a' | 'b', attackAnim: string, defenderAnim: string, isCritical: boolean) {
const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0]
if (!attacker || !defender) return
const origAX = attacker.pos.x
const origDX = defender.pos.x
const direction = side === 'a' ? 1 : -1
const lunge = attackAnim === 'special' ? 20 : 40 + (isCritical ? 20 : 0)
// Lunge forward
await k.tween(attacker.pos.x, attacker.pos.x + direction * lunge, 0.15, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad)
attacker.play(attackAnim as any)
await k.wait(attackAnim === 'special' ? 0.35 : 0.2)
defender.play(defenderAnim as any)
// Hit text
const hitFx = k.get('hitText')[0]
if (hitFx) {
const words = isCritical
? ['CRITICAL!', 'DEVASTATING!', 'BRUTAL!', 'OBLITERATED!']
: attackAnim === 'kick' ? ['KICK!', 'ROUNDHOUSE!', 'SWEPT!']
: attackAnim === 'special' ? ['SPECIAL!', 'HADOUKEN!', 'ZAPPED!']
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!']
hitFx.text = words[Math.floor(Math.random() * words.length)]
hitFx.pos.x = defender.pos.x + (side === 'a' ? -20 : 20)
hitFx.pos.y = defender.pos.y - 90
hitFx.opacity = 1
hitFx.color = isCritical ? k.Color.fromHex('#ffe14d') : attackAnim === 'special' ? k.Color.fromHex('#00f0ff') : k.Color.fromHex('#ff2d2d')
k.tween(hitFx.pos.y, hitFx.pos.y - 50, 0.8, (v) => { hitFx.pos.y = v })
k.tween(1, 0, 1, (v) => { hitFx.opacity = v })
}
// Screen shake
k.shake(isCritical ? 15 : attackAnim === 'special' ? 8 : 5)
// Knockback — push defender back
if (defenderAnim === 'knockback') {
const pushDist = direction * -60
await k.tween(defender.pos.x, defender.pos.x + pushDist, 0.3, (v) => { defender.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.3)
// Return defender
await k.tween(defender.pos.x, origDX, 0.4, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad)
} else {
// Flash defender
await k.wait(0.1)
defender.opacity = 0.3; await k.wait(0.05)
defender.opacity = 1; await k.wait(0.05)
defender.opacity = 0.3; await k.wait(0.05)
defender.opacity = 1
await k.wait(0.2)
}
// Return attacker
await k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
await k.wait(0.2)
attacker.play('idle')
defender.play('idle')
},
async playRound(event: RoundEvent) {
const aWon = event.winnerId === event.botAId
const bWon = event.winnerId === event.botBId
const isCritical = Math.abs(event.botAScore - event.botBScore) > 4
const atkAnim = pickAttackAnim(event.challengeType, isCritical)
const defAnim = pickDefenderAnim(isCritical)
if (aWon) {
comboA++; comboB = 0
await this.playAttack('a', atkAnim, defAnim, isCritical)
if (comboA >= 2) {
const ct = k.get('comboA')[0]
if (ct) { ct.text = `x${comboA} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) }
}
} else if (bWon) {
comboB++; comboA = 0
await this.playAttack('b', atkAnim, defAnim, isCritical)
if (comboB >= 2) {
const ct = k.get('comboB')[0]
if (ct) { ct.text = `x${comboB} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) }
}
} else {
comboA = 0; comboB = 0
// Draw — both take a hit
const fA = k.get('fighterA')[0]
const fB = k.get('fighterB')[0]
if (fA && fB) {
fA.play('hit'); fB.play('hit')
k.shake(3)
await k.wait(0.5)
fA.play('idle'); fB.play('idle')
}
}
},
async playKO(winningSide: 'a' | 'b') {
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
if (!loser || !winner) return
loser.play('knockback')
k.shake(20)
await k.wait(0.4)
loser.play('ko')
await k.wait(0.6)
await this.showAnnouncement('K.O.!', '#ff2d2d', 2000)
winner.play('win')
await k.wait(0.5)
},
async playPerfect(winningSide: 'a' | 'b') {
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
if (!loser || !winner) return
loser.play('knockback')
k.shake(25)
await k.wait(0.5)
loser.play('ko')
await this.showAnnouncement('PERFECT!', '#ffe14d', 2500)
winner.play('win')
},
destroy() {
k.quit()
},
}
}
export type FightSceneController = ReturnType<typeof createFightScene>
+586
View File
@@ -0,0 +1,586 @@
// Pixel-art sprite sheet generator
// 48x48 internal resolution scaled to 96x96 frames
// Many animation states for rich fighting
const FRAME_SIZE = 96
const INTERNAL = 48
const SCALE = FRAME_SIZE / INTERNAL
const ANIMATIONS = {
idle: { frames: 4, row: 0 },
attack: { frames: 6, row: 1 },
kick: { frames: 5, row: 2 },
special: { frames: 6, row: 3 },
hit: { frames: 3, row: 4 },
knockback: { frames: 5, row: 5 },
ko: { frames: 5, row: 6 },
win: { frames: 4, row: 7 },
}
const TOTAL_ROWS = Object.keys(ANIMATIONS).length
const MAX_FRAMES = 6
interface Pal {
body: string; dark: string; light: string
acc: string; accDark: string; accLight: string
out: string; skin: string; skinDark: string
}
function makePal(primary: string, secondary: string, tier: number): Pal {
const [h, s, l] = parseHSL(primary)
const [h2, s2, l2] = parseHSL(secondary)
return {
body: primary,
dark: `hsl(${h}, ${s}%, ${Math.max(0, l - 20)}%)`,
light: `hsl(${h}, ${Math.min(100, s + 5)}%, ${Math.min(95, l + 15)}%)`,
acc: secondary,
accDark: `hsl(${h2}, ${s2}%, ${Math.max(0, l2 - 20)}%)`,
accLight: `hsl(${h2}, ${Math.min(100, s2)}%, ${Math.min(95, l2 + 15)}%)`,
out: '#0a0a0a',
skin: tier <= 1 ? primary : `hsl(${h}, ${Math.max(20, s - 30)}%, ${Math.min(85, l + 25)}%)`,
skinDark: tier <= 1 ? `hsl(${h}, ${s}%, ${Math.max(0, l - 10)}%)` : `hsl(${h}, ${Math.max(15, s - 35)}%, ${Math.min(75, l + 15)}%)`,
}
}
function parseHSL(c: string): [number, number, number] {
const m = c.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
return m ? [+m[1], +m[2], +m[3]] : [200, 70, 50]
}
export function generateSpriteSheet(
seed: string, tier: number, primaryColor: string, secondaryColor: string,
): string {
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * MAX_FRAMES
canvas.height = FRAME_SIZE * TOTAL_ROWS
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
const pal = makePal(primaryColor, secondaryColor, tier)
let sh = 0
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
const rng = () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 }
rng(); rng(); rng()
const hasVisor = rng() > 0.5 && tier >= 2
const hasMohawk = rng() > 0.5 && tier >= 3
const hasHorns = rng() > 0.6 && tier >= 4 && !hasMohawk
const specialType = rng() > 0.5 ? 'fire' : 'electric' // determines special attack visuals
function px(x: number, y: number, color: string, ox: number, oy: number) {
if (x < 0 || x >= INTERNAL || y < 0 || y >= INTERNAL) return
ctx.fillStyle = color
ctx.fillRect(ox + x * SCALE, oy + y * SCALE, SCALE, SCALE)
}
function box(x: number, y: number, w: number, h: number, fillColor: string, ox: number, oy: number) {
for (let i = x - 1; i <= x + w; i++) { px(i, y - 1, pal.out, ox, oy); px(i, y + h, pal.out, ox, oy) }
for (let i = y; i < y + h; i++) { px(x - 1, i, pal.out, ox, oy); px(x + w, i, pal.out, ox, oy) }
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, fillColor, ox, oy)
}
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 drawFrame(fx: number, fy: number, pose: string, frame: number, total: number) {
const ox = fx * FRAME_SIZE
const oy = fy * FRAME_SIZE
const t = frame / Math.max(1, total - 1)
const bounce = Math.round(Math.sin(t * Math.PI * 2))
const idle = pose === 'idle'
const atk = pose === 'attack'
const kick = pose === 'kick'
const special = pose === 'special'
const hit = pose === 'hit'
const knockback = pose === 'knockback'
const ko = pose === 'ko'
const win = pose === 'win'
// Dimensions scale with tier
const bw = 10 + tier * 2 // body width
const bh = 8 + tier // body height
const hw = 10 + tier // head width
const hh = 9 + tier // head height
const legH = 6 + tier // leg height
const legW = 3 + Math.floor(tier * 0.5)
const armW = 3
const armH = 5 + tier
// Anchor: center bottom at (24, 42) in 48x48
const cx = 24
const ground = 42
// Positions bottom-up
const feetY = ground - 2
const legsTop = feetY - legH
const bodyTop = legsTop - bh
const headTop = bodyTop - hh
// Pose offsets
const hOff = hit ? Math.round(t * 3) : knockback ? Math.round(t * 8) : ko ? 2 : 0
const vBounce = idle ? bounce : 0
const koSlump = ko ? Math.round(t * 5) : 0
const kbLift = knockback ? Math.round(Math.sin(t * Math.PI) * 6) : 0 // arc in the air
const globalY = -kbLift
// ---- SHADOW ----
const shadowW = Math.floor(bw * 0.7) + (knockback ? 2 : 0)
for (let sx = cx - shadowW; sx <= cx + shadowW; sx++) {
px(sx, ground, 'rgba(0,0,0,0.25)', ox, oy)
px(sx, ground + 1, 'rgba(0,0,0,0.1)', ox, oy)
}
// ---- LEGS ----
const legGap = atk || kick ? Math.round(1 + t * 3) : ko ? 4 : knockback ? 3 : 1
const ll = cx - legGap - Math.floor(legW / 2) + hOff
const rl = cx + legGap - Math.floor(legW / 2) + hOff
if (ko) {
box(ll - 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), pal.dark, ox, oy)
box(rl + 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), pal.dark, ox, oy)
} else if (kick) {
// Standing leg
box(ll, legsTop + globalY, legW, legH, pal.dark, ox, oy)
// Kicking leg — extends horizontally
const kickExt = Math.round(Math.sin(t * Math.PI) * (legH + tier * 2))
box(rl, legsTop + Math.floor(legH * 0.3) + globalY, kickExt + legW, legW, pal.dark, ox, oy)
// Foot on kick
if (kickExt > 2) {
box(rl + kickExt + legW, legsTop + Math.floor(legH * 0.3) - 1 + globalY, 3 + tier, 3, pal.acc, ox, oy)
}
} else if (knockback) {
// Legs trailing behind in arc
box(ll + Math.round(t * -3), legsTop + globalY + 2, legW, legH - 2, pal.dark, ox, oy)
box(rl + Math.round(t * -2), legsTop + globalY + 3, legW, legH - 3, pal.dark, ox, oy)
} else {
box(ll, legsTop + vBounce + globalY, legW, legH, pal.dark, ox, oy)
box(rl + (atk ? Math.round(t * 2) : 0), legsTop + vBounce + globalY, legW, legH, pal.dark, ox, oy)
}
// Feet (tier 2+)
if (tier >= 2 && !ko && !knockback && !kick) {
box(ll - 1, feetY + vBounce + globalY, legW + 2, 2, pal.accDark, ox, oy)
box(rl - 1 + (atk ? Math.round(t * 2) : 0), feetY + vBounce + globalY, legW + 2, 2, pal.accDark, ox, oy)
}
// ---- BODY ----
const bx = cx - Math.floor(bw / 2) + hOff
const by = bodyTop + vBounce + koSlump + globalY
box(bx, by, bw, bh, pal.body, ox, oy)
// Shading
for (let iy = by + 1; iy < by + bh - 1; iy++) {
px(bx + bw - 1, iy, pal.dark, ox, oy)
px(bx + bw - 2, iy, pal.dark, ox, oy)
px(bx + 1, iy, pal.light, ox, oy)
}
// Horizontal stripes (tier detail)
if (tier >= 1) {
for (let iy = by + 2; iy < by + bh - 1; iy += 2) {
for (let ix = bx + 2; ix < bx + bw - 2; ix++) {
px(ix, iy, pal.dark, ox, oy)
}
}
}
// Belt (tier 2+)
if (tier >= 2) {
const beltY = by + bh - 2
fill(bx, beltY, bw, 1, pal.acc, ox, oy)
fill(bx, beltY + 1, bw, 1, pal.accDark, ox, oy)
if (tier >= 3) { px(cx + hOff, beltY, '#ffd700', ox, oy); px(cx + hOff + 1, beltY, '#ffd700', ox, oy) }
}
// Chest emblem (tier 4+)
if (tier >= 4) {
const ey = by + Math.round(bh * 0.3)
px(cx + hOff - 1, ey, pal.acc, ox, oy)
px(cx + hOff, ey, pal.accLight, ox, oy)
px(cx + hOff + 1, ey, pal.acc, ox, oy)
px(cx + hOff, ey - 1, pal.acc, ox, oy)
px(cx + hOff, ey + 1, pal.acc, ox, oy)
}
// Shoulder pads (tier 3+)
if (tier >= 3 && !ko && !knockback) {
const sy = by
const pw = 2 + Math.floor(tier * 0.5)
box(bx - pw - 1, sy, pw + 1, 3, pal.acc, ox, oy)
box(bx + bw, sy, pw + 1, 3, pal.acc, ox, oy)
// Highlight
px(bx - pw, sy, pal.accLight, ox, oy)
px(bx + bw + 1, sy, pal.accLight, ox, oy)
}
// ---- ARMS ----
const armAttach = by + 2 + vBounce
const armLx = bx - armW + hOff
const armRx = bx + bw + hOff
if (ko) {
fill(armLx - 3, armAttach + koSlump + 2, armH, armW, pal.body, ox, oy)
fill(armRx + 2, armAttach + koSlump + 2, armH, armW, pal.body, ox, oy)
} else if (knockback) {
// Arms flailing behind
box(armLx - Math.round(t * 4), armAttach + globalY - 2, armW, armH + 1, pal.body, ox, oy)
box(armRx - Math.round(t * 3), armAttach + globalY - 1, armW, armH, pal.body, ox, oy)
} else if (atk) {
// Guard left arm
box(armLx, armAttach + 2, armW, armH - 2, pal.body, ox, oy)
// Punch right arm
const reach = Math.round(Math.sin(t * Math.PI) * (armH + tier * 2))
if (reach > 0) {
box(armRx, armAttach - 1, reach + armW, armW + 1, pal.body, ox, oy)
const fS = 3 + Math.floor(tier * 0.5)
const fC = tier >= 5 ? '#ffd700' : tier >= 3 ? '#ff3333' : pal.body
box(armRx + reach + armW, armAttach - 2, fS, fS + 1, fC, ox, oy)
// Impact
if (tier >= 2 && t > 0.3 && t < 0.7) {
const ix = armRx + reach + armW + fS + 1
px(ix, armAttach - 2, '#ffff00', ox, oy)
px(ix + 1, armAttach, '#ffffff', ox, oy)
px(ix, armAttach + 2, '#ffff00', ox, oy)
px(ix + 2, armAttach - 1, '#ffaa00', ox, oy)
px(ix + 2, armAttach + 1, '#ffaa00', ox, oy)
}
}
// Left glove
if (tier >= 3) {
const gs = 3 + Math.floor(tier * 0.3)
box(armLx - 1, armAttach + armH - 1, gs, gs, tier >= 5 ? '#ffd700' : '#ff3333', ox, oy)
}
} else if (kick) {
// Both arms in guard
box(armLx, armAttach, armW, armH - 1, pal.body, ox, oy)
box(armRx, armAttach, armW, armH - 1, pal.body, ox, oy)
if (tier >= 3) {
const gs = 2 + Math.floor(tier * 0.3)
const gc = tier >= 5 ? '#ffd700' : '#ff3333'
box(armLx, armAttach + armH - 1, gs, gs, gc, ox, oy)
box(armRx, armAttach + armH - 1, gs, gs, gc, ox, oy)
}
} else if (special) {
// Left arm forward, channeling
box(armLx, armAttach, armW, armH, pal.body, ox, oy)
// Right arm extended, casting
const ext = Math.round(Math.sin(t * Math.PI) * (armH + 2))
box(armRx, armAttach - 2, ext + armW + 2, armW, pal.body, ox, oy)
// Projectile effect
if (t > 0.3) {
const projX = armRx + ext + armW + 3 + Math.round(t * 8)
const projY = armAttach - 2
if (specialType === 'fire') {
// Fireball
px(projX, projY, '#ff4400', ox, oy)
px(projX + 1, projY, '#ff6600', ox, oy)
px(projX, projY + 1, '#ff8800', ox, oy)
px(projX + 1, projY + 1, '#ffaa00', ox, oy)
px(projX + 2, projY, '#ffcc00', ox, oy)
px(projX - 1, projY, '#ff2200', ox, oy)
// Trail
px(projX - 2, projY + 1, '#ff440066', ox, oy)
px(projX - 3, projY, '#ff220044', ox, oy)
} else {
// Electric bolt
px(projX, projY, '#00eeff', ox, oy)
px(projX + 1, projY - 1, '#44ffff', ox, oy)
px(projX + 2, projY + 1, '#00eeff', ox, oy)
px(projX + 3, projY, '#88ffff', ox, oy)
px(projX + 1, projY + 1, '#0088ff', ox, oy)
// Sparks
px(projX - 1, projY - 1, '#44ffff', ox, oy)
px(projX + 4, projY - 1, '#ffffff', ox, oy)
}
}
} else if (win) {
box(armLx, armAttach + 2, armW, armH - 1, pal.body, ox, oy)
// Raised arm
box(armRx, armAttach - armH + bounce, armW, armH, pal.body, ox, oy)
if (tier >= 3) {
const gs = 3 + Math.floor(tier * 0.3)
box(armRx - 1, armAttach - armH + bounce - gs, gs + 1, gs, tier >= 5 ? '#ffd700' : '#ff3333', ox, oy)
}
} else {
// Idle
const sw = idle ? bounce : hit ? 1 : 0
box(armLx, armAttach + sw + globalY, armW, armH, pal.body, ox, oy)
box(armRx, armAttach - sw + globalY, armW, armH, pal.body, ox, oy)
if (tier >= 3) {
const gs = 3 + Math.floor(tier * 0.3)
const gc = tier >= 5 ? '#ffd700' : '#ff3333'
box(armLx - 1, armAttach + sw + armH + globalY, gs, gs, gc, ox, oy)
box(armRx, armAttach - sw + armH + globalY, gs, gs, gc, ox, oy)
}
}
// ---- HEAD ----
const hx = cx - Math.floor(hw / 2) + hOff
const hy = headTop + vBounce + koSlump + globalY
if (tier <= 1) {
// BOXY ROBOT
box(hx, hy, hw, hh, pal.body, ox, oy)
// Shading
for (let iy = hy + 1; iy < hy + hh - 1; iy++) px(hx + hw - 1, iy, pal.dark, ox, oy)
px(hx + 1, hy + 1, pal.light, ox, oy)
// Antenna
px(cx + hOff, hy - 1, pal.accDark, ox, oy)
px(cx + hOff, hy - 2 - (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
px(cx + hOff, hy - 3 - (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
px(cx + hOff - 1, hy - 3 - (idle ? Math.abs(bounce) : 0), pal.accDark, ox, oy)
px(cx + hOff + 1, hy - 3 - (idle ? Math.abs(bounce) : 0), pal.accDark, ox, oy)
// Eyes
const eyeY = hy + Math.floor(hh * 0.3)
if (ko) {
px(hx + 2, eyeY, '#ff0000', ox, oy); px(hx + 3, eyeY + 1, '#ff0000', ox, oy)
px(hx + 3, eyeY, '#330000', ox, oy); px(hx + 2, eyeY + 1, '#330000', ox, oy)
px(hx + hw - 3, eyeY, '#ff0000', ox, oy); px(hx + hw - 4, eyeY + 1, '#ff0000', ox, oy)
px(hx + hw - 4, eyeY, '#330000', ox, oy); px(hx + hw - 3, eyeY + 1, '#330000', ox, oy)
} else {
fill(hx + 2, eyeY, 2, 2, '#00ff41', ox, oy)
fill(hx + hw - 4, eyeY, 2, 2, '#00ff41', ox, oy)
// Scanline flicker
if (frame % 2 === 0) {
px(hx + 2, eyeY, '#00cc33', ox, oy)
px(hx + hw - 4, eyeY, '#00cc33', ox, oy)
}
}
// Mouth grille
const mY = hy + Math.floor(hh * 0.65)
for (let mx = hx + 2; mx < hx + hw - 2; mx += 2) {
px(mx, mY, pal.out, ox, oy)
px(mx, mY + 1, pal.out, ox, oy)
}
// Bolts
px(hx, hy + Math.floor(hh / 2), pal.accDark, ox, oy)
px(hx + hw - 1, hy + Math.floor(hh / 2), pal.accDark, ox, oy)
// Claw pincers (tier 0)
if (tier === 0) {
const cy = hy + Math.floor(hh / 2)
px(hx - 2, cy, pal.acc, ox, oy); px(hx - 3, cy - 1, pal.acc, ox, oy); px(hx - 3, cy + 1, pal.acc, ox, oy)
px(hx + hw + 1, cy, pal.acc, ox, oy); px(hx + hw + 2, cy - 1, pal.acc, ox, oy); px(hx + hw + 2, cy + 1, pal.acc, ox, oy)
}
} else {
// ROUNDED HEAD (tier 2+)
box(hx + 1, hy, hw - 2, hh, pal.body, ox, oy)
for (let iy = hy + 2; iy < hy + hh - 2; iy++) {
px(hx, iy, pal.body, ox, oy); px(hx + hw - 1, iy, pal.body, ox, oy)
px(hx - 1, iy, pal.out, ox, oy); px(hx + hw, iy, pal.out, ox, oy)
}
// Shading
for (let iy = hy + 2; iy < hy + hh - 2; iy++) {
px(hx + hw - 1, iy, pal.dark, ox, oy)
px(hx + hw - 2, iy, pal.dark, ox, oy)
}
px(hx + 2, hy + 1, pal.light, ox, oy); px(hx + 3, hy + 1, pal.light, ox, oy)
// Face area (lighter "skin" for tier 2+)
if (tier >= 2) {
const faceTop = hy + Math.floor(hh * 0.25)
const faceBot = hy + Math.floor(hh * 0.75)
for (let iy = faceTop; iy < faceBot; iy++) {
for (let ix = hx + 2; ix < hx + hw - 2; ix++) {
px(ix, iy, pal.skin, ox, oy)
}
px(hx + hw - 3, iy, pal.skinDark, ox, oy)
}
}
// Eyes
const eyeY = hy + Math.floor(hh * 0.35)
const leX = hx + Math.floor(hw * 0.2)
const reX = hx + Math.floor(hw * 0.6)
const ew = Math.max(2, Math.floor(tier * 0.5) + 1)
if (ko) {
px(leX, eyeY, '#ff0000', ox, oy); px(leX + 1, eyeY + 1, '#ff0000', ox, oy)
px(leX + 1, eyeY, '#880000', ox, oy); px(leX, eyeY + 1, '#880000', ox, oy)
px(reX, eyeY, '#ff0000', ox, oy); px(reX + 1, eyeY + 1, '#ff0000', ox, oy)
px(reX + 1, eyeY, '#880000', ox, oy); px(reX, eyeY + 1, '#880000', ox, oy)
} else if (knockback) {
// Wide shock eyes
fill(leX - 1, eyeY, ew + 2, 3, '#ffffff', ox, oy)
fill(reX - 1, eyeY, ew + 2, 3, '#ffffff', ox, oy)
px(leX, eyeY + 1, '#000000', ox, oy)
px(reX, eyeY + 1, '#000000', ox, oy)
} else {
fill(leX, eyeY, ew, 2, '#ffffff', ox, oy)
fill(reX, eyeY, ew, 2, '#ffffff', ox, oy)
const ps = atk || kick || special ? 1 : 0
px(leX + ps, eyeY + 1, '#000000', ox, oy)
px(reX + ps, eyeY + 1, '#000000', ox, oy)
// Eye glow (tier 4+)
if (tier >= 4) {
px(leX, eyeY, pal.acc, ox, oy)
px(reX + ew - 1, eyeY, pal.acc, ox, oy)
if (special) {
px(leX - 1, eyeY, specialType === 'fire' ? '#ff4400' : '#00eeff', ox, oy)
px(reX + ew, eyeY, specialType === 'fire' ? '#ff4400' : '#00eeff', ox, oy)
}
}
// Angry brows when attacking
if (atk || kick || special) {
px(leX, eyeY - 1, pal.out, ox, oy); px(leX + 1, eyeY - 1, pal.out, ox, oy)
px(reX, eyeY - 1, pal.out, ox, oy); px(reX + 1, eyeY - 1, pal.out, ox, oy)
}
}
// Mouth
const mY = hy + Math.floor(hh * 0.65)
if (win) {
// Big grin
px(cx + hOff - 2, mY, pal.out, ox, oy)
fill(cx + hOff - 1, mY, 3, 1, '#ffffff', ox, oy)
px(cx + hOff + 2, mY, pal.out, ox, oy)
px(cx + hOff - 1, mY + 1, pal.out, ox, oy)
px(cx + hOff, mY + 1, pal.out, ox, oy)
px(cx + hOff + 1, mY + 1, pal.out, ox, oy)
} else if (ko || knockback) {
// Open mouth shock
box(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy)
} else if (hit) {
px(cx + hOff, mY, pal.out, ox, oy)
px(cx + hOff + 1, mY, pal.out, ox, oy)
} else if (atk || kick || special) {
// Battle yell
fill(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy)
px(cx + hOff - 1, mY, pal.out, ox, oy)
px(cx + hOff + 1, mY, pal.out, ox, oy)
} else {
px(cx + hOff - 1, mY, pal.out, ox, oy)
px(cx + hOff, mY, pal.out, ox, oy)
}
// Visor
if (hasVisor) {
const vY = eyeY - 1
for (let vx = hx + 1; vx < hx + hw - 1; vx++) px(vx, vY, pal.accDark, ox, oy)
px(hx + 1, vY, pal.accLight, ox, oy) // highlight
}
// Headband (tier 4+)
if (tier >= 4) {
const bY = hy + 2
for (let bx2 = hx; bx2 < hx + hw; bx2++) px(bx2, bY, pal.acc, ox, oy)
px(hx - 1, bY + 1, pal.acc, ox, oy)
px(hx - 2, bY + 1 + (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
px(hx - 3, bY + 2 + (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
px(hx - 4, bY + 2, pal.accDark, ox, oy)
}
// Mohawk
if (hasMohawk) {
for (let m = 1; m <= Math.min(tier + 1, 5); m++) {
px(cx + hOff, hy - m, pal.acc, ox, oy)
if (m <= 3) { px(cx + hOff + 1, hy - m, pal.accDark, ox, oy) }
}
}
// Horns (tier 4+, alt to mohawk)
if (hasHorns) {
px(hx + 1, hy - 1, pal.acc, ox, oy); px(hx, hy - 2, pal.acc, ox, oy); px(hx - 1, hy - 3, pal.accLight, ox, oy)
px(hx + hw - 2, hy - 1, pal.acc, ox, oy); px(hx + hw - 1, hy - 2, pal.acc, ox, oy); px(hx + hw, hy - 3, pal.accLight, ox, oy)
}
// Crown (tier 5)
if (tier >= 5) {
const cY = hy - 1 - (hasMohawk ? 5 : hasHorns ? 3 : 0)
for (let cx2 = hx + 1; cx2 < hx + hw - 1; cx2++) px(cx2, cY, '#ffd700', ox, oy)
for (let cx2 = hx + 2; cx2 < hx + hw - 2; cx2++) px(cx2, cY + 1, '#ffd700', ox, oy)
px(hx + 2, cY - 1, '#ffd700', ox, oy)
px(cx + hOff, cY - 2, '#ffd700', ox, oy)
px(hx + hw - 3, cY - 1, '#ffd700', ox, oy)
px(cx + hOff, cY - 1, '#ff2d7b', ox, oy)
px(hx + 2, cY, '#00f0ff', ox, oy)
px(hx + hw - 3, cY, '#00f0ff', ox, oy)
}
}
// ---- AURA (tier 4+) ----
if (tier >= 4 && !ko) {
const aCx = cx + hOff
const aCy = by + Math.floor(bh / 2)
const aR = Math.floor(bw / 2) + tier + 3
const dots = 6 + tier * 2
for (let i = 0; i < dots; i++) {
const ang = t * Math.PI * 2 + i * Math.PI * 2 / dots
const ax = aCx + Math.round(Math.cos(ang) * aR)
const ay = aCy + Math.round(Math.sin(ang) * (aR * 0.6))
if ((frame + i) % 3 !== 0) px(ax, ay, i % 2 === 0 ? pal.acc : pal.light, ox, oy)
}
if (tier >= 5) {
for (let p = 0; p < 4; p++) {
const pt = (t + p * 0.25) % 1
const py = ground - Math.round(pt * (ground - hy + 4))
const ppx = aCx + Math.round(Math.sin(py * 0.4 + p) * 3)
px(ppx, py, p % 2 === 0 ? pal.acc : '#ffd700', ox, oy)
}
}
}
// ---- HIT SPARK ----
if (hit && t > 0.2) {
const sx = cx + hOff + Math.floor(bw / 2) + 3
const sy = by + 2
px(sx, sy, '#ffffff', ox, oy); px(sx - 1, sy, '#ffff00', ox, oy); px(sx + 1, sy, '#ffff00', ox, oy)
px(sx, sy - 1, '#ffff00', ox, oy); px(sx, sy + 1, '#ffff00', ox, oy)
px(sx + 2, sy - 1, '#ff8800', ox, oy); px(sx + 2, sy + 1, '#ff8800', ox, oy)
px(sx - 1, sy - 1, '#ff4400', ox, oy)
}
// ---- KNOCKBACK STARS ----
if (knockback) {
for (let s = 0; s < 3; s++) {
const sa = t * Math.PI + s * 2.1
const sr = 5 + s * 3
const sx = cx + hOff - 2 + Math.round(Math.cos(sa) * sr)
const sy = hy - 2 + Math.round(Math.sin(sa) * sr * 0.5)
px(sx, sy, '#ffff00', ox, oy)
px(sx + 1, sy, '#ffffff', ox, oy)
}
}
// ---- WIN SPARKLES ----
if (tier >= 2 && win) {
for (let i = 0; i < tier + 2; i++) {
const sa = t * Math.PI * 2 + i * 1.5
const sr = 10 + tier * 2
const sx = cx + hOff + Math.round(Math.cos(sa) * sr)
const sy = by + Math.floor(bh / 2) + Math.round(Math.sin(sa) * sr * 0.5)
if ((frame + i) % 2 === 0) { px(sx, sy, '#ffd700', ox, oy); px(sx + 1, sy, '#ffffff', ox, oy) }
}
}
}
const entries = Object.entries(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++) drawFrame(f, row, pose, f, cfg.frames)
for (let f = cfg.frames; f < MAX_FRAMES; f++) drawFrame(f, row, pose, cfg.frames - 1, cfg.frames)
}
return canvas.toDataURL()
}
export function getBotColors(seed: string): { primary: string; secondary: string } {
let h = 0
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
const hue = Math.abs(h % 360)
return {
primary: `hsl(${hue}, 70%, 50%)`,
secondary: `hsl(${(hue + 140) % 360}, 80%, 60%)`,
}
}
export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS }