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.
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
| 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/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()
|
||||
await sleep(500)
|
||||
announceFinishHim()
|
||||
await showOverlay('FINISH HIM!', '#ff2d2d', 900)
|
||||
await showOverlay('FINISH IT!', '#ff2d2d', 900)
|
||||
await sleep(150)
|
||||
|
||||
if (isPerfect) {
|
||||
@@ -344,7 +344,6 @@ async function replay() {
|
||||
announceFlawlessVictory()
|
||||
} else {
|
||||
await scene.playKO(winningSide, winnerName)
|
||||
announceFatality()
|
||||
}
|
||||
|
||||
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>
|
||||
</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-if="item.type === 'divider'" class="py-2" />
|
||||
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-2">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'prompt'" class="text-text-muted text-xs italic pl-2 pb-1">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'responseA'" class="text-neon-cyan text-sm pl-2">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'responseB'" class="text-neon-pink text-sm pl-2">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'narration'" class="text-neon-yellow font-bold text-sm pl-2 py-1">{{ 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 v-if="item.type === 'divider'" class="py-1.5">
|
||||
<div class="border-t border-white/5" />
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
<span class="text-neon-purple/60 font-bold text-[10px] uppercase tracking-widest block mb-1">Challenge</span>
|
||||
<p class="text-text-primary text-sm leading-snug">{{ 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>
|
||||
</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
|
||||
export function announceFinishHim() {
|
||||
speak('Finish him!', 'announcer', true, true)
|
||||
speak('Finish it!', 'announcer', true, true)
|
||||
}
|
||||
|
||||
export function announceFatality() {
|
||||
speak('Fatality!', 'deep', true, true)
|
||||
export function announceFatality(tagline?: string) {
|
||||
speak(tagline || 'Fatality!', 'deep', true, true)
|
||||
}
|
||||
|
||||
export function announceFlawlessVictory() {
|
||||
|
||||
@@ -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 { getBotColors } from './palette'
|
||||
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
|
||||
export { generateHumanSpriteSheet, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, HUMAN_FRAME_SIZE } from './human'
|
||||
export { archetypes, rollArchetype } from './archetypes'
|
||||
|
||||
export interface SpriteCustomization {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
import type { SpriteCustomization } from '../game/sprites'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -278,25 +279,34 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header with large character -->
|
||||
<!-- Header with human + bot character -->
|
||||
<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
|
||||
:seed="stats.avatarSeed || stats.name"
|
||||
:archetype="stats.archetype"
|
||||
:tier="stats.tier"
|
||||
: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"
|
||||
/>
|
||||
<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>
|
||||
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
|
||||
{{ stats.name }}
|
||||
</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"
|
||||
:style="{ color: stats.tierColor }">
|
||||
{{ getStatusTitle(stats) }}
|
||||
@@ -304,6 +314,98 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
<p class="font-mono text-text-muted text-[10px] mt-0.5">
|
||||
#{{ stats.rank }} of {{ stats.totalBots }}
|
||||
</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>
|
||||
|
||||
<!-- Stats -->
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import PixelGlove from '../components/PixelGlove.vue'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
|
||||
interface FightResult {
|
||||
id: string
|
||||
@@ -12,6 +14,20 @@ interface FightResult {
|
||||
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 fullTagline = 'A safe place to hash it out.'
|
||||
const isTypingDone = ref(false)
|
||||
@@ -39,8 +55,41 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
|
||||
<div class="max-w-4xl w-full text-center slide-up">
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden relative">
|
||||
<!-- 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 -->
|
||||
<div class="mb-6">
|
||||
@@ -130,3 +179,14 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</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 error = ref('')
|
||||
const isJoining = ref(false)
|
||||
const isFightingClassic = ref(false)
|
||||
const queueCount = ref(0)
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -173,6 +174,25 @@ async function fight() {
|
||||
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() {
|
||||
logout()
|
||||
step.value = 'login'
|
||||
@@ -505,17 +525,46 @@ function handleSignOut() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Big fight button -->
|
||||
<button
|
||||
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-2xl tracking-[0.2em]
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="isJoining"
|
||||
@click="fight"
|
||||
>
|
||||
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
|
||||
</button>
|
||||
<!-- Fight buttons -->
|
||||
<div class="space-y-3">
|
||||
<!-- Main fight button — real queue -->
|
||||
<button
|
||||
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-2xl tracking-[0.2em]
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="isJoining || isFightingClassic"
|
||||
@click="fight"
|
||||
>
|
||||
{{ 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 -->
|
||||
<div class="mt-5 flex gap-2">
|
||||
|
||||
@@ -7,6 +7,10 @@ import { queueRouter } from './routes/queue.js'
|
||||
import { authRouter } from './routes/auth.js'
|
||||
import { docsRouter } from './routes/docs.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'
|
||||
|
||||
export const app = new Hono()
|
||||
@@ -30,6 +34,57 @@ app.route('/api/fights', fightsRouter)
|
||||
app.route('/api/queue', queueRouter)
|
||||
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
|
||||
cleanupOrphanedFights().then(() => {
|
||||
console.log('[botfights] orphaned fights cleaned up')
|
||||
|
||||
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'url'
|
||||
import { mkdirSync } from 'fs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const dataDir = join(__dirname, '..', '..', 'data')
|
||||
mkdirSync(dataDir, { recursive: true })
|
||||
const dbPath = process.env.DB_PATH || join(__dirname, '..', '..', 'data', 'botfights.db')
|
||||
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('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 { 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user