feat: human vs AI mode — live typing challenges, baby growth system, SSE rounds
- Add choose-mode step: "I BUILD BOTS" vs "I FIGHT MYSELF" paths - Human registration with baby avatar picker, no webhook required - Live fight scene with SSE round streaming and real-time challenge UI - 5-second timer per round, submit answers via browser - Baby → toddler → kid → teen → adult → hero → super growth stages - Huge sparkly baby eyes, diapers, pacifiers, bibs, rattles, rosy cheeks - Speech bubble positioning fix (pushed to outside of sprite) - Canvas text rendering via offscreen canvas to bypass kaplay color issues - Voice timing improvements: await pauses between voice lines and hits - 30 devastating announcement lines, 15 critical/hit word variants - Orchestrator human player detection + waitForHumanResponse system - Server endpoints: GET /challenge/:botId, POST /respond/:botId - Human player auth: register-human route, isHuman flag on login Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
56785cfdea
commit
8448f1d823
@@ -6083,10 +6083,10 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
const bubbleH = lines.length * lineH + padY * 2
|
||||
const tailSize = 10
|
||||
|
||||
// Position — offset away from center so bubbles don't overlap
|
||||
const offsetX = side === 'a' ? -bubbleW * 0.3 : bubbleW * 0.3
|
||||
const bx = fighter.pos.x + offsetX
|
||||
const by = Math.max(8, fighter.pos.y - 85 - bubbleH)
|
||||
// Position — push bubble to the outside of the bot so it doesn't overlap the sprite
|
||||
const offsetX = side === 'a' ? -bubbleW * 0.7 : bubbleW * 0.7
|
||||
const bx = Math.max(bubbleW / 2 + 4, Math.min(W - bubbleW / 2 - 4, fighter.pos.x + offsetX))
|
||||
const by = Math.max(8, fighter.pos.y - 75 - bubbleH)
|
||||
|
||||
const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
|
||||
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
|
||||
@@ -6130,15 +6130,27 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// Text lines
|
||||
// Render text to an offscreen canvas, then display as a sprite
|
||||
// This bypasses kaplay's text rendering which has color issues
|
||||
const textCanvas = document.createElement('canvas')
|
||||
textCanvas.width = bubbleW
|
||||
textCanvas.height = bubbleH
|
||||
const tc = textCanvas.getContext('2d')!
|
||||
tc.fillStyle = '#ffffff'
|
||||
tc.font = `${fontSize}px monospace`
|
||||
tc.textBaseline = 'top'
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
tc.fillText(lines[i], padX, padY + i * lineH + 2)
|
||||
}
|
||||
const spriteKey = `bubble_${side}_${Date.now()}`
|
||||
k.loadSprite(spriteKey, textCanvas.toDataURL()).then(() => {
|
||||
const tEl = k.add([
|
||||
k.text(safeText(lines[i]), { size: fontSize, font: 'monospace' }),
|
||||
k.pos(bx - bubbleW / 2 + padX, by + padY + i * lineH + 2),
|
||||
k.color(255, 255, 255), k.opacity(1), k.z(57),
|
||||
k.sprite(spriteKey),
|
||||
k.pos(bx - bubbleW / 2, by),
|
||||
k.opacity(1), k.z(57),
|
||||
])
|
||||
allEls.push(tEl)
|
||||
}
|
||||
})
|
||||
|
||||
// Border glow pulse
|
||||
border.onUpdate(() => {
|
||||
@@ -7002,7 +7014,8 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
const savedScaleBX = fB?.scale.x
|
||||
const savedScaleBY = fB?.scale.y
|
||||
|
||||
// Voice: round start announcements (30% chance)
|
||||
// Voice: round start announcements (30% chance) — fires BEFORE animation starts
|
||||
let didChallengeVoice = false
|
||||
if (Math.random() < 0.3) {
|
||||
const challengeVoice: Record<string, () => void> = {
|
||||
roast_battle: () => announceHype('TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!'),
|
||||
@@ -7024,9 +7037,11 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'),
|
||||
}
|
||||
const voiceFn = challengeVoice[event.challengeType]
|
||||
if (voiceFn) voiceFn()
|
||||
else if (Math.random() < 0.5) announceRoundHype()
|
||||
if (voiceFn) { voiceFn(); didChallengeVoice = true }
|
||||
else if (Math.random() < 0.5) { announceRoundHype(); didChallengeVoice = true }
|
||||
}
|
||||
// Give the voice line time to finish before the first punch lands
|
||||
if (didChallengeVoice) await k.wait(0.8)
|
||||
|
||||
// Visual chaos: schizo cut before round (20% chance)
|
||||
if (Math.random() < 0.2 && fA && fB) {
|
||||
@@ -7120,7 +7135,13 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
'CRITICAL! THEIR INSURANCE DOESN\'T COVER THIS!',
|
||||
'CRITICAL HIT! THAT WAS PERSONAL!',
|
||||
'CRITICAL! EVEN THE REFEREE FELT THAT!',
|
||||
][Math.floor(Math.random() * 5)])
|
||||
'CRITICAL! SEND THE AMBULANCE! ACTUALLY SEND TWO!',
|
||||
'CRITICAL HIT! THAT BOT\'S WARRANTY JUST EXPIRED!',
|
||||
'CRITICAL! THE SCOREBOARD CAN\'T EVEN HANDLE THIS!',
|
||||
'OHHH CRITICAL! RIGHT IN THE CIRCUITS!',
|
||||
'CRITICAL! THAT\'S NOT A HIT, THAT\'S A STATEMENT!',
|
||||
][Math.floor(Math.random() * 10)])
|
||||
await k.wait(0.4) // let the voice line land before the hit animation
|
||||
announceCrowdReaction('gasp')
|
||||
// RGB glitch + hyperspeed lines on critical final blow
|
||||
glitchRGB(0.3)
|
||||
@@ -7212,47 +7233,77 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}
|
||||
|
||||
// Update combos
|
||||
const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3)
|
||||
if (aWon) {
|
||||
comboA++; comboB = 0
|
||||
if (comboA >= 3) { fanfareCombo(comboA); announceHype(`${comboA} hit combo!`); announceCrowdReaction('cheer') }
|
||||
} else if (bWon) {
|
||||
comboB++; comboA = 0
|
||||
if (comboB >= 3) { fanfareCombo(comboB); announceHype(`${comboB} hit combo!`); announceCrowdReaction('cheer') }
|
||||
} else {
|
||||
comboA = 0; comboB = 0
|
||||
}
|
||||
|
||||
// Prioritize: devastating > combo > regular crowd reaction (only one speech per round-end)
|
||||
if (isCritical && (aWon || bWon)) {
|
||||
fanfareDevastating()
|
||||
announceDramatic([
|
||||
'DEVASTATING! SOMEBODY CALL A DOCTOR!',
|
||||
'DEVASTATING! THAT BOT HAS A FAMILY!',
|
||||
'ABSOLUTELY DEVASTATING! THE CROWD IS LOSING IT!',
|
||||
'DEVASTATING! EVEN THE JANITOR FELT THAT!',
|
||||
'THAT WAS DEVASTATING AND I\'M NOT EVEN BEING DRAMATIC!',
|
||||
][Math.floor(Math.random() * 5)])
|
||||
if (hasCombo) { fanfareCombo(aWon ? comboA : comboB) } // SFX only, no speech
|
||||
const devastatingLines = [
|
||||
'SOMEBODY CALL A DOCTOR!',
|
||||
'THAT BOT HAS A FAMILY!',
|
||||
'THE CROWD IS LOSING IT!',
|
||||
'EVEN THE JANITOR FELT THAT!',
|
||||
'AND I\'M NOT EVEN BEING DRAMATIC!',
|
||||
'CALL THE FIRE DEPARTMENT!',
|
||||
'I CAN\'T BELIEVE WHAT I JUST WITNESSED!',
|
||||
'THAT\'S GOTTA VOID THE WARRANTY!',
|
||||
'SOMEBODY CHECK ON THAT BOT\'S NEXT OF KIN!',
|
||||
'THE ARENA IS SHAKING!',
|
||||
'THAT WAS ABSOLUTELY RUTHLESS!',
|
||||
'HIS MOTHERBOARD JUST CALLED CRYING!',
|
||||
'EVEN THE REPLAYS ARE SCARED!',
|
||||
'THAT\'S ONE FOR THE HISTORY BOOKS!',
|
||||
'DID ANYONE ELSE FEEL THE EARTH MOVE?',
|
||||
'I\'M GETTING CHILLS AND I\'M MADE OF CODE!',
|
||||
'THE CROWD JUST WENT SILENT... NOW THEY\'RE SCREAMING!',
|
||||
'SOMEBODY GET THE STRETCHER!',
|
||||
'THAT WAS PURE DISRESPECT!',
|
||||
'I NEED A MOMENT TO PROCESS WHAT JUST HAPPENED!',
|
||||
'THE OTHER BOT IS HAVING AN EXISTENTIAL CRISIS!',
|
||||
'THAT HIT REGISTERED ON THE RICHTER SCALE!',
|
||||
'NO RECOVERY FROM THAT ONE!',
|
||||
'I THINK I SAW A PIXEL FLY OFF!',
|
||||
'THE SPECTATORS ARE CALLING THEIR LAWYERS!',
|
||||
'SOMEONE NOTIFY THE UNITED NATIONS!',
|
||||
'THAT SHOULD BE CLASSIFIED AS A WAR CRIME!',
|
||||
'MY GRANDMA COULD FEEL THAT AND SHE\'S OFFLINE!',
|
||||
'THE ARENA INSURANCE PREMIUMS JUST WENT UP!',
|
||||
'THAT BOT IS RECONSIDERING ITS LIFE CHOICES!',
|
||||
]
|
||||
announceDramatic(devastatingLines[Math.floor(Math.random() * devastatingLines.length)])
|
||||
await k.wait(0.5) // let the voice line play before crowd reacts
|
||||
announceCrowdReaction('ooh')
|
||||
// Dimensional shift on devastating rounds (60%)
|
||||
if (Math.random() < 0.6) dimensionalShift(0.5)
|
||||
} else if (hasCombo) {
|
||||
// Non-critical combo
|
||||
fanfareCombo(aWon ? comboA : comboB)
|
||||
announceHype(`${aWon ? comboA : comboB} hit combo!`)
|
||||
await k.wait(0.4)
|
||||
announceCrowdReaction('cheer')
|
||||
} else if (aWon || bWon) {
|
||||
// Regular round win: crowd reacts (40%)
|
||||
// Regular round win: crowd reacts (40%) — only SFX, no speech
|
||||
if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause')
|
||||
}
|
||||
|
||||
// Emotion: heartfelt announcer moment (8% chance on normal rounds)
|
||||
if (Math.random() < 0.08) {
|
||||
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
||||
}
|
||||
|
||||
// Crowd sympathy for the loser on devastating rounds (25%)
|
||||
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
|
||||
const loser = k.get(aWon ? 'fighterB' : 'fighterA')[0]
|
||||
if (loser) {
|
||||
announceSilly(crowdSympathyLines[Math.floor(Math.random() * crowdSympathyLines.length)])
|
||||
spawnCrowdSigns(3, '#4488ff', '\u2665')
|
||||
// Heartfelt announcer moment (10% chance on normal, non-critical rounds)
|
||||
if (Math.random() < 0.1) {
|
||||
await k.wait(0.3)
|
||||
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
||||
}
|
||||
}
|
||||
|
||||
// Crowd sympathy for the loser on devastating rounds (25%) — visual only, no speech (devastating voice is still playing)
|
||||
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
|
||||
spawnCrowdSigns(3, '#4488ff', '\u2665')
|
||||
}
|
||||
|
||||
// Crowd signs on big combos (20%)
|
||||
if ((comboA >= 3 || comboB >= 3) && Math.random() < 0.2) {
|
||||
const comboName = comboA >= 3 ? botA.name : botB.name
|
||||
@@ -8130,13 +8181,13 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}
|
||||
|
||||
// === Common ending: KO + celebration ===
|
||||
announceFinishHim()
|
||||
sfxKO()
|
||||
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
|
||||
spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff2d2d')
|
||||
k.shake(18); sfxExplosion()
|
||||
await k.wait(0.2)
|
||||
await k.wait(0.3)
|
||||
loser.play('ko')
|
||||
await k.wait(0.4) // pause for visual impact before fatality voice
|
||||
announceFatality(fatalityTagline)
|
||||
announceCrowdReaction('cheer')
|
||||
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.25, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
||||
@@ -8280,7 +8331,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
sfxWin()
|
||||
sfxWinAnnounce(winnerName)
|
||||
}, 300)
|
||||
announceFlawlessVictory()
|
||||
// FlawlessVictory voice is called by FightViewer after playPerfect returns
|
||||
announceCrowdReaction('cheer')
|
||||
// Massive fireworks
|
||||
for (let i = 0; i < 8; i++) {
|
||||
|
||||
@@ -269,7 +269,9 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
|
||||
if (typeof speechSynthesis === 'undefined') return
|
||||
if (masterMuted) return
|
||||
if (!voicesLoaded) loadVoices()
|
||||
if (cancelPrevious) speechSynthesis.cancel()
|
||||
// Always cancel queued speech — only the most recent line matters
|
||||
// This prevents stale voiceovers from playing seconds after their visual moment
|
||||
speechSynthesis.cancel()
|
||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||
const utter = new SpeechSynthesisUtterance(text)
|
||||
if (profile.voice) utter.voice = profile.voice
|
||||
@@ -290,6 +292,7 @@ export function announce(text: string, pitch?: number, rate?: number) {
|
||||
if (pitch !== undefined || rate !== undefined) {
|
||||
if (typeof speechSynthesis === 'undefined') return
|
||||
if (!voicesLoaded) loadVoices()
|
||||
speechSynthesis.cancel() // cancel previous to stay in sync with visuals
|
||||
const utter = new SpeechSynthesisUtterance(text)
|
||||
const profile = voiceProfiles.announcer
|
||||
if (profile.voice) utter.voice = profile.voice
|
||||
|
||||
@@ -179,18 +179,31 @@ export function generateHumanSpriteSheet(
|
||||
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
|
||||
// Growth stages: baby → toddler → kid → teen → adult → hero → super
|
||||
const isBaby = wr < 0.1
|
||||
const isToddler = wr >= 0.1 && wr < 0.25
|
||||
const isKid = wr >= 0.25 && wr < 0.4
|
||||
const isFat = isBaby || isToddler ? true : wr < 0.5 ? rng() > 0.4 : false
|
||||
const isShort = isBaby || isToddler || isKid
|
||||
const isSwole = wr > 0.75
|
||||
const isSuperHero = wr > 0.85
|
||||
const isSuperMode = wr > 0.9
|
||||
const hasBeard = wr < 0.35 ? true : isSuperMode ? false : rng() > 0.6
|
||||
const hasGlasses = wr < 0.4 ? true : isSuperMode ? false : rng() > 0.5
|
||||
const hasStains = wr < 0.3
|
||||
const hasCheetos = wr < 0.2
|
||||
const hasBeard = isBaby || isToddler || isKid ? false : wr < 0.5 ? rng() > 0.7 : isSuperMode ? false : rng() > 0.6
|
||||
const hasGlasses = isBaby || isToddler ? false : wr < 0.4 ? true : isSuperMode ? false : rng() > 0.5
|
||||
const hasStains = isToddler || isKid
|
||||
const hasCheetos = false
|
||||
const hasCape = isSuperHero
|
||||
const hasHeadband = wr > 0.7
|
||||
const hasSweatDrops = wr < 0.4
|
||||
const hasSweatDrops = wr < 0.4 && !isBaby
|
||||
const hasDiaper = isBaby
|
||||
const hasPacifier = isBaby && rng() > 0.3
|
||||
const hasBib = isBaby || (isToddler && rng() > 0.5)
|
||||
const hasRattle = isBaby
|
||||
const hasRosyCheeks = isBaby || isToddler
|
||||
const hasTuftHair = isBaby
|
||||
const hasSippyCup = isToddler
|
||||
const hasBackpack = isKid && rng() > 0.5
|
||||
const hasBandaid = isToddler || isKid
|
||||
|
||||
const costume = getCostume(archetype)
|
||||
const out = '#0a0a0a'
|
||||
@@ -283,13 +296,34 @@ export function generateHumanSpriteSheet(
|
||||
fill(cx - 3 + xOff, bodyTop + yOff - 2, 6, 3, '#ffd700', ox, oy)
|
||||
}
|
||||
|
||||
// ===== DIAPER (babies) =====
|
||||
if (hasDiaper) {
|
||||
const diaperY = legsTop + yOff - 2
|
||||
const diaperW = bodyW + 4
|
||||
const diaperH = 12
|
||||
const dx = cx - Math.floor(diaperW / 2) + xOff
|
||||
// White puffy diaper
|
||||
roundBox(dx, diaperY, diaperW, diaperH, '#f5f5f0', ox, oy)
|
||||
// Diaper tape strips (blue)
|
||||
fill(dx + 3, diaperY + 2, 4, 2, '#66aadd', ox, oy)
|
||||
fill(dx + diaperW - 7, diaperY + 2, 4, 2, '#66aadd', ox, oy)
|
||||
// Little star decoration
|
||||
px(cx + xOff, diaperY + 4, '#ffcc44', ox, oy)
|
||||
px(cx + xOff - 1, diaperY + 5, '#ffcc44', ox, oy)
|
||||
px(cx + xOff + 1, diaperY + 5, '#ffcc44', ox, oy)
|
||||
// Puffy sides
|
||||
fill(dx - 1, diaperY + 3, 2, 4, '#eeeee8', ox, oy)
|
||||
fill(dx + diaperW - 1, diaperY + 3, 2, 4, '#eeeee8', 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
|
||||
// Babies have bare skin-colored legs, toddlers too
|
||||
const legColor = hasDiaper || isToddler ? skinTone : costume.onesie ? (costume.hat || shirtColor) : pantsColor
|
||||
const legColorDk = hasDiaper || isToddler ? skinDark : costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark
|
||||
|
||||
// Left leg
|
||||
roundBox(ll, legsTop + yOff, legW, legH, legColor, ox, oy)
|
||||
@@ -728,7 +762,17 @@ export function generateHumanSpriteSheet(
|
||||
}
|
||||
|
||||
// Hair
|
||||
if (!costume.onesie || costume.type !== 'animal') {
|
||||
if (hasTuftHair) {
|
||||
// Baby: just a tiny curl on top
|
||||
px(cx + xOff - 1, hy - 1, hairColor, ox, oy)
|
||||
px(cx + xOff, hy - 2, hairColor, ox, oy)
|
||||
px(cx + xOff + 1, hy - 1, hairColor, ox, oy)
|
||||
px(cx + xOff, hy - 3, lighten(hairColor, 15), ox, oy)
|
||||
px(cx + xOff - 1, hy - 3, hairColor, ox, oy)
|
||||
// Tiny curl
|
||||
px(cx + xOff + 2, hy - 2, hairColor, ox, oy)
|
||||
px(cx + xOff + 3, hy - 1, hairColor, ox, oy)
|
||||
} else if (!costume.onesie || costume.type !== 'animal') {
|
||||
// Top hair (fluffy)
|
||||
for (let ix = hx; ix < hx + headW; ix++) {
|
||||
px(ix, hy, hairColor, ox, oy)
|
||||
@@ -746,7 +790,7 @@ export function generateHumanSpriteSheet(
|
||||
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) {
|
||||
if (wr < 0.3 && !isKid) {
|
||||
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)
|
||||
@@ -777,6 +821,30 @@ export function generateHumanSpriteSheet(
|
||||
// Raised cheeks
|
||||
fill(leX - 1, eyeY + 2, 3, 2, '#ff8888', ox, oy)
|
||||
fill(reX + 4, eyeY + 2, 3, 2, '#ff8888', ox, oy)
|
||||
} else if (isBaby || isToddler) {
|
||||
// BABY/TODDLER: huge sparkly adorable eyes
|
||||
const eyeSize = isBaby ? 8 : 7
|
||||
fill(leX - 1, eyeY - 1, eyeSize, eyeSize, '#ffffff', ox, oy)
|
||||
fill(reX - 1, eyeY - 1, eyeSize, eyeSize, '#ffffff', ox, oy)
|
||||
// Big iris
|
||||
const irisSize = isBaby ? 5 : 4
|
||||
fill(leX + 1, eyeY + 1, irisSize, irisSize, isBaby ? '#5588cc' : '#4466aa', ox, oy)
|
||||
fill(reX + 1, eyeY + 1, irisSize, irisSize, isBaby ? '#5588cc' : '#4466aa', ox, oy)
|
||||
// Big sparkly pupil
|
||||
fill(leX + 2, eyeY + 2, 3, 3, '#000000', ox, oy)
|
||||
fill(reX + 2, eyeY + 2, 3, 3, '#000000', ox, oy)
|
||||
// Two sparkle highlights per eye
|
||||
px(leX + 1, eyeY, '#ffffff', ox, oy)
|
||||
px(leX + 2, eyeY, '#ffffff', ox, oy)
|
||||
px(leX + 4, eyeY + 3, '#ffffff', ox, oy)
|
||||
px(reX + 1, eyeY, '#ffffff', ox, oy)
|
||||
px(reX + 2, eyeY, '#ffffff', ox, oy)
|
||||
px(reX + 4, eyeY + 3, '#ffffff', ox, oy)
|
||||
// Tiny eyebrows (barely there)
|
||||
if (!isBaby) {
|
||||
fill(leX, eyeY - 3, 5, 1, hairColor, ox, oy)
|
||||
fill(reX, eyeY - 3, 5, 1, hairColor, ox, oy)
|
||||
}
|
||||
} else {
|
||||
// Normal eyes with iris detail
|
||||
fill(leX, eyeY, 6, 5, '#ffffff', ox, oy)
|
||||
@@ -905,8 +973,8 @@ export function generateHumanSpriteSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Double chin
|
||||
if (isFat && wr < 0.25) {
|
||||
// Double chin (adults only, not babies)
|
||||
if (isFat && wr >= 0.25 && wr < 0.5) {
|
||||
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)
|
||||
@@ -943,6 +1011,100 @@ export function generateHumanSpriteSheet(
|
||||
px(hx - 7, hy + 5 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy)
|
||||
}
|
||||
|
||||
// ===== BABY ACCESSORIES =====
|
||||
if (hasRosyCheeks) {
|
||||
// Pink rosy cheeks
|
||||
const cheekY = hy + Math.floor(headH * 0.55)
|
||||
fill(hx + 2, cheekY, 4, 3, '#ffaaaa', ox, oy)
|
||||
fill(hx + headW - 6, cheekY, 4, 3, '#ffaaaa', ox, oy)
|
||||
px(hx + 3, cheekY + 1, '#ff8888', ox, oy)
|
||||
px(hx + headW - 5, cheekY + 1, '#ff8888', ox, oy)
|
||||
}
|
||||
|
||||
if (hasPacifier && !isCheer && !isPanic && !isCoach) {
|
||||
// Pacifier in mouth
|
||||
const pY = hy + Math.floor(headH * 0.72)
|
||||
// Shield (round part)
|
||||
roundBox(cx - 4 + xOff, pY - 1, 8, 5, '#66ccff', ox, oy)
|
||||
// Nipple (sticks out)
|
||||
fill(cx - 2 + xOff, pY, 4, 3, '#ffcc66', ox, oy)
|
||||
// Handle ring
|
||||
px(cx - 5 + xOff, pY + 1, '#88ddff', ox, oy)
|
||||
px(cx - 6 + xOff, pY, '#88ddff', ox, oy)
|
||||
px(cx - 6 + xOff, pY + 2, '#88ddff', ox, oy)
|
||||
px(cx - 7 + xOff, pY + 1, '#88ddff', ox, oy)
|
||||
}
|
||||
|
||||
if (hasBib) {
|
||||
// Bib around neck area
|
||||
const bibY = neckTop + yOff
|
||||
const bibW = isBaby ? 20 : 16
|
||||
const bx2 = cx - Math.floor(bibW / 2) + xOff
|
||||
fill(bx2, bibY, bibW, 3, '#ffffff', ox, oy)
|
||||
fill(bx2 + 2, bibY + 3, bibW - 4, 6, '#ffffff', ox, oy)
|
||||
fill(bx2 + 4, bibY + 9, bibW - 8, 4, '#ffffff', ox, oy)
|
||||
// Bib border
|
||||
for (let ix = bx2 + 2; ix < bx2 + bibW - 2; ix++) px(ix, bibY + 8, '#dddddd', ox, oy)
|
||||
// Cute stain on bib
|
||||
px(cx - 2 + xOff, bibY + 5, '#ffaa66', ox, oy)
|
||||
px(cx - 1 + xOff, bibY + 6, '#ffaa66', ox, oy)
|
||||
px(cx + xOff, bibY + 5, '#ff8844', ox, oy)
|
||||
// "I <3 AI" text (tiny)
|
||||
px(cx - 3 + xOff, bibY + 4, '#ff4466', ox, oy) // heart
|
||||
px(cx - 2 + xOff, bibY + 3, '#ff4466', ox, oy)
|
||||
px(cx - 4 + xOff, bibY + 3, '#ff4466', ox, oy)
|
||||
}
|
||||
|
||||
if (hasRattle && (isCheer || isIdle)) {
|
||||
// Rattle in raised hand
|
||||
const rattleX = isCheer ? cx + Math.floor(bodyW / 2) + 8 + xOff : cx + Math.floor(bodyW / 2) + 3 + xOff
|
||||
const rattleY = isCheer ? bodyTop + yOff - 10 : bodyTop + yOff + 5
|
||||
// Handle
|
||||
fill(rattleX, rattleY + 4, 2, 8, '#dda844', ox, oy)
|
||||
// Ball
|
||||
roundBox(rattleX - 3, rattleY - 2, 8, 7, '#ff6688', ox, oy)
|
||||
// Sparkle on rattle
|
||||
px(rattleX - 1, rattleY - 1, '#ffffff', ox, oy)
|
||||
px(rattleX + 2, rattleY, '#ffccdd', ox, oy)
|
||||
}
|
||||
|
||||
if (hasSippyCup && (isIdle || isCoach)) {
|
||||
// Sippy cup in hand
|
||||
const scX = cx + Math.floor(bodyW / 2) + 3 + xOff
|
||||
const scY = bodyTop + yOff + Math.floor(bodyH * 0.5)
|
||||
roundBox(scX, scY, 6, 10, '#4488ff', ox, oy)
|
||||
// Lid
|
||||
fill(scX - 1, scY - 1, 8, 2, '#66aaff', ox, oy)
|
||||
// Spout
|
||||
fill(scX + 2, scY - 3, 2, 3, '#66aaff', ox, oy)
|
||||
// Liquid level
|
||||
fill(scX + 1, scY + 4, 4, 4, '#ffaa22', ox, oy)
|
||||
}
|
||||
|
||||
if (hasBandaid) {
|
||||
// Band-aid on forehead or cheek
|
||||
const baidX = hx + headW - 8
|
||||
const baidY = hy + 4
|
||||
fill(baidX, baidY, 6, 3, '#ffcc88', ox, oy)
|
||||
px(baidX + 1, baidY + 1, '#ddaa66', ox, oy)
|
||||
px(baidX + 4, baidY + 1, '#ddaa66', ox, oy)
|
||||
// Cross-hatch in middle
|
||||
px(baidX + 2, baidY, '#eebb77', ox, oy)
|
||||
px(baidX + 3, baidY + 2, '#eebb77', ox, oy)
|
||||
}
|
||||
|
||||
if (hasBackpack && !isCheer) {
|
||||
// Little backpack on back
|
||||
const bpX = cx - Math.floor(bodyW / 2) - 6 + xOff
|
||||
const bpY = bodyTop + yOff + 3
|
||||
roundBox(bpX, bpY, 6, 12, '#ee5533', ox, oy)
|
||||
// Zipper
|
||||
fill(bpX + 2, bpY + 2, 2, 3, '#cc3311', ox, oy)
|
||||
// Strap
|
||||
px(bpX + 5, bpY, '#cc3311', ox, oy)
|
||||
px(bpX + 6, bpY + 1, '#cc3311', ox, oy)
|
||||
}
|
||||
|
||||
// Winner glow aura
|
||||
if (isSuperHero && !isPanic) {
|
||||
for (let a = 0; a < 16; a++) {
|
||||
|
||||
Reference in New Issue
Block a user