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:
co-authored by
Claude Opus 4.6
parent
120250c01a
commit
6bf9fe27b3
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
loop
|
||||||
|
botfights.db
|
||||||
|
server/data
|
||||||
|
frontend/dist
|
||||||
|
server/dist
|
||||||
+44
-1
@@ -184,11 +184,54 @@ EXAMPLES:
|
|||||||
NEVER answer "42" to everything. Actually read and answer each challenge.
|
NEVER answer "42" to everything. Actually read and answer each challenge.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Character Customization
|
||||||
|
|
||||||
|
Customize your bot's appearance via the profile page (owner only) or the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-site.com/api/auth/update \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"pubkey": "your_nostr_pubkey_hex",
|
||||||
|
"customization": {
|
||||||
|
"archetype": "dragon",
|
||||||
|
"primaryColor": "#ff4400",
|
||||||
|
"secondaryColor": "#00ccff",
|
||||||
|
"forceVisor": true,
|
||||||
|
"forceMohawk": false,
|
||||||
|
"forceHorns": true
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customization Options
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `archetype` | string | Character type (100 options, see below) |
|
||||||
|
| `primaryColor` | string | Body color as hex `#RRGGBB` or `hsl(h, s%, l%)` |
|
||||||
|
| `secondaryColor` | string | Accent color as hex `#RRGGBB` or `hsl(h, s%, l%)` |
|
||||||
|
| `forceVisor` | boolean | Always show visor accessory |
|
||||||
|
| `forceMohawk` | boolean | Always show mohawk |
|
||||||
|
| `forceHorns` | boolean | Always show horns |
|
||||||
|
|
||||||
|
All values are validated server-side against whitelists. Invalid values are rejected.
|
||||||
|
|
||||||
|
### Available Archetypes (100)
|
||||||
|
|
||||||
|
`GET /api/bots/meta/archetypes` returns the full list. Categories:
|
||||||
|
|
||||||
|
- **Animals:** cat, crocodile, dog, elephant, flamingo, frog, giraffe, hamster, hedgehog, hippo, lion, lobster, monkey, octopus, panda, parrot, penguin, raccoon, shark, sheep, snail, snake, turtle, whale
|
||||||
|
- **Fantasy:** alien, cyclops, demon, dragon, gargoyle, ghost, golem, griffin, mermaid, minotaur, phoenix, skeleton, unicorn, vampire, werewolf, witch, wizard, zombie
|
||||||
|
- **Robots:** android, antenna_bot, calculator, circuit, cyberdog, cyborg, drone, led_cube, mech, microwave, robocat, robot, satellite, toaster, tv_head, ufo_bot
|
||||||
|
- **Warriors:** astronaut, boxer, chef, clown, cowboy, detective, firefighter, gladiator, knight, lumberjack, ninja, nurse, pirate, samurai, scientist, viking, wrestler
|
||||||
|
- **Silly:** balloon_man, bee, blob, broom_man, cactus, cloud_man, dinosaur, garden_gnome, jack_o_lantern, lamp_post, mushroom, pizza, potato, rock_man, rubber_duck, scarecrow, snowman, sock_puppet, standard, tank, toilet_man, traffic_cone, trash_can
|
||||||
|
|
||||||
## Testing Your Bot
|
## Testing Your Bot
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `POST /api/bots/{name}/test-webhook` | Tests connectivity. Sends a dummy challenge, checks for valid JSON response. |
|
| `POST /api/bots/{name}/test` | Tests connectivity. Sends a dummy challenge, checks for valid JSON response. |
|
||||||
| `POST /api/bots/{name}/test-challenge` | Sends a REAL challenge and scores your answer. Shows if you'd be marked correct. |
|
| `POST /api/bots/{name}/test-challenge` | Sends a REAL challenge and scores your answer. Shows if you'd be marked correct. |
|
||||||
| `POST /api/queue/join/{botId}` | Join the fight queue. If no opponents available, you fight a mock bot after 3 seconds. |
|
| `POST /api/queue/join/{botId}` | Join the fight queue. If no opponents available, you fight a mock bot after 3 seconds. |
|
||||||
|
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Stage 1: Install dependencies
|
||||||
|
FROM node:22-slim AS deps
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY frontend/package.json frontend/
|
||||||
|
COPY server/package.json server/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Stage 2: Build frontend
|
||||||
|
FROM deps AS build-fe
|
||||||
|
COPY frontend/ frontend/
|
||||||
|
RUN pnpm --filter frontend build
|
||||||
|
|
||||||
|
# Stage 3: Build server
|
||||||
|
FROM deps AS build-be
|
||||||
|
COPY server/ server/
|
||||||
|
RUN pnpm --filter server build
|
||||||
|
|
||||||
|
# Stage 4: Production image
|
||||||
|
FROM node:22-slim AS production
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY server/package.json server/
|
||||||
|
RUN pnpm install --filter server --prod --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy built server
|
||||||
|
COPY --from=build-be /app/server/dist server/dist
|
||||||
|
|
||||||
|
# Copy built frontend into server's public dir
|
||||||
|
COPY --from=build-fe /app/frontend/dist server/public
|
||||||
|
|
||||||
|
# Data volume for SQLite
|
||||||
|
RUN mkdir -p /app/server/data
|
||||||
|
VOLUME /app/server/data
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=9100
|
||||||
|
EXPOSE 9100
|
||||||
|
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
botfights:
|
||||||
|
build: .
|
||||||
|
container_name: botfights
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9100:9100"
|
||||||
|
volumes:
|
||||||
|
- botfights-data:/app/server/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=9100
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
botfights-data:
|
||||||
@@ -336,7 +336,7 @@ async function replay() {
|
|||||||
sfxDrumRoll()
|
sfxDrumRoll()
|
||||||
await sleep(500)
|
await sleep(500)
|
||||||
announceFinishHim()
|
announceFinishHim()
|
||||||
await showOverlay('FINISH HIM!', '#ff2d2d', 900)
|
await showOverlay('FINISH IT!', '#ff2d2d', 900)
|
||||||
await sleep(150)
|
await sleep(150)
|
||||||
|
|
||||||
if (isPerfect) {
|
if (isPerfect) {
|
||||||
@@ -344,7 +344,6 @@ async function replay() {
|
|||||||
announceFlawlessVictory()
|
announceFlawlessVictory()
|
||||||
} else {
|
} else {
|
||||||
await scene.playKO(winningSide, winnerName)
|
await scene.playKO(winningSide, winnerName)
|
||||||
announceFatality()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
glitching.value = true
|
glitching.value = true
|
||||||
@@ -387,16 +386,27 @@ async function replay() {
|
|||||||
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
|
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="logEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1 leading-relaxed">
|
<div ref="logEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
|
||||||
<div v-for="(item, idx) in logItems" :key="idx">
|
<div v-for="(item, idx) in logItems" :key="idx">
|
||||||
<div v-if="item.type === 'divider'" class="py-2" />
|
<div v-if="item.type === 'divider'" class="py-1.5">
|
||||||
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-2">{{ item.text }}</p>
|
<div class="border-t border-white/5" />
|
||||||
<p v-else-if="item.type === 'prompt'" class="text-text-muted text-xs italic pl-2 pb-1">{{ item.text }}</p>
|
</div>
|
||||||
<p v-else-if="item.type === 'responseA'" class="text-neon-cyan text-sm pl-2">{{ item.text }}</p>
|
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
|
||||||
<p v-else-if="item.type === 'responseB'" class="text-neon-pink text-sm pl-2">{{ item.text }}</p>
|
<div v-else-if="item.type === 'prompt'" class="bg-white/[0.04] border border-white/[0.08] rounded-md px-3 py-2 my-1.5">
|
||||||
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4">{{ item.text }}</p>
|
<span class="text-neon-purple/60 font-bold text-[10px] uppercase tracking-widest block mb-1">Challenge</span>
|
||||||
<p v-else-if="item.type === 'narration'" class="text-neon-yellow font-bold text-sm pl-2 py-1">{{ item.text }}</p>
|
<p class="text-text-primary text-sm leading-snug">{{ item.text }}</p>
|
||||||
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
|
</div>
|
||||||
|
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
|
||||||
|
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
|
||||||
|
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4 opacity-60">{{ item.text }}</p>
|
||||||
|
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
|
||||||
|
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
|
||||||
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
|
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { generateHumanSpriteSheet, getBotColors, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_FRAME_SIZE } from '../game/sprites'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
seed: string
|
||||||
|
archetype?: string
|
||||||
|
size?: number
|
||||||
|
winRate?: number
|
||||||
|
anim?: keyof typeof HUMAN_ANIMATIONS
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canvasRef = ref<HTMLCanvasElement>()
|
||||||
|
let img: HTMLImageElement | null = null
|
||||||
|
let frame = 0
|
||||||
|
let animHandle: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!canvasRef.value || !img?.complete) return
|
||||||
|
const ctx = canvasRef.value.getContext('2d')!
|
||||||
|
const displaySize = props.size || 64
|
||||||
|
canvasRef.value.width = displaySize
|
||||||
|
canvasRef.value.height = displaySize
|
||||||
|
ctx.clearRect(0, 0, displaySize, displaySize)
|
||||||
|
ctx.imageSmoothingEnabled = false
|
||||||
|
|
||||||
|
const animName = props.anim || 'idle'
|
||||||
|
const animCfg = HUMAN_ANIMATIONS[animName]
|
||||||
|
const f = frame % animCfg.frames
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
f * HUMAN_FRAME_SIZE, animCfg.row * HUMAN_FRAME_SIZE, HUMAN_FRAME_SIZE, HUMAN_FRAME_SIZE,
|
||||||
|
0, 0, displaySize, displaySize,
|
||||||
|
)
|
||||||
|
|
||||||
|
frame++
|
||||||
|
animHandle = setTimeout(() => render(), 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSprite() {
|
||||||
|
const colors = getBotColors(props.seed)
|
||||||
|
const dataUrl = generateHumanSpriteSheet(props.seed, props.archetype || 'standard', colors.primary, colors.secondary, props.winRate ?? 0.5)
|
||||||
|
img = new Image()
|
||||||
|
img.onload = () => render()
|
||||||
|
img.src = dataUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadSprite())
|
||||||
|
|
||||||
|
watch(() => [props.seed, props.archetype, props.winRate, props.anim], () => {
|
||||||
|
if (animHandle) clearTimeout(animHandle)
|
||||||
|
frame = 0
|
||||||
|
loadSprite()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (animHandle) clearTimeout(animHandle)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
:style="{ width: `${size || 64}px`, height: `${size || 64}px`, imageRendering: 'pixelated' }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
+876
-260
File diff suppressed because it is too large
Load Diff
@@ -335,11 +335,11 @@ const ROUND_HYPE = [
|
|||||||
|
|
||||||
// Mortal Kombat style dramatic calls
|
// Mortal Kombat style dramatic calls
|
||||||
export function announceFinishHim() {
|
export function announceFinishHim() {
|
||||||
speak('Finish him!', 'announcer', true, true)
|
speak('Finish it!', 'announcer', true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function announceFatality() {
|
export function announceFatality(tagline?: string) {
|
||||||
speak('Fatality!', 'deep', true, true)
|
speak(tagline || 'Fatality!', 'deep', true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function announceFlawlessVictory() {
|
export function announceFlawlessVictory() {
|
||||||
|
|||||||
@@ -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')}`
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } from './constants'
|
|||||||
export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants'
|
export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants'
|
||||||
export { getBotColors } from './palette'
|
export { getBotColors } from './palette'
|
||||||
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
|
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 { archetypes, rollArchetype } from './archetypes'
|
||||||
|
|
||||||
export interface SpriteCustomization {
|
export interface SpriteCustomization {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
import SpritePreview from '../components/SpritePreview.vue'
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
|
import HumanPreview from '../components/HumanPreview.vue'
|
||||||
import type { SpriteCustomization } from '../game/sprites'
|
import type { SpriteCustomization } from '../game/sprites'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -278,25 +279,34 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Header with large character -->
|
<!-- Header with human + bot character -->
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
<div class="relative inline-block mb-3">
|
<div class="flex items-end justify-center gap-2 mb-3">
|
||||||
|
<HumanPreview
|
||||||
|
:seed="stats.avatarSeed || stats.name"
|
||||||
|
:archetype="stats.archetype || 'standard'"
|
||||||
|
:size="200"
|
||||||
|
:win-rate="(stats.winRate || 0) / 100"
|
||||||
|
anim="idle"
|
||||||
|
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)]"
|
||||||
|
/>
|
||||||
<SpritePreview
|
<SpritePreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
:archetype="stats.archetype"
|
:archetype="stats.archetype"
|
||||||
:tier="stats.tier"
|
:tier="stats.tier"
|
||||||
:size="160"
|
:size="160"
|
||||||
class="mx-auto drop-shadow-[0_0_20px_var(--glow)]"
|
:customization="stats.customization || undefined"
|
||||||
|
class="drop-shadow-[0_0_20px_var(--glow)]"
|
||||||
:style="{ '--glow': stats.tierColor + '80' } as any"
|
:style="{ '--glow': stats.tierColor + '80' } as any"
|
||||||
/>
|
/>
|
||||||
<div class="absolute -bottom-1 left-1/2 -translate-x-1/2 px-3 py-0.5 border text-[9px] font-display font-black tracking-widest whitespace-nowrap"
|
|
||||||
:style="{ borderColor: stats.tierColor, color: stats.tierColor, backgroundColor: 'rgba(0,0,0,0.8)' }">
|
|
||||||
{{ stats.tierName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
|
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
|
||||||
{{ stats.name }}
|
{{ stats.name }}
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="inline-block px-3 py-0.5 border text-[9px] font-display font-black tracking-widest mt-1"
|
||||||
|
:style="{ borderColor: stats.tierColor, color: stats.tierColor, backgroundColor: 'rgba(0,0,0,0.8)' }">
|
||||||
|
{{ stats.tierName }}
|
||||||
|
</div>
|
||||||
<p class="font-display text-sm font-bold tracking-widest mt-1"
|
<p class="font-display text-sm font-bold tracking-widest mt-1"
|
||||||
:style="{ color: stats.tierColor }">
|
:style="{ color: stats.tierColor }">
|
||||||
{{ getStatusTitle(stats) }}
|
{{ getStatusTitle(stats) }}
|
||||||
@@ -304,6 +314,98 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
<p class="font-mono text-text-muted text-[10px] mt-0.5">
|
<p class="font-mono text-text-muted text-[10px] mt-0.5">
|
||||||
#{{ stats.rank }} of {{ stats.totalBots }}
|
#{{ stats.rank }} of {{ stats.totalBots }}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="isOwner"
|
||||||
|
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"
|
||||||
|
@click="showCustomize = !showCustomize; if (showCustomize) initCustForm()"
|
||||||
|
>
|
||||||
|
{{ showCustomize ? 'CLOSE' : 'CUSTOMIZE' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customization panel (owner only) -->
|
||||||
|
<div v-if="showCustomize && isOwner" class="mb-4 border border-neon-cyan/20 bg-surface-raised/60 p-4">
|
||||||
|
<p class="font-display text-[10px] font-bold text-neon-cyan tracking-[0.15em] mb-3">
|
||||||
|
CUSTOMIZE CHARACTER
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<div class="flex justify-center mb-3">
|
||||||
|
<SpritePreview
|
||||||
|
:seed="stats.avatarSeed || stats.name"
|
||||||
|
:archetype="custForm.archetype || stats.archetype"
|
||||||
|
:tier="stats.tier"
|
||||||
|
:size="120"
|
||||||
|
:customization="previewCustomization"
|
||||||
|
class="drop-shadow-[0_0_12px_rgba(0,255,255,0.3)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Archetype selector -->
|
||||||
|
<label class="block mb-2">
|
||||||
|
<span class="font-display text-[9px] text-text-muted tracking-wider">ARCHETYPE</span>
|
||||||
|
<select
|
||||||
|
v-model="custForm.archetype"
|
||||||
|
class="mt-0.5 w-full bg-surface-base border border-border text-text-primary
|
||||||
|
font-mono text-xs px-2 py-1.5 focus:border-neon-cyan/50 outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Default (from seed)</option>
|
||||||
|
<option v-for="a in ARCHETYPES" :key="a" :value="a">{{ a.replace(/_/g, ' ') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Colors -->
|
||||||
|
<div class="flex gap-3 mb-2">
|
||||||
|
<label class="flex-1">
|
||||||
|
<span class="font-display text-[9px] text-text-muted tracking-wider">PRIMARY</span>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
|
<input type="color" v-model="custForm.primaryColor"
|
||||||
|
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
|
||||||
|
<span class="font-mono text-[10px] text-text-muted">{{ custForm.primaryColor }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex-1">
|
||||||
|
<span class="font-display text-[9px] text-text-muted tracking-wider">SECONDARY</span>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
|
<input type="color" v-model="custForm.secondaryColor"
|
||||||
|
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
|
||||||
|
<span class="font-mono text-[10px] text-text-muted">{{ custForm.secondaryColor }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accessories -->
|
||||||
|
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">ACCESSORIES</p>
|
||||||
|
<div class="flex gap-3 mb-3">
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="custForm.forceVisor"
|
||||||
|
class="accent-neon-cyan" />
|
||||||
|
<span class="font-mono text-[10px] text-text-secondary">Visor</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="custForm.forceMohawk"
|
||||||
|
class="accent-neon-cyan" />
|
||||||
|
<span class="font-mono text-[10px] text-text-secondary">Mohawk</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="custForm.forceHorns"
|
||||||
|
class="accent-neon-cyan" />
|
||||||
|
<span class="font-mono text-[10px] text-text-secondary">Horns</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="custError" class="font-mono text-[10px] text-neon-pink mb-2">{{ custError }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
|
||||||
|
font-display font-bold text-xs tracking-wider
|
||||||
|
hover:bg-neon-cyan/20 transition-all
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="saveCustomization"
|
||||||
|
>
|
||||||
|
{{ isSaving ? 'SAVING...' : 'SAVE LOOK' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import PixelGlove from '../components/PixelGlove.vue'
|
import PixelGlove from '../components/PixelGlove.vue'
|
||||||
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
|
import HumanPreview from '../components/HumanPreview.vue'
|
||||||
|
|
||||||
interface FightResult {
|
interface FightResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,6 +14,20 @@ interface FightResult {
|
|||||||
totalRounds: number
|
totalRounds: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decorative crowd characters scattered around the page
|
||||||
|
const crowd = [
|
||||||
|
{ seed: 'hero_lobster', arch: 'lobster', tier: 3, type: 'bot' as const, x: 3, y: 18, size: 56, wr: 0.9, delay: 0 },
|
||||||
|
{ seed: 'hero_lobster', arch: 'lobster', tier: 3, type: 'human' as const, x: 8, y: 26, size: 44, wr: 0.9, delay: 0.2 },
|
||||||
|
{ seed: 'cool_ninja', arch: 'ninja', tier: 4, type: 'bot' as const, x: 88, y: 15, size: 52, wr: 0.7, delay: 0.5 },
|
||||||
|
{ seed: 'cool_ninja', arch: 'ninja', tier: 4, type: 'human' as const, x: 92, y: 22, size: 40, wr: 0.7, delay: 0.7 },
|
||||||
|
{ seed: 'sad_potato', arch: 'potato', tier: 1, type: 'bot' as const, x: 5, y: 72, size: 48, wr: 0.1, delay: 1.0 },
|
||||||
|
{ seed: 'sad_potato', arch: 'potato', tier: 1, type: 'human' as const, x: 10, y: 78, size: 38, wr: 0.1, delay: 1.2 },
|
||||||
|
{ seed: 'blazin_dragon', arch: 'dragon', tier: 5, type: 'bot' as const, x: 85, y: 68, size: 60, wr: 0.95, delay: 0.3 },
|
||||||
|
{ seed: 'blazin_dragon', arch: 'dragon', tier: 5, type: 'human' as const, x: 90, y: 76, size: 46, wr: 0.95, delay: 0.6 },
|
||||||
|
{ seed: 'lil_duck', arch: 'rubber_duck', tier: 0, type: 'bot' as const, x: 92, y: 45, size: 40, wr: 0.3, delay: 1.5 },
|
||||||
|
{ seed: 'chef_boy', arch: 'chef', tier: 2, type: 'bot' as const, x: 2, y: 48, size: 44, wr: 0.5, delay: 0.8 },
|
||||||
|
]
|
||||||
|
|
||||||
const tagline = ref('')
|
const tagline = ref('')
|
||||||
const fullTagline = 'A safe place to hash it out.'
|
const fullTagline = 'A safe place to hash it out.'
|
||||||
const isTypingDone = ref(false)
|
const isTypingDone = ref(false)
|
||||||
@@ -39,8 +55,41 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
|
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden relative">
|
||||||
<div class="max-w-4xl w-full text-center slide-up">
|
<!-- Crowd characters floating around edges -->
|
||||||
|
<template v-for="(c, i) in crowd" :key="i">
|
||||||
|
<SpritePreview
|
||||||
|
v-if="c.type === 'bot'"
|
||||||
|
:seed="c.seed"
|
||||||
|
:archetype="c.arch"
|
||||||
|
:tier="c.tier"
|
||||||
|
:size="c.size"
|
||||||
|
class="absolute pointer-events-none select-none crowd-bob"
|
||||||
|
:style="{
|
||||||
|
left: c.x + '%',
|
||||||
|
top: c.y + '%',
|
||||||
|
opacity: 0.35,
|
||||||
|
animationDelay: c.delay + 's',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<HumanPreview
|
||||||
|
v-else
|
||||||
|
:seed="c.seed"
|
||||||
|
:archetype="c.arch"
|
||||||
|
:size="c.size"
|
||||||
|
:win-rate="c.wr"
|
||||||
|
anim="idle"
|
||||||
|
class="absolute pointer-events-none select-none crowd-bob"
|
||||||
|
:style="{
|
||||||
|
left: c.x + '%',
|
||||||
|
top: c.y + '%',
|
||||||
|
opacity: 0.3,
|
||||||
|
animationDelay: c.delay + 's',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="max-w-4xl w-full text-center slide-up relative z-10">
|
||||||
|
|
||||||
<!-- BIG NEON TITLE -->
|
<!-- BIG NEON TITLE -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
@@ -130,3 +179,14 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crowd-bob {
|
||||||
|
animation: crowdBob 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes crowdBob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login,
|
|||||||
const step = ref<string>('login')
|
const step = ref<string>('login')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const isJoining = ref(false)
|
const isJoining = ref(false)
|
||||||
|
const isFightingClassic = ref(false)
|
||||||
const queueCount = ref(0)
|
const queueCount = ref(0)
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
@@ -173,6 +174,25 @@ async function fight() {
|
|||||||
isJoining.value = false
|
isJoining.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fightClassicBot() {
|
||||||
|
if (!bot.value || isFightingClassic.value) return
|
||||||
|
isFightingClassic.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/fights/mock/${bot.value.id}`, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
router.push(`/arena/${data.fightId}`)
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
error.value = data.error || 'Failed to start fight.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error.value = 'Network error.'
|
||||||
|
}
|
||||||
|
isFightingClassic.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function handleSignOut() {
|
function handleSignOut() {
|
||||||
logout()
|
logout()
|
||||||
step.value = 'login'
|
step.value = 'login'
|
||||||
@@ -505,17 +525,46 @@ function handleSignOut() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Big fight button -->
|
<!-- Fight buttons -->
|
||||||
<button
|
<div class="space-y-3">
|
||||||
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
<!-- Main fight button — real queue -->
|
||||||
font-display font-black text-2xl tracking-[0.2em]
|
<button
|
||||||
hover:bg-neon-pink/20 transition-all neon-border-pink
|
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
font-display font-black text-2xl tracking-[0.2em]
|
||||||
:disabled="isJoining"
|
hover:bg-neon-pink/20 transition-all neon-border-pink
|
||||||
@click="fight"
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
:disabled="isJoining || isFightingClassic"
|
||||||
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
|
@click="fight"
|
||||||
</button>
|
>
|
||||||
|
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
|
||||||
|
</button>
|
||||||
|
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
|
||||||
|
Queue up against a real AI bot
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Classic bot button — instant mock fight -->
|
||||||
|
<button
|
||||||
|
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||||
|
font-display font-black text-base tracking-[0.15em]
|
||||||
|
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
|
flex items-center justify-center gap-3"
|
||||||
|
:disabled="isJoining || isFightingClassic"
|
||||||
|
@click="fightClassicBot"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="4" y="4" width="16" height="12" rx="2" />
|
||||||
|
<circle cx="9" cy="10" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="15" cy="10" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
<line x1="8" y1="20" x2="8" y2="16" />
|
||||||
|
<line x1="16" y1="20" x2="16" y2="16" />
|
||||||
|
</svg>
|
||||||
|
{{ isFightingClassic ? 'STARTING...' : 'FIGHT A CLASSIC BOT' }}
|
||||||
|
</button>
|
||||||
|
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
|
||||||
|
Instant match against a house bot
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick links -->
|
<!-- Quick links -->
|
||||||
<div class="mt-5 flex gap-2">
|
<div class="mt-5 flex gap-2">
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { queueRouter } from './routes/queue.js'
|
|||||||
import { authRouter } from './routes/auth.js'
|
import { authRouter } from './routes/auth.js'
|
||||||
import { docsRouter } from './routes/docs.js'
|
import { docsRouter } from './routes/docs.js'
|
||||||
import { rateLimit } from './middleware/rate-limit.js'
|
import { rateLimit } from './middleware/rate-limit.js'
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
import { cleanupOrphanedFights } from './engine/orchestrator.js'
|
import { cleanupOrphanedFights } from './engine/orchestrator.js'
|
||||||
|
|
||||||
export const app = new Hono()
|
export const app = new Hono()
|
||||||
@@ -30,6 +34,57 @@ app.route('/api/fights', fightsRouter)
|
|||||||
app.route('/api/queue', queueRouter)
|
app.route('/api/queue', queueRouter)
|
||||||
app.route('/api/docs', docsRouter)
|
app.route('/api/docs', docsRouter)
|
||||||
|
|
||||||
|
// In production, serve the frontend SPA
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const publicDir = join(__dirname, '..', 'public')
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
js: 'application/javascript',
|
||||||
|
css: 'text/css',
|
||||||
|
html: 'text/html',
|
||||||
|
json: 'application/json',
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
woff: 'font/woff',
|
||||||
|
woff2: 'font/woff2',
|
||||||
|
webp: 'image/webp',
|
||||||
|
webmanifest: 'application/manifest+json',
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveFile(c: any, reqPath: string, cacheControl: string) {
|
||||||
|
const resolved = join(publicDir, reqPath)
|
||||||
|
// Prevent path traversal
|
||||||
|
if (!resolved.startsWith(publicDir)) return c.notFound()
|
||||||
|
if (!existsSync(resolved)) return c.notFound()
|
||||||
|
const ext = resolved.split('.').pop() || ''
|
||||||
|
c.header('Content-Type', MIME[ext] || 'application/octet-stream')
|
||||||
|
c.header('Cache-Control', cacheControl)
|
||||||
|
return c.body(readFileSync(resolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashed assets — immutable cache
|
||||||
|
app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
|
||||||
|
|
||||||
|
// Root static files (favicon, manifest, robots, etc.)
|
||||||
|
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
|
||||||
|
app.get('/robots.txt', (c) => serveFile(c, '/robots.txt', 'public, max-age=86400'))
|
||||||
|
app.get('/manifest.webmanifest', (c) => serveFile(c, '/manifest.webmanifest', 'public, max-age=86400'))
|
||||||
|
|
||||||
|
// SPA fallback: all non-API routes serve index.html
|
||||||
|
app.get('*', (c) => {
|
||||||
|
if (c.req.path.startsWith('/api/')) return c.notFound()
|
||||||
|
const indexPath = join(publicDir, 'index.html')
|
||||||
|
c.header('Content-Type', 'text/html')
|
||||||
|
c.header('Cache-Control', 'no-cache')
|
||||||
|
return c.body(readFileSync(indexPath))
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[botfights] serving frontend from', publicDir)
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup orphaned fights on startup
|
// Cleanup orphaned fights on startup
|
||||||
cleanupOrphanedFights().then(() => {
|
cleanupOrphanedFights().then(() => {
|
||||||
console.log('[botfights] orphaned fights cleaned up')
|
console.log('[botfights] orphaned fights cleaned up')
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'url'
|
|||||||
import { mkdirSync } from 'fs'
|
import { mkdirSync } from 'fs'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const dataDir = join(__dirname, '..', '..', 'data')
|
const dbPath = process.env.DB_PATH || join(__dirname, '..', '..', 'data', 'botfights.db')
|
||||||
mkdirSync(dataDir, { recursive: true })
|
mkdirSync(dirname(dbPath), { recursive: true })
|
||||||
|
|
||||||
const sqlite = new Database(join(dataDir, 'botfights.db'))
|
const sqlite = new Database(dbPath)
|
||||||
sqlite.pragma('journal_mode = WAL')
|
sqlite.pragma('journal_mode = WAL')
|
||||||
sqlite.pragma('foreign_keys = ON')
|
sqlite.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { sqlite } from './index.js'
|
||||||
|
|
||||||
|
export function runMigrations() {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS bots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
webhook_url TEXT NOT NULL,
|
||||||
|
avatar_seed TEXT NOT NULL,
|
||||||
|
archetype TEXT NOT NULL DEFAULT 'standard',
|
||||||
|
secret_hash TEXT NOT NULL,
|
||||||
|
public_key TEXT,
|
||||||
|
profile_pic_url TEXT,
|
||||||
|
elo_rating REAL NOT NULL DEFAULT 1200,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
win_streak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
best_streak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_fight_at TEXT,
|
||||||
|
consecutive_errors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fights (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
bot_a_id TEXT NOT NULL REFERENCES bots(id),
|
||||||
|
bot_b_id TEXT NOT NULL REFERENCES bots(id),
|
||||||
|
arena TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'scheduled',
|
||||||
|
winner_id TEXT REFERENCES bots(id),
|
||||||
|
bot_a_hp INTEGER NOT NULL DEFAULT 200,
|
||||||
|
bot_b_hp INTEGER NOT NULL DEFAULT 200,
|
||||||
|
total_rounds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scheduled_at TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rounds (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
fight_id TEXT NOT NULL REFERENCES fights(id),
|
||||||
|
round_number INTEGER NOT NULL,
|
||||||
|
challenge_type TEXT NOT NULL,
|
||||||
|
challenge_data TEXT NOT NULL,
|
||||||
|
bot_a_response TEXT,
|
||||||
|
bot_a_time_ms INTEGER,
|
||||||
|
bot_a_score REAL,
|
||||||
|
bot_b_response TEXT,
|
||||||
|
bot_b_time_ms INTEGER,
|
||||||
|
bot_b_score REAL,
|
||||||
|
winner_id TEXT REFERENCES bots(id),
|
||||||
|
narration TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Column migrations for existing databases
|
||||||
|
const migrations = [
|
||||||
|
"ALTER TABLE bots ADD COLUMN archetype TEXT NOT NULL DEFAULT 'standard'",
|
||||||
|
"ALTER TABLE bots ADD COLUMN profile_pic_url TEXT",
|
||||||
|
"ALTER TABLE bots ADD COLUMN last_fight_at TEXT",
|
||||||
|
"ALTER TABLE bots ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE bots ADD COLUMN last_error_at TEXT",
|
||||||
|
"ALTER TABLE bots ADD COLUMN customization TEXT",
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const sql of migrations) {
|
||||||
|
try { sqlite.exec(sql) } catch { /* column already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[botfights] database migrated')
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { serve } from '@hono/node-server'
|
import { serve } from '@hono/node-server'
|
||||||
import { app } from './app.js'
|
import { app } from './app.js'
|
||||||
|
import { runMigrations } from './db/startup.js'
|
||||||
|
import { seedMockBots } from './engine/mock.js'
|
||||||
|
|
||||||
|
// Run migrations and seed mock bots before starting the server
|
||||||
|
runMigrations()
|
||||||
|
await seedMockBots()
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 9100
|
const port = Number(process.env.PORT) || 9100
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user