feat: mobile human vs AI — multiple choice, two-row VS bar, responsive arena
- Server-side choice generation for all 22 challenge types (factual + creative) - Mobile: 3 multiple-choice buttons replace keyboard input (4s timer) - Two-row VS bar on mobile (names row + HP bars row) in FightPage and FightViewer - Battle log hidden on mobile to maximize canvas visibility - Arena page responsive: stacking header, tighter fight cards, no name overflow - Profile page two-column desktop layout with contained sprite display - Human fighter sprites with baby growth system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1906821452
commit
4b46aaf643
@@ -1,5 +1,5 @@
|
||||
import kaplay from 'kaplay'
|
||||
import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites'
|
||||
import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, generateHumanFighterSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites'
|
||||
import {
|
||||
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
|
||||
sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect,
|
||||
@@ -231,25 +231,47 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
|
||||
let sheetA: string
|
||||
let sheetB: string
|
||||
|
||||
// Human fighters use their profile human character, not a bot sprite
|
||||
const winRateA = (botA.wins || 0) / Math.max(1, (botA.wins || 0) + (botA.losses || 0))
|
||||
const winRateB = (botB.wins || 0) / Math.max(1, (botB.wins || 0) + (botB.losses || 0))
|
||||
|
||||
try {
|
||||
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization)
|
||||
sheetA = botA.archetype === 'human'
|
||||
? generateHumanFighterSpriteSheet(botA.seed, botA.archetype, colorsA.primary, colorsA.secondary, winRateA)
|
||||
: generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization)
|
||||
} catch (err) {
|
||||
console.error('[FightScene] Failed to generate sprite for botA:', err)
|
||||
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
||||
}
|
||||
try {
|
||||
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization)
|
||||
sheetB = botB.archetype === 'human'
|
||||
? generateHumanFighterSpriteSheet(botB.seed, botB.archetype, colorsB.primary, colorsB.secondary, winRateB)
|
||||
: generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization)
|
||||
} catch (err) {
|
||||
console.error('[FightScene] Failed to generate sprite for botB:', err)
|
||||
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
||||
}
|
||||
|
||||
const isHumanA = botA.archetype === 'human'
|
||||
const isHumanB = botB.archetype === 'human'
|
||||
|
||||
// Await sprite loading to prevent race condition where scene starts before sprites decode
|
||||
await Promise.all([
|
||||
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
])
|
||||
|
||||
// Preload bot sprites for human fighters (used when humans morph into bots)
|
||||
if (isHumanA) {
|
||||
const botSheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
||||
k.loadSprite('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
}
|
||||
if (isHumanB) {
|
||||
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
||||
k.loadSprite('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
}
|
||||
|
||||
// Judge sprite (red lobster referee)
|
||||
const judgeSheet = generateJudgeSpriteSheet()
|
||||
const judgeAnims = {
|
||||
@@ -260,9 +282,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}
|
||||
await k.loadSprite('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
|
||||
|
||||
// Human sprites (the bot owner's silly human avatar)
|
||||
const winRateA = (botA.wins || 0) / Math.max(1, (botA.wins || 0) + (botA.losses || 0))
|
||||
const winRateB = (botB.wins || 0) / Math.max(1, (botB.wins || 0) + (botB.losses || 0))
|
||||
// Human sprites (the bot owner's silly human avatar for crowd)
|
||||
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
|
||||
const humanSheetB = generateHumanSpriteSheet(botB.seed, botB.archetype || 'standard', colorsB.primary, colorsB.secondary, winRateB)
|
||||
const humanAnims: Record<string, { from: number; to: number; loop: boolean; speed: number }> = {}
|
||||
@@ -5941,6 +5961,9 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
async function playMorph(fighter: any, side: 'a' | 'b', morphIndex: number) {
|
||||
const savedScaleX = fighter.scale.x
|
||||
const savedScaleY = fighter.scale.y
|
||||
const isHumanFighter = side === 'a' ? isHumanA : isHumanB
|
||||
const morphSpriteKey = side === 'a' ? 'botA_morph' : 'botB_morph'
|
||||
const normalSpriteKey = side === 'a' ? 'botA' : 'botB'
|
||||
|
||||
// Flash + announcement
|
||||
screenFlash('#ffffff', 0.15)
|
||||
@@ -5956,6 +5979,11 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1))
|
||||
}, k.easings.easeOutBack)
|
||||
|
||||
// Human fighters morph into bots (opposite direction!)
|
||||
if (isHumanFighter) {
|
||||
fighter.use(k.sprite(morphSpriteKey, { anim: 'idle' }))
|
||||
}
|
||||
|
||||
// Apply the morph visuals
|
||||
morphAppliers[morphIndex](fighter, side)
|
||||
|
||||
@@ -5970,6 +5998,10 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
screenFlash(theme.accent, 0.1)
|
||||
fighter.color = safeColor(k,'#ffffff')
|
||||
fighter.opacity = 1
|
||||
// Revert human fighters back to their human sprite
|
||||
if (isHumanFighter) {
|
||||
fighter.use(k.sprite(normalSpriteKey, { anim: 'idle' }))
|
||||
}
|
||||
await k.tween(0, 1, 0.2, (t) => {
|
||||
fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor)
|
||||
fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FRAME_SIZE, INTERNAL, SCALE, ANIMATIONS, TOTAL_ROWS, MAX_FRAMES } from './constants'
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1215,6 +1217,459 @@ export function generateHumanSpriteSheet(
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human character sprite sheet in the BOT format (96px frame, 48x48 internal)
|
||||
* with all 8 fighting animations. This allows humans to fight in the arena while looking
|
||||
* like their profile character (same seed/archetype/winRate).
|
||||
*/
|
||||
export function generateHumanFighterSpriteSheet(
|
||||
seed: string,
|
||||
archetype: string,
|
||||
primaryColor: string,
|
||||
secondaryColor: string,
|
||||
winRate: number = 0.5,
|
||||
): 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 I = INTERNAL // 48
|
||||
const S = SCALE // 2
|
||||
const rng = seededRng(seed)
|
||||
|
||||
const skinTone = SKIN_TONES[Math.floor(rng() * SKIN_TONES.length)]
|
||||
const skinDk = darken(skinTone, 20)
|
||||
const skinLt = lighten(skinTone, 10)
|
||||
const hairColor = HAIR_COLORS[Math.floor(rng() * HAIR_COLORS.length)]
|
||||
const shirtColor = primaryColor
|
||||
const shirtDk = darken(primaryColor, 15)
|
||||
const pantsColor = secondaryColor
|
||||
const pantsDk = darken(secondaryColor, 15)
|
||||
const out = '#0a0a0a'
|
||||
|
||||
const wr = Math.max(0, Math.min(1, winRate))
|
||||
const isBaby = wr < 0.1
|
||||
const isToddler = wr >= 0.1 && wr < 0.25
|
||||
const isKid = wr >= 0.25 && wr < 0.4
|
||||
const isShort = isBaby || isToddler || isKid
|
||||
const isFat = isBaby || isToddler ? true : wr < 0.5 ? rng() > 0.4 : false
|
||||
const isSwole = wr > 0.75
|
||||
const isSuperHero = wr > 0.85
|
||||
const hasDiaper = isBaby
|
||||
const hasCape = isSuperHero
|
||||
const hasGlasses = isBaby || isToddler ? false : wr < 0.4 ? true : rng() > 0.5
|
||||
|
||||
const costume = getCostume(archetype)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function drawFighterFrame(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'
|
||||
|
||||
// Body dimensions at 48x48 scale (adapted from human proportions)
|
||||
const bodyW = isFat ? 14 : isSwole ? 12 : isShort ? 8 : 10
|
||||
const bodyH = isShort ? 8 : isSwole ? 11 : 10
|
||||
const headW = isFat ? 10 : isShort ? 10 : isSwole ? 10 : 9
|
||||
const headH = isFat ? 9 : isShort ? 9 : 8
|
||||
const legH = isShort ? 6 : isSwole ? 10 : 9
|
||||
const legW = isFat ? 5 : isSwole ? 4 : 3
|
||||
const armW = isSwole ? 3 : 2
|
||||
const armH = isShort ? 5 : isSwole ? 8 : 7
|
||||
|
||||
const cx = 24 // center x in 48x48
|
||||
const ground = 42
|
||||
|
||||
const feetY = ground - 2
|
||||
const legsTop = feetY - legH
|
||||
const bodyTop = legsTop - bodyH
|
||||
const headTop = bodyTop - headH - 1
|
||||
|
||||
// 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
|
||||
const globalY = -kbLift
|
||||
|
||||
// Shadow
|
||||
const shadowW = Math.floor(bodyW * 0.7)
|
||||
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)
|
||||
}
|
||||
|
||||
// Cape (behind everything for superhero)
|
||||
if (hasCape && !ko) {
|
||||
const capeBx = cx - Math.floor(bodyW / 2) - 1 + hOff
|
||||
for (let iy = bodyTop + vBounce + globalY; iy < feetY + 4; iy++) {
|
||||
const flutter = knockback ? Math.round(Math.sin(t * Math.PI * 3 + iy * 0.3) * 2) : 0
|
||||
for (let ix = capeBx - flutter; ix < capeBx + bodyW + 2 + flutter; ix++) {
|
||||
px(ix, iy, (iy + ix) % 3 === 0 ? '#881111' : '#cc2222', ox, oy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diaper (babies, behind legs)
|
||||
if (hasDiaper) {
|
||||
const dw = bodyW + 2
|
||||
const dx = cx - Math.floor(dw / 2) + hOff
|
||||
fill(dx, legsTop + vBounce + globalY - 1, dw, 4, '#f5f5f0', ox, oy)
|
||||
px(cx + hOff, legsTop + vBounce + globalY, '#66aadd', 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
|
||||
const legColor = hasDiaper || isToddler ? skinTone : costume.onesie ? (costume.hat || shirtColor) : pantsColor
|
||||
const legColorDk = hasDiaper || isToddler ? skinDk : costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDk
|
||||
|
||||
if (ko) {
|
||||
box(ll - 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), legColorDk, ox, oy)
|
||||
box(rl + 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), legColorDk, ox, oy)
|
||||
} else if (kick) {
|
||||
box(ll, legsTop + globalY, legW, legH, legColor, ox, oy)
|
||||
const kickExt = Math.round(Math.sin(t * Math.PI) * (legH + 2))
|
||||
box(rl, legsTop + Math.floor(legH * 0.3) + globalY, kickExt + legW, legW, legColor, ox, oy)
|
||||
if (kickExt > 2) fill(rl + kickExt + legW, legsTop + Math.floor(legH * 0.3) - 1 + globalY, 3, 3, skinTone, ox, oy)
|
||||
} else if (knockback) {
|
||||
box(ll + Math.round(t * -3), legsTop + globalY + 2, legW, legH - 2, legColor, ox, oy)
|
||||
box(rl + Math.round(t * -2), legsTop + globalY + 3, legW, legH - 3, legColor, ox, oy)
|
||||
} else {
|
||||
box(ll, legsTop + vBounce + globalY, legW, legH, legColor, ox, oy)
|
||||
box(rl + (atk ? Math.round(t * 2) : 0), legsTop + vBounce + globalY, legW, legH, legColor, ox, oy)
|
||||
}
|
||||
|
||||
// Shoes
|
||||
if (!ko && !knockback) {
|
||||
fill(ll - 1, feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy)
|
||||
fill(rl - 1 + (atk ? Math.round(t * 2) : 0), feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy)
|
||||
}
|
||||
|
||||
// Body
|
||||
const bx = cx - Math.floor(bodyW / 2) + hOff
|
||||
const by = bodyTop + vBounce + koSlump + globalY
|
||||
const torsoColor = costume.onesie ? (costume.hat || shirtColor) : shirtColor
|
||||
const torsoDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : shirtDk
|
||||
box(bx, by, bodyW, bodyH, torsoColor, ox, oy)
|
||||
// Shading
|
||||
for (let iy = by + 1; iy < by + bodyH - 1; iy++) {
|
||||
px(bx + bodyW - 1, iy, torsoDk, ox, oy)
|
||||
px(bx + 1, iy, lighten(torsoColor, 8), ox, oy)
|
||||
}
|
||||
// Zipper for onesies
|
||||
if (costume.onesie) {
|
||||
for (let iy = by + 1; iy < by + bodyH - 1; iy++) px(cx + hOff, iy, darken(torsoColor, 20), ox, oy)
|
||||
}
|
||||
// Super mode: show skin and abs hint
|
||||
if (isSwole && !costume.onesie) {
|
||||
fill(bx + 2, by + 2, bodyW - 4, bodyH - 4, skinTone, ox, oy)
|
||||
px(cx + hOff, by + 3, skinDk, ox, oy)
|
||||
px(cx + hOff, by + 5, skinDk, ox, oy)
|
||||
px(cx + hOff, by + 7, skinDk, ox, oy)
|
||||
}
|
||||
// Belt
|
||||
if (!costume.onesie) {
|
||||
fill(bx, by + bodyH - 2, bodyW, 2, isSuperHero ? '#ffd700' : '#443322', ox, oy)
|
||||
px(cx + hOff, by + bodyH - 2, '#ffcc00', ox, oy)
|
||||
}
|
||||
|
||||
// Arms
|
||||
const armAttach = by + 2 + vBounce
|
||||
const armLx = bx - armW + hOff
|
||||
const armRx = bx + bodyW + hOff
|
||||
const armColor = costume.onesie ? (costume.hat || shirtColor) : skinTone
|
||||
const armColorDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : skinDk
|
||||
|
||||
if (ko) {
|
||||
fill(armLx - 3, armAttach + koSlump + 2, armH, armW, armColor, ox, oy)
|
||||
fill(armRx + 2, armAttach + koSlump + 2, armH, armW, armColor, ox, oy)
|
||||
} else if (knockback) {
|
||||
box(armLx - Math.round(t * 4), armAttach + globalY - 2, armW, armH + 1, armColor, ox, oy)
|
||||
box(armRx - Math.round(t * 3), armAttach + globalY - 1, armW, armH, armColor, ox, oy)
|
||||
} else if (atk) {
|
||||
box(armLx, armAttach + 2, armW, armH - 2, armColor, ox, oy)
|
||||
const reach = Math.round(Math.sin(t * Math.PI) * (armH + 2))
|
||||
if (reach > 0) {
|
||||
box(armRx, armAttach - 1, reach + armW, armW + 1, armColor, ox, oy)
|
||||
// Fist
|
||||
fill(armRx + reach + armW, armAttach - 2, 3, 3, skinTone, ox, oy)
|
||||
}
|
||||
} else if (kick) {
|
||||
box(armLx, armAttach, armW, armH - 1, armColor, ox, oy)
|
||||
box(armRx, armAttach, armW, armH - 1, armColor, ox, oy)
|
||||
} else if (special) {
|
||||
box(armLx, armAttach, armW, armH, armColor, ox, oy)
|
||||
const ext = Math.round(Math.sin(t * Math.PI) * (armH + 2))
|
||||
box(armRx, armAttach - 2, ext + armW + 2, armW, armColor, ox, oy)
|
||||
// Projectile (energy ball)
|
||||
if (t > 0.3) {
|
||||
const projX = armRx + ext + armW + 3 + Math.round(t * 8)
|
||||
const projY = armAttach - 2
|
||||
px(projX, projY, '#ff4400', ox, oy)
|
||||
px(projX + 1, projY, '#ff6600', ox, oy)
|
||||
px(projX, projY + 1, '#ffaa00', ox, oy)
|
||||
px(projX + 1, projY + 1, '#ffcc00', ox, oy)
|
||||
px(projX + 2, projY, '#ffcc00', ox, oy)
|
||||
}
|
||||
} else if (win) {
|
||||
box(armLx, armAttach + 2, armW, armH - 1, armColor, ox, oy)
|
||||
box(armRx, armAttach - armH + bounce, armW, armH, armColor, ox, oy)
|
||||
// Fist pump up
|
||||
fill(armRx - 1, armAttach - armH + bounce - 2, armW + 2, 2, skinTone, ox, oy)
|
||||
} else {
|
||||
const sw = idle ? bounce : hit ? 1 : 0
|
||||
box(armLx, armAttach + sw + globalY, armW, armH, armColor, ox, oy)
|
||||
box(armRx, armAttach - sw + globalY, armW, armH, armColor, ox, oy)
|
||||
// Hands
|
||||
fill(armLx, armAttach + sw + armH + globalY, armW + 1, 2, skinTone, ox, oy)
|
||||
fill(armRx - 1, armAttach - sw + armH + globalY, armW + 1, 2, skinTone, ox, oy)
|
||||
}
|
||||
|
||||
// Head
|
||||
const hx = cx - Math.floor(headW / 2) + hOff
|
||||
const hy = headTop + vBounce + koSlump + globalY
|
||||
const faceColor = costume.facePaint || skinTone
|
||||
const faceDk = costume.facePaint ? darken(costume.facePaint, 15) : skinDk
|
||||
const faceLt = costume.facePaint ? lighten(costume.facePaint, 10) : skinLt
|
||||
|
||||
box(hx, hy, headW, headH, faceColor, ox, oy)
|
||||
// Head shading
|
||||
for (let iy = hy + 1; iy < hy + headH - 1; iy++) {
|
||||
px(hx + headW - 1, iy, faceDk, ox, oy)
|
||||
px(hx + 1, iy, faceLt, ox, oy)
|
||||
}
|
||||
|
||||
// Ears
|
||||
if (!costume.facePaint) {
|
||||
px(hx - 1, hy + Math.floor(headH * 0.4), skinTone, ox, oy)
|
||||
px(hx + headW, hy + Math.floor(headH * 0.4), skinTone, ox, oy)
|
||||
}
|
||||
|
||||
// Hair (baby = tuft, otherwise full)
|
||||
if (isBaby) {
|
||||
px(cx + hOff, hy - 1, hairColor, ox, oy)
|
||||
px(cx + hOff - 1, hy - 1, hairColor, ox, oy)
|
||||
px(cx + hOff + 1, hy, hairColor, ox, oy)
|
||||
} else if (!costume.onesie || costume.type !== 'animal') {
|
||||
for (let ix = hx; ix < hx + headW; ix++) {
|
||||
px(ix, hy, hairColor, ox, oy)
|
||||
px(ix, hy + 1, hairColor, ox, oy)
|
||||
}
|
||||
for (let ix = hx + 1; ix < hx + headW - 1; ix++) px(ix, hy - 1, hairColor, ox, oy)
|
||||
px(hx + 2, hy, lighten(hairColor, 15), ox, oy)
|
||||
}
|
||||
|
||||
// Eyes
|
||||
const eyeY = hy + Math.floor(headH * 0.3)
|
||||
const leX = hx + 2
|
||||
const reX = hx + headW - 4
|
||||
|
||||
if (ko) {
|
||||
px(leX, eyeY, '#ff0000', ox, oy); px(leX + 1, eyeY + 1, '#ff0000', ox, oy)
|
||||
px(leX + 1, eyeY, '#330000', ox, oy); px(leX, eyeY + 1, '#330000', ox, oy)
|
||||
px(reX, eyeY, '#ff0000', ox, oy); px(reX + 1, eyeY + 1, '#ff0000', ox, oy)
|
||||
px(reX + 1, eyeY, '#330000', ox, oy); px(reX, eyeY + 1, '#330000', ox, oy)
|
||||
} else if (knockback) {
|
||||
fill(leX, eyeY, 2, 2, '#ffffff', ox, oy)
|
||||
fill(reX, eyeY, 2, 2, '#ffffff', ox, oy)
|
||||
px(leX, eyeY + 1, '#000000', ox, oy)
|
||||
px(reX, eyeY + 1, '#000000', ox, oy)
|
||||
} else if (isBaby || isToddler) {
|
||||
// Big sparkly baby eyes
|
||||
fill(leX, eyeY, 3, 3, '#ffffff', ox, oy)
|
||||
fill(reX, eyeY, 3, 3, '#ffffff', ox, oy)
|
||||
fill(leX + 1, eyeY + 1, 2, 2, '#5588cc', ox, oy)
|
||||
fill(reX + 1, eyeY + 1, 2, 2, '#5588cc', ox, oy)
|
||||
px(leX + 1, eyeY + 1, '#000000', ox, oy)
|
||||
px(reX + 1, eyeY + 1, '#000000', ox, oy)
|
||||
px(leX, eyeY, '#ffffff', ox, oy)
|
||||
px(reX, eyeY, '#ffffff', ox, oy)
|
||||
} else {
|
||||
fill(leX, eyeY, 2, 2, '#ffffff', ox, oy)
|
||||
fill(reX, eyeY, 2, 2, '#ffffff', ox, oy)
|
||||
px(leX + (atk || kick || special ? 1 : 0), eyeY + 1, '#000000', ox, oy)
|
||||
px(reX + (atk || kick || special ? 1 : 0), eyeY + 1, '#000000', ox, oy)
|
||||
// Angry brows for attack/kick/special
|
||||
if (atk || kick || special) {
|
||||
px(leX, eyeY - 1, out, ox, oy); px(leX + 1, eyeY - 1, out, ox, oy)
|
||||
px(reX, eyeY - 1, out, ox, oy); px(reX + 1, eyeY - 1, out, ox, oy)
|
||||
}
|
||||
}
|
||||
|
||||
// Glasses
|
||||
if (hasGlasses && !ko) {
|
||||
for (let ix = leX - 1; ix <= reX + 2; ix++) px(ix, eyeY - 1, '#444444', ox, oy)
|
||||
px(leX - 1, eyeY, '#444444', ox, oy); px(leX + 2, eyeY, '#444444', ox, oy)
|
||||
px(reX - 1, eyeY, '#444444', ox, oy); px(reX + 2, eyeY, '#444444', ox, oy)
|
||||
}
|
||||
|
||||
// Nose
|
||||
px(cx + hOff, hy + Math.floor(headH * 0.55), skinDk, ox, oy)
|
||||
|
||||
// Mouth
|
||||
const mY = hy + Math.floor(headH * 0.7)
|
||||
if (win) {
|
||||
px(cx + hOff - 1, mY, out, ox, oy); fill(cx + hOff, mY, 2, 1, '#ffffff', ox, oy); px(cx + hOff + 2, mY, out, ox, oy)
|
||||
} else if (ko || knockback) {
|
||||
fill(cx + hOff - 1, mY, 2, 2, '#000000', ox, oy)
|
||||
} else if (atk || kick || special) {
|
||||
fill(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy)
|
||||
} else {
|
||||
px(cx + hOff - 1, mY, out, ox, oy); px(cx + hOff, mY, out, ox, oy)
|
||||
}
|
||||
|
||||
// Baby rosy cheeks
|
||||
if (isBaby || isToddler) {
|
||||
px(hx + 1, hy + Math.floor(headH * 0.55), '#ffaaaa', ox, oy)
|
||||
px(hx + headW - 2, hy + Math.floor(headH * 0.55), '#ffaaaa', ox, oy)
|
||||
}
|
||||
|
||||
// Costume hat
|
||||
const hatColor = costume.hat || hairColor
|
||||
const hatDk = darken(hatColor, 15)
|
||||
const hatLt = lighten(hatColor, 15)
|
||||
if (costume.hatShape === 'ears') {
|
||||
fill(hx, hy - 2, 2, 3, hatColor, ox, oy)
|
||||
fill(hx + headW - 2, hy - 2, 2, 3, hatColor, ox, oy)
|
||||
px(hx, hy - 1, hatLt, ox, oy); px(hx + headW - 2, hy - 1, hatLt, ox, oy)
|
||||
} else if (costume.hatShape === 'helmet') {
|
||||
for (let ix = hx - 1; ix < hx + headW + 1; ix++) { px(ix, hy - 2, hatColor, ox, oy); px(ix, hy - 1, hatColor, ox, oy) }
|
||||
px(hx + 1, hy - 2, hatLt, ox, oy)
|
||||
} else if (costume.hatShape === 'pointy') {
|
||||
for (let h = 0; h < 6; h++) {
|
||||
const w = Math.max(1, Math.floor(headW * (1 - h / 6)))
|
||||
const sx = cx + hOff - Math.floor(w / 2)
|
||||
for (let ix = sx; ix < sx + w; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
|
||||
}
|
||||
px(cx + hOff, hy - 7, '#ffcc44', ox, oy)
|
||||
} else if (costume.hatShape === 'wide') {
|
||||
for (let ix = hx - 3; ix < hx + headW + 3; ix++) { px(ix, hy - 2, hatColor, ox, oy); px(ix, hy - 1, hatColor, ox, oy) }
|
||||
for (let ix = hx - 1; ix < hx + headW + 1; ix++) { px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 4, hatColor, ox, oy) }
|
||||
} else if (costume.hatShape === 'tall') {
|
||||
for (let h = 0; h < 6; h++) for (let ix = hx + 1; ix < hx + headW - 1; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
|
||||
fill(hx + 2, hy - 5, headW - 4, 1, hatLt, ox, oy)
|
||||
} else if (costume.hatShape === 'horns') {
|
||||
for (let h = 0; h < 3; h++) {
|
||||
px(hx - h, hy - 1 - h, hatColor, ox, oy)
|
||||
px(hx + headW - 1 + h, hy - 1 - h, hatColor, ox, oy)
|
||||
}
|
||||
px(hx - 3, hy - 4, hatLt, ox, oy); px(hx + headW + 2, hy - 4, hatLt, ox, oy)
|
||||
} else if (costume.hatShape === 'antenna') {
|
||||
fill(hx + 1, hy - 1, headW - 2, 1, '#888888', ox, oy)
|
||||
for (let h = 0; h < 4; h++) px(hx + 2, hy - 2 - h, '#888888', ox, oy)
|
||||
for (let h = 0; h < 4; h++) px(hx + headW - 3, hy - 2 - h, '#888888', ox, oy)
|
||||
fill(hx + 1, hy - 6, 2, 2, '#ff2222', ox, oy)
|
||||
fill(hx + headW - 4, hy - 6, 2, 2, '#ff2222', ox, oy)
|
||||
} else if (costume.hatShape === 'cone') {
|
||||
for (let h = 0; h < 6; h++) {
|
||||
const w = Math.max(1, 6 - h)
|
||||
const sx = cx + hOff - Math.floor(w / 2)
|
||||
const stripe = h % 2 === 0 ? '#ffffff' : hatColor
|
||||
for (let ix = sx; ix < sx + w; ix++) px(ix, hy - 1 - h, stripe, ox, oy)
|
||||
}
|
||||
} else if (costume.hatShape === 'crown') {
|
||||
for (let ix = hx; ix < hx + headW; ix++) px(ix, hy - 1, '#ffd700', ox, oy)
|
||||
px(hx + 1, hy - 2, '#ffd700', ox, oy); px(cx + hOff, hy - 3, '#ffd700', ox, oy); px(hx + headW - 2, hy - 2, '#ffd700', ox, oy)
|
||||
}
|
||||
|
||||
// Tail
|
||||
if (costume.tail && !ko) {
|
||||
const tailColor = costume.hat || shirtColor
|
||||
for (let i = 0; i < 4; i++) {
|
||||
px(bx - 1 - i, by + bodyH - 2 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2)), tailColor, ox, oy)
|
||||
}
|
||||
}
|
||||
|
||||
// Onesie hood (for animal costumes, covers hair)
|
||||
if (costume.onesie && costume.type === 'animal') {
|
||||
const oc = costume.hat || shirtColor
|
||||
fill(hx, hy, headW, 2, oc, ox, oy)
|
||||
fill(hx - 1, hy, 1, headH - 2, oc, ox, oy)
|
||||
fill(hx + headW, hy, 1, headH - 2, oc, ox, oy)
|
||||
}
|
||||
|
||||
// Hit spark
|
||||
if (hit && t > 0.2) {
|
||||
const sx = cx + hOff + Math.floor(bodyW / 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)
|
||||
}
|
||||
|
||||
// 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 (win) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const sa = t * Math.PI * 2 + i * 1.5
|
||||
const sr = 10
|
||||
const sx = cx + hOff + Math.round(Math.cos(sa) * sr)
|
||||
const sy = by + Math.floor(bodyH / 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) }
|
||||
}
|
||||
}
|
||||
|
||||
// Super hero glow
|
||||
if (isSuperHero && !ko) {
|
||||
for (let a = 0; a < 8; a++) {
|
||||
const angle = a * Math.PI * 2 / 8 + t * Math.PI * 0.5
|
||||
const r = Math.floor(bodyW / 2) + 6
|
||||
const gx = cx + hOff + Math.round(Math.cos(angle) * r)
|
||||
const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(angle) * r * 0.6)
|
||||
if ((frame + a) % 3 !== 0) px(gx, gy, a % 2 === 0 ? '#ffd700' : '#ffcc4460', 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++) drawFighterFrame(f, row, pose, f, cfg.frames)
|
||||
for (let f = cfg.frames; f < MAX_FRAMES; f++) drawFighterFrame(f, row, pose, cfg.frames - 1, cfg.frames)
|
||||
}
|
||||
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
|
||||
function getCostume(archetype: string): CostumeConfig {
|
||||
const raw = COSTUME_MAP[archetype] || {}
|
||||
return {
|
||||
|
||||
@@ -7,7 +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 { generateHumanSpriteSheet, generateHumanFighterSpriteSheet, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, HUMAN_FRAME_SIZE } from './human'
|
||||
export { archetypes, rollArchetype } from './archetypes'
|
||||
|
||||
export interface SpriteCustomization {
|
||||
|
||||
Reference in New Issue
Block a user