feat: v2 — queue matchmaking, procedural audio, sprite archetypes, auth

- Add queue-based matchmaking with Elo-proximity and 10s timeout
- Procedural sound engine (SFX, voice announcer, 4-track music)
- Sprite system refactored into 6 archetypes (standard, lobster, sheep, cyborg, blob, tank)
- 42+ fight choreographies with themed/generic/wild card selection
- 4 KO finish styles, super-speed mode, hyperdetail close-ups
- Auth routes, JoinBout page, bot profile with stats
- 7-tier ranking system (Baby through Legend)
- Arena and challenge system expansions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 22:13:19 +00:00
co-authored by Claude Opus 4.6
parent 335c148866
commit 47d20fbe66
82 changed files with 14011 additions and 741 deletions
+414 -212
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { createFightScene, type FightSceneController } from '../game/FightScene'
import {
fanfareRound, fanfareFight, announce, announceDeep, announceFast,
announceDeepIntro, announceRandomHype, announceRoundHype,
announceFinishHim, announceFatality, announceFlawlessVictory,
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
setMusicIntensity,
} from '../game/sounds'
interface Round {
roundNumber: number
@@ -18,8 +25,8 @@ interface Round {
interface FightData {
id: string
botA: { id: string; name: string; avatarSeed: string; eloRating: number; wins: number; losses: number; tier: number } | null
botB: { id: string; name: string; avatarSeed: string; eloRating: number; wins: number; losses: number; tier: number } | null
botA: { id: string; name: string; avatarSeed: string; archetype?: string; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
botB: { id: string; name: string; avatarSeed: string; archetype?: string; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
arena: string
winnerId: string | null
@@ -30,7 +37,7 @@ interface FightData {
rounds: Round[]
}
const props = defineProps<{ fight: FightData }>()
const props = defineProps<{ fight: FightData; autoplay?: boolean }>()
const canvasRef = ref<HTMLCanvasElement>()
const logEl = ref<HTMLElement>()
@@ -39,28 +46,45 @@ let scene: FightSceneController | null = null
const isReplaying = ref(false)
const displayHpA = ref(100)
const displayHpB = ref(100)
const visibleRounds = ref<Round[]>([])
const currentRound = ref(0)
const showingFinal = ref(true)
// Staggered log items within a round
// Floating overlay announcements (replaces in-canvas text)
const announcement = ref('')
const announcementColor = ref('#ffffff')
const announcementVisible = ref(false)
const hitText = ref('')
const hitTextVisible = ref(false)
const hitTextColor = ref('#ff2d2d')
const hitTextX = ref(50)
const hitTextY = ref(30)
const glitching = ref(false)
// Staggered log
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
onMounted(() => {
displayHpA.value = props.fight.botAHp
displayHpB.value = props.fight.botBHp
visibleRounds.value = [...props.fight.rounds]
// Build full log for static view
for (const r of props.fight.rounds) {
addRoundToLog(r, false)
onMounted(async () => {
if (props.autoplay) {
// Fresh fight — start replay immediately instead of showing static result
initScene()
await nextTick()
replay()
} else {
// Map server HP (0-200) to display (0-100), loser always 0
displayHpA.value = mapHp(props.fight.botAHp, props.fight.winnerId, props.fight.botA?.id)
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
for (const r of props.fight.rounds) addRoundToLog(r, false)
initScene()
}
initScene()
})
onUnmounted(() => {
scene?.destroy()
scene = null
})
function mapHp(hp: number, winnerId: string | null, botId: string | undefined): number {
// If there's a winner and this bot lost, show 0
if (winnerId && botId && winnerId !== botId) return 0
return Math.round((hp / 200) * 100)
}
onUnmounted(() => { scene?.destroy(); scene = null })
function initScene() {
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
@@ -74,50 +98,52 @@ function initScene() {
scene = createFightScene({
canvas: canvasRef.value,
botA: {
name: props.fight.botA.name,
seed: props.fight.botA.avatarSeed || props.fight.botA.name,
tier: props.fight.botA.tier,
},
botB: {
name: props.fight.botB.name,
seed: props.fight.botB.avatarSeed || props.fight.botB.name,
tier: props.fight.botB.tier,
},
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype },
arena: props.fight.arena,
})
}
const challengeLabel = (type: string) => {
const labels: Record<string, string> = {
speed_blitz: 'SPEED BLITZ',
riddle: 'RIDDLE ME THIS',
code_golf: 'CODE GOLF',
roast_battle: 'ROAST BATTLE',
hallucination_check: 'HALLUCINATION CHECK',
token_economy: 'TOKEN ECONOMY',
creative_writing: 'CREATIVE WRITING',
math_blitz: 'MATH BLITZ',
trap_card: 'TRAP CARD',
speed_blitz: 'SPEED BLITZ', riddle: 'RIDDLE ME THIS', code_golf: 'CODE GOLF',
roast_battle: 'ROAST BATTLE', hallucination_check: 'HALLUCINATION CHECK',
token_economy: 'TOKEN ECONOMY', creative_writing: 'CREATIVE WRITING',
math_blitz: 'MATH BLITZ', trap_card: 'TRAP CARD',
}
return labels[type] || type.toUpperCase()
}
const tierClass = (t: number) => `tier-${t}`
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
function scrollLog() { nextTick(() => { logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' }) }) }
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
// Show floating announcement over the canvas
async function showOverlay(text: string, color: string, duration: number) {
announcement.value = text
announcementColor.value = color
announcementVisible.value = true
await sleep(duration)
announcementVisible.value = false
await sleep(60)
}
function scrollLog() {
nextTick(() => {
logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' })
})
// Show hit text that flies around
async function showHitText(text: string, color: string, x: number) {
hitText.value = text
hitTextColor.value = color
hitTextX.value = x
hitTextY.value = 35 + Math.random() * 20
hitTextVisible.value = true
glitching.value = true
await sleep(100)
glitching.value = false
await sleep(700)
hitTextVisible.value = false
}
function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
if (!stagger) {
// Add all at once (static view)
const challenge = JSON.parse(round.challengeData)
logItems.value.push(
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
@@ -125,41 +151,25 @@ function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
{ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botATimeMs}ms)`, color: 'neon-cyan' },
{ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botBTimeMs}ms)`, color: 'neon-pink' },
)
if (round.narration) {
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
}
// Score
const winner = round.winnerId === props.fight.botA?.id ? props.fight.botA?.name
: round.winnerId === props.fight.botB?.id ? props.fight.botB?.name : 'DRAW'
if (round.narration) logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
const winner = round.winnerId === props.fight.botA?.id ? props.fight.botA?.name : round.winnerId === props.fight.botB?.id ? props.fight.botB?.name : 'DRAW'
logItems.value.push({ type: 'result', round: round.roundNumber, text: `${winner} wins round! (${round.botAScore} vs ${round.botBScore})`, color: 'text-secondary' })
return Promise.resolve()
}
// Staggered delivery
return (async () => {
const challenge = JSON.parse(round.challengeData)
logItems.value.push({ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' })
scrollLog()
await sleep(600)
scrollLog(); await sleep(150)
logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' })
scrollLog()
await sleep(800)
scrollLog(); await sleep(200)
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-cyan' })
scrollLog()
await sleep(500)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` Response time: ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
scrollLog()
await sleep(600)
scrollLog(); await sleep(150)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
scrollLog(); await sleep(150)
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-pink' })
scrollLog()
await sleep(500)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` Response time: ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
scrollLog(); await sleep(150)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
scrollLog()
})()
}
@@ -170,40 +180,43 @@ async function replay() {
showingFinal.value = false
displayHpA.value = 100
displayHpB.value = 100
visibleRounds.value = []
logItems.value = []
currentRound.value = 0
initScene()
await sleep(600)
scene?.startMusic()
await sleep(300)
// Arena intro
await scene!.showAnnouncement(props.fight.arenaInfo?.name || 'THE RING', '#b83dff', 1800)
logItems.value.push({ type: 'system', round: 0, text: `ARENA: ${props.fight.arenaInfo?.name || 'THE RING'}`, color: 'neon-purple' })
if (props.fight.arenaInfo?.description) {
logItems.value.push({ type: 'system', round: 0, text: props.fight.arenaInfo.description, color: 'text-muted' })
}
logItems.value.push({ type: 'system', round: 0, text: `${props.fight.botA.name} (${Math.round(props.fight.botA.eloRating)} ELO) vs ${props.fight.botB.name} (${Math.round(props.fight.botB.eloRating)} ELO)`, color: 'text-secondary' })
logItems.value.push({ type: 'divider', round: 0, text: '━'.repeat(30), color: 'text-muted' })
// Deep movie trailer intro
announceDeepIntro()
await showOverlay(props.fight.arenaInfo?.name || 'THE RING', '#b83dff', 900)
logItems.value.push(
{ type: 'system', round: 0, text: `ARENA: ${props.fight.arenaInfo?.name || 'THE RING'}`, color: 'neon-purple' },
{ type: 'system', round: 0, text: `${props.fight.botA.name} (${Math.round(props.fight.botA.eloRating)}) vs ${props.fight.botB.name} (${Math.round(props.fight.botB.eloRating)})`, color: 'text-secondary' },
{ type: 'divider', round: 0, text: '', color: '' },
)
scrollLog()
await sleep(800)
await sleep(200)
for (const round of props.fight.rounds) {
currentRound.value = round.roundNumber
// Round announcements with pauses
await scene!.showAnnouncement(`ROUND ${round.roundNumber}`, '#00f0ff', 1000)
await sleep(400)
await scene!.showAnnouncement(challengeLabel(round.challengeType), '#b83dff', 1000)
await sleep(400)
await scene!.showAnnouncement('FIGHT!', '#ff2d7b', 600)
await sleep(300)
fanfareRound(round.roundNumber)
await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
await sleep(80)
await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
await sleep(80)
fanfareFight()
announceRoundHype()
sfxCrowdCheer()
await showOverlay('FIGHT!', '#ff2d7b', 400)
await sleep(80)
// Stagger the battle log alongside the fight
// Log + fight animation in parallel
const logPromise = addRoundToLog(round, true)
// Play the round animation
const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
const aWon = round.winnerId === props.fight.botA!.id
const bWon = round.winnerId === props.fight.botB!.id
await scene!.playRound({
round: round.roundNumber,
@@ -217,24 +230,44 @@ async function replay() {
botBScore: round.botBScore || 0,
})
// Wait for log to finish
// Hit text overlay
const hitWords = isCritical
? ['CRITICAL!', 'DEVASTATING!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!']
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!']
if (aWon || bWon) {
showHitText(
hitWords[Math.floor(Math.random() * hitWords.length)],
isCritical ? '#ffe14d' : '#ff2d2d',
aWon ? 65 : 35,
)
// Crowd reactions
if (isCritical) {
sfxCrowdGasp()
setTimeout(() => sfxCrowdOoh(), 400)
} else if (Math.random() < 0.4) {
sfxCrowdOoh()
}
// Random hype voiceover on big moments
if (isCritical || Math.random() < 0.3) {
setTimeout(() => announceRandomHype(), 300)
}
}
await logPromise
// Narration after fight
if (round.narration) {
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
scrollLog()
}
// Round result
const aWon = round.winnerId === props.fight.botA!.id
const bWon = round.winnerId === props.fight.botB!.id
const winner = aWon ? props.fight.botA!.name : bWon ? props.fight.botB!.name : 'DRAW'
logItems.value.push({ type: 'result', round: round.roundNumber, text: `${winner} ${aWon || bWon ? 'wins round!' : '- no winner'}`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' })
logItems.value.push({ type: 'divider', round: round.roundNumber, text: '', color: '' })
logItems.value.push(
{ type: 'result', round: round.roundNumber, text: `${winner} ${aWon || bWon ? 'wins round!' : '- no winner'}`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' },
{ type: 'divider', round: round.roundNumber, text: '', color: '' },
)
scrollLog()
// Update HP
// HP update
const baseDmg = 15
if (aWon) {
const dmg = Math.max(8, baseDmg + ((round.botAScore || 5) - (round.botBScore || 5)) * 3)
@@ -244,32 +277,76 @@ async function replay() {
displayHpA.value = Math.max(0, displayHpA.value - Math.round(dmg))
}
// Longer pause between rounds for ~1 min total fight
await sleep(2000)
// Dynamic music intensity — lower HP = more intense
const lowestHp = Math.min(displayHpA.value, displayHpB.value)
setMusicIntensity(1 - lowestHp / 100)
// Taunting between rounds — winner taunts, sometimes both
if (scene && (aWon || bWon)) {
const winnerSide = aWon ? 'a' : 'b'
await sleep(150)
await scene.playTaunt(winnerSide)
// Sometimes loser taunts back (30% chance)
if (Math.random() < 0.3) {
await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a')
}
await sleep(200)
} else {
await sleep(400)
}
}
// Final HP
displayHpA.value = props.fight.botAHp
displayHpB.value = props.fight.botBHp
// Map server HP (0-200) to display (0-100), loser always 0
displayHpA.value = mapHp(props.fight.botAHp, props.fight.winnerId, props.fight.botA?.id)
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
// Ending
if (props.fight.winnerId && scene) {
const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b'
const isPerfect = props.fight.botAHp === 100 || props.fight.botBHp === 100
scene?.stopMusic()
if (isPerfect) {
await scene.playPerfect(winningSide)
// Always end with a dramatic death/KO sequence
if (scene) {
if (props.fight.winnerId) {
const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b'
const winnerHp = winningSide === 'a' ? props.fight.botAHp : props.fight.botBHp
const isPerfect = winnerHp >= 200
const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name
// "FINISH HIM!" moment before KO
sfxDrumRoll()
await sleep(500)
announceFinishHim()
await showOverlay('FINISH HIM!', '#ff2d2d', 900)
await sleep(150)
if (isPerfect) {
await scene.playPerfect(winningSide, winnerName)
announceFlawlessVictory()
} else {
await scene.playKO(winningSide, winnerName)
announceFatality()
}
glitching.value = true
sfxApplause()
sfxCrowdCheer()
await sleep(200)
glitching.value = false
await showOverlay(`${winnerName} WINS!`, '#00f0ff', 1800)
logItems.value.push(
{ type: 'divider', round: 99, text: '', color: '' },
{ type: 'result', round: 99, text: `${winnerName.toUpperCase()} WINS!${isPerfect ? ' PERFECT!' : ''}`, color: winningSide === 'a' ? 'neon-cyan' : 'neon-pink' },
)
scrollLog()
} else {
await scene.playKO(winningSide)
// Draws get a dramatic double-KO
await scene.playKO('a', 'NOBODY')
await showOverlay('DOUBLE K.O.!', '#ff2d2d', 1500)
logItems.value.push(
{ type: 'divider', round: 99, text: '', color: '' },
{ type: 'result', round: 99, text: 'DOUBLE K.O.! DRAW!', color: 'neon-purple' },
)
scrollLog()
}
const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name
await sleep(600)
await scene.showAnnouncement(`${winnerName} WINS!`, '#00f0ff', 3000)
logItems.value.push({ type: 'divider', round: 99, text: '━'.repeat(30), color: 'text-muted' })
logItems.value.push({ type: 'result', round: 99, text: `${winnerName.toUpperCase()} WINS THE FIGHT!${isPerfect ? ' PERFECT!' : ''}`, color: winningSide === 'a' ? 'neon-cyan' : 'neon-pink' })
scrollLog()
}
isReplaying.value = false
@@ -277,11 +354,10 @@ async function replay() {
</script>
<template>
<div class="h-full flex flex-col lg:flex-row gap-2">
<div class="h-full flex flex-col lg:flex-row gap-1 sm:gap-2">
<!-- LEFT: Terminal Battle Log -->
<div class="lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 neon-border-cyan overflow-hidden order-2 lg:order-1">
<!-- Terminal header -->
<!-- LEFT: Battle Log (below on mobile) -->
<div class="h-[30vh] sm:h-auto lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden order-2 lg:order-1">
<div class="bg-surface-raised border-b border-border px-3 py-1.5 flex items-center gap-2 flex-shrink-0">
<span class="w-2.5 h-2.5 rounded-full bg-ko" />
<span class="w-2.5 h-2.5 rounded-full bg-neon-yellow" />
@@ -289,60 +365,21 @@ 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-4 font-mono 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 leading-relaxed">
<div v-for="(item, idx) in logItems" :key="idx">
<div v-if="item.type === 'divider'" class="py-2">
<div v-if="item.text" class="text-border text-xs">{{ item.text }}</div>
</div>
<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>
<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 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>
<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 v-if="logItems.length === 0 && !isReplaying" class="text-text-muted italic pt-8 text-center text-sm">
Hit REPLAY to watch the fight.
</div>
<div v-if="isReplaying && logItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">
Fight starting...
</div>
<div v-if="logItems.length === 0 && !isReplaying" class="text-text-muted italic pt-8 text-center text-sm">Hit REPLAY to watch the fight.</div>
<div v-if="isReplaying && logItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Fight starting...</div>
</div>
</div>
@@ -350,63 +387,87 @@ async function replay() {
<div class="lg:w-[62%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2">
<!-- Health bars -->
<div class="px-3 py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<div class="flex items-center gap-2">
<div class="flex-shrink-0 min-w-0">
<p class="font-display font-black text-xs tracking-wider truncate"
<div class="px-2 sm:px-3 py-1.5 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<div class="flex items-center gap-1 sm:gap-2">
<div class="flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none">
<img
v-if="fight.botA?.profilePicUrl"
:src="fight.botA.profilePicUrl"
alt=""
class="w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-cyan/40 flex-shrink-0"
/>
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate"
:class="fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">
{{ fight.botA?.name }}
</p>
</div>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar"
:style="{ width: `${displayHpA}%` }" />
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar" :style="{ width: `${displayHpA}%` }" />
</div>
<span class="font-mono font-bold text-sm w-8 text-right"
:class="displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'">
{{ displayHpA }}
</span>
<span class="font-glitch text-neon-purple text-base px-1">VS</span>
<span class="font-mono font-bold text-sm w-8 text-left"
:class="displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'">
{{ displayHpB }}
</span>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto"
:style="{ width: `${displayHpB}%` }" />
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-right" :class="displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpA }}</span>
<span class="font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1">VS</span>
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-left" :class="displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpB }}</span>
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto" :style="{ width: `${displayHpB}%` }" />
</div>
<div class="flex-shrink-0 min-w-0">
<p class="font-display font-black text-xs tracking-wider truncate text-right"
<div class="flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none">
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-right"
:class="fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'">
{{ fight.botB?.name }}
</p>
<img
v-if="fight.botB?.profilePicUrl"
:src="fight.botB.profilePicUrl"
alt=""
class="w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-pink/40 flex-shrink-0"
/>
</div>
</div>
<div class="flex items-center justify-between mt-1">
<span class="font-pixel text-[9px]" :class="tierClass(fight.botA?.tier || 0)">
{{ Math.round(fight.botA?.eloRating || 0) }} ELO
</span>
<span class="font-pixel text-[9px] text-text-muted">
{{ fight.arenaInfo?.name }} | R{{ currentRound || fight.totalRounds }}/{{ fight.totalRounds }}
</span>
<span class="font-pixel text-[9px]" :class="tierClass(fight.botB?.tier || 0)">
{{ Math.round(fight.botB?.eloRating || 0) }} ELO
</span>
<div class="flex items-center justify-between mt-0.5 sm:mt-1">
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(fight.botA?.tier || 0)">{{ Math.round(fight.botA?.eloRating || 0) }}</span>
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted">{{ fight.arenaInfo?.name }} | R{{ currentRound || fight.totalRounds }}/{{ fight.totalRounds }}</span>
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(fight.botB?.tier || 0)">{{ Math.round(fight.botB?.eloRating || 0) }}</span>
</div>
</div>
<!-- Canvas -->
<div class="flex-1 relative min-h-0">
<!-- Canvas + floating overlays -->
<div class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
<canvas ref="canvasRef" class="w-full h-full block" />
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="announcementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<div class="announce-text-wrapper">
<p class="font-funky text-5xl sm:text-7xl tracking-widest announce-text uppercase announce-chromatic"
:data-text="announcement"
:style="{ color: announcementColor, textShadow: `0 0 20px ${announcementColor}, 0 0 40px ${announcementColor}, 0 0 80px ${announcementColor}40, 0 0 120px ${announcementColor}20` }">
{{ announcement }}
</p>
</div>
</div>
</Transition>
<!-- Floating hit text -->
<Transition name="hit-pop">
<div v-if="hitTextVisible"
class="absolute pointer-events-none z-30"
:style="{ left: `${hitTextX}%`, top: `${hitTextY}%`, transform: 'translate(-50%, -50%)' }">
<div class="hit-text-wrapper">
<p class="font-neon text-4xl sm:text-5xl tracking-wider hit-text hit-chromatic"
:data-text="hitText"
:style="{ color: hitTextColor, textShadow: `0 0 15px ${hitTextColor}, 0 0 30px ${hitTextColor}, 0 0 60px ${hitTextColor}60` }">
{{ hitText }}
</p>
</div>
</div>
</Transition>
</div>
<!-- Controls -->
<div class="px-3 py-2 border-t border-border flex-shrink-0 flex items-center justify-between bg-surface-raised/50">
<button
class="px-6 py-2 border border-neon-pink/50 text-neon-pink font-display font-bold text-xs
class="px-6 py-2 border border-neon-pink/50 text-neon-pink font-marker text-sm
tracking-widest hover:bg-neon-pink/10 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isReplaying"
@@ -414,10 +475,151 @@ async function replay() {
>
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
</button>
<span class="font-pixel text-[10px] text-text-muted">
{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}
</span>
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* Announcement transitions */
.announce-enter-active { animation: announce-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
.announce-leave-active { animation: announce-out 0.25s ease-in; }
@keyframes announce-in { from { opacity: 0; transform: scale(0.1) rotate(-15deg); filter: blur(8px); } to { opacity: 1; transform: scale(1) rotate(0); filter: blur(0); } }
@keyframes announce-out { from { opacity: 1; } to { opacity: 0; transform: scale(1.5) rotate(5deg); filter: blur(4px); } }
.announce-text-wrapper {
position: relative;
}
.announce-text {
animation: announce-pulse 0.4s ease-in-out infinite alternate, announce-hue 2s linear infinite;
-webkit-text-stroke: 1px rgba(0,0,0,0.3);
}
@keyframes announce-pulse {
from { transform: scale(1) rotate(-1deg); }
to { transform: scale(1.08) rotate(1deg); }
}
@keyframes announce-hue {
0% { filter: hue-rotate(0deg) brightness(1); }
25% { filter: hue-rotate(15deg) brightness(1.1); }
50% { filter: hue-rotate(0deg) brightness(1.2); }
75% { filter: hue-rotate(-15deg) brightness(1.1); }
100% { filter: hue-rotate(0deg) brightness(1); }
}
/* Chromatic aberration on announcements */
.announce-chromatic {
position: relative;
}
.announce-chromatic::before,
.announce-chromatic::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
right: 0;
text-align: center;
opacity: 0.5;
pointer-events: none;
}
.announce-chromatic::before {
color: #ff2d7b;
animation: chromatic-r 0.3s ease-in-out infinite alternate;
clip-path: inset(0 0 50% 0);
}
.announce-chromatic::after {
color: #00f0ff;
animation: chromatic-b 0.3s ease-in-out infinite alternate-reverse;
clip-path: inset(50% 0 0 0);
}
@keyframes chromatic-r { from { transform: translate(-3px, -2px); } to { transform: translate(3px, 2px); } }
@keyframes chromatic-b { from { transform: translate(3px, 2px); } to { transform: translate(-3px, -2px); } }
/* Hit text */
.hit-pop-enter-active { animation: hit-in 0.12s cubic-bezier(0.34, 1.56, 0.64, 1); }
.hit-pop-leave-active { animation: hit-out 0.6s ease-in; }
@keyframes hit-in { from { opacity: 0; transform: translate(-50%, -50%) scale(0.1) rotate(-20deg); } to { opacity: 1; transform: translate(-50%, -50%) scale(1.2) rotate(0); } }
@keyframes hit-out { from { opacity: 1; transform: translate(-50%, -50%) scale(1); } to { opacity: 0; transform: translate(-50%, -100%) scale(0.4) rotate(15deg); } }
.hit-text-wrapper {
position: relative;
}
.hit-text {
animation: hit-shake 0.08s ease-in-out 5, hit-rainbow 0.5s steps(4) infinite;
-webkit-text-stroke: 1px rgba(0,0,0,0.4);
}
@keyframes hit-shake {
0%, 100% { transform: translate(-50%, -50%) rotate(0); }
20% { transform: translate(-48%, -52%) rotate(-5deg) scale(1.15); }
40% { transform: translate(-52%, -48%) rotate(5deg) scale(1.1); }
60% { transform: translate(-50%, -53%) rotate(-3deg) scale(1.2); }
80% { transform: translate(-49%, -47%) rotate(4deg) scale(1.05); }
}
@keyframes hit-rainbow {
0% { filter: hue-rotate(0deg) brightness(1.2); }
25% { filter: hue-rotate(60deg) brightness(1.4); }
50% { filter: hue-rotate(120deg) brightness(1.2); }
75% { filter: hue-rotate(180deg) brightness(1.3); }
100% { filter: hue-rotate(360deg) brightness(1.2); }
}
/* Chromatic aberration on hits */
.hit-chromatic {
position: relative;
}
.hit-chromatic::before,
.hit-chromatic::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
opacity: 0.6;
pointer-events: none;
}
.hit-chromatic::before {
color: #ff2d2d;
animation: hit-chr-r 0.06s ease-in-out infinite alternate;
}
.hit-chromatic::after {
color: #00f0ff;
animation: hit-chr-b 0.06s ease-in-out infinite alternate-reverse;
}
@keyframes hit-chr-r { from { transform: translate(-4px, -3px) rotate(-2deg); } to { transform: translate(4px, 3px) rotate(2deg); } }
@keyframes hit-chr-b { from { transform: translate(4px, 3px) rotate(2deg); } to { transform: translate(-4px, -3px) rotate(-2deg); } }
/* Glitch effect on hits */
.glitch-container {
animation: glitch-screen 0.15s steps(2) 2;
}
@keyframes glitch-screen {
0% { filter: none; }
20% { filter: hue-rotate(90deg) saturate(2); transform: translate(2px, -1px); }
40% { filter: hue-rotate(-90deg) contrast(1.5); transform: translate(-2px, 1px); }
60% { filter: invert(0.1) saturate(3); transform: translate(1px, 2px); }
80% { filter: hue-rotate(45deg) brightness(1.3); transform: translate(-1px, -2px); }
100% { filter: none; transform: none; }
}
/* VHS scanline overlay on canvas */
.glitch-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.06) 2px,
rgba(0, 0, 0, 0.06) 4px
);
z-index: 40;
animation: scanline-drift 0.3s linear infinite;
}
@keyframes scanline-drift {
0% { background-position-y: 0; }
100% { background-position-y: 4px; }
}
</style>
+26 -4
View File
@@ -1,21 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import PixelGlove from './PixelGlove.vue'
import { useNostr } from '../composables/useNostr'
const { bot, isLoggedIn } = useNostr()
const isMenuOpen = ref(false)
const links = [
{ to: '/join', label: 'JOIN A BOUT' },
{ to: '/arena', label: 'ARENA' },
{ to: '/schedule', label: 'FIGHT CARD' },
{ to: '/leaderboard', label: 'RANKINGS' },
{ to: '/register', label: 'ENTER A BOT' },
]
</script>
<template>
<nav class="border-b border-border bg-surface/90 backdrop-blur-md sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<RouterLink to="/" class="flex items-center gap-3 group">
<div class="px-6 h-16 flex items-center justify-between">
<RouterLink to="/" class="flex items-center gap-2 group">
<span class="flex items-center gap-0.5" style="transform: rotate(-10deg)">
<PixelGlove :size="18" />
<PixelGlove :size="18" flip />
</span>
<span class="font-display font-black text-neon-pink text-lg tracking-widest glow-pink">
BOTFIGHTS
</span>
@@ -31,6 +37,14 @@ const links = [
>
{{ link.label }}
</RouterLink>
<RouterLink
v-if="isLoggedIn && bot"
:to="`/bot/${bot.name}`"
class="text-xs font-display font-bold text-neon-cyan tracking-wider
hover:text-neon-pink transition-colors duration-200"
>
{{ bot.name.toUpperCase() }}
</RouterLink>
</div>
<button
@@ -63,6 +77,14 @@ const links = [
>
{{ link.label }}
</RouterLink>
<RouterLink
v-if="isLoggedIn && bot"
:to="`/bot/${bot.name}`"
class="block text-sm font-display font-bold text-neon-cyan tracking-wider"
@click="isMenuOpen = false"
>
{{ bot.name.toUpperCase() }}
</RouterLink>
</div>
</nav>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
size?: number
flip?: boolean
}>(), {
size: 24,
flip: false,
})
// 10x11 pixel boxing glove facing right
// M=main body, H=highlight, S=shadow, C=cuff
const grid = [
'...MMMM...',
'..HMMMMM..',
'.HMMMMMM..',
'HMMMMMMS..',
'MMMMMMMMS.',
'MMMMMMMMSS',
'MMMMMMMM..',
'.MMMMMM...',
'..CCCC....',
'..CCCC....',
'...CC.....',
]
const colorMap: Record<string, string> = {
M: '#ff2d78',
H: '#ff6fa0',
S: '#cc1155',
C: '#64dfff',
}
const pixels = computed(() => {
const result: { x: number; y: number; color: string }[] = []
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
const ch = grid[y][x]
if (ch !== '.' && colorMap[ch]) {
result.push({ x, y, color: colorMap[ch] })
}
}
}
return result
})
const cols = grid[0].length
const rows = grid.length
</script>
<template>
<svg
:width="props.size"
:height="props.size * (rows / cols)"
:viewBox="`0 0 ${cols} ${rows}`"
:style="{ transform: props.flip ? 'scaleX(-1)' : undefined }"
class="pixel-glove"
>
<rect
v-for="(p, i) in pixels"
:key="i"
:x="p.x"
:y="p.y"
width="1"
height="1"
:fill="p.color"
/>
</svg>
</template>
<style scoped>
.pixel-glove {
image-rendering: pixelated;
filter: drop-shadow(0 0 3px rgba(255, 45, 120, 0.6));
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { generateSpriteSheet, getBotColors, FRAME_SIZE, ANIMATIONS } from '../game/sprites'
const props = defineProps<{
seed: string
archetype?: string
size?: number
}>()
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 idleAnim = ANIMATIONS.idle
const f = frame % idleAnim.frames
ctx.drawImage(
img,
f * FRAME_SIZE, idleAnim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
0, 0, displaySize, displaySize,
)
frame++
animHandle = setTimeout(() => render(), 180)
}
function loadSprite() {
const colors = getBotColors(props.seed)
const dataUrl = generateSpriteSheet(props.seed, 0, colors.primary, colors.secondary, props.archetype)
img = new Image()
img.onload = () => render()
img.src = dataUrl
}
onMounted(() => loadSprite())
watch(() => [props.seed, props.archetype], () => {
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>
+184
View File
@@ -0,0 +1,184 @@
import { ref, readonly, computed } from 'vue'
interface BotData {
id: string
name: string
avatarSeed: string
archetype: string
profilePicUrl: string | null
eloRating: number
wins: number
losses: number
winStreak: number
bestStreak: number
tier: number
}
interface NostrWindow {
getPublicKey(): Promise<string>
signEvent(event: Record<string, unknown>): Promise<Record<string, unknown>>
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>
}
declare global {
interface Window {
nostr?: NostrWindow
}
}
const pubkey = ref<string | null>(null)
const bot = ref<BotData | null>(null)
const profilePicUrl = ref<string | null>(null)
const isLoading = ref(false)
export function useNostr() {
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
const hasExtension = computed(() => !!window.nostr)
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
if (!window.nostr) {
throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.')
}
isLoading.value = true
try {
const pk = await window.nostr.getPublicKey()
pubkey.value = pk
// Fetch Nostr profile pic from relay
const pic = await fetchNostrProfilePic(pk)
if (pic) profilePicUrl.value = pic
// Check if this pubkey has a bot
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pk }),
})
if (res.ok) {
const data = await res.json()
if (data.exists) {
bot.value = data.bot
return { pubkey: pk, bot: data.bot }
}
}
return { pubkey: pk, bot: null }
} finally {
isLoading.value = false
}
}
async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<BotData> {
if (!pubkey.value) throw new Error('Not logged in')
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pubkey: pubkey.value,
name,
webhookUrl,
archetype,
profilePicUrl: profilePicUrl.value,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Registration failed')
bot.value = {
id: data.id,
name: data.name,
avatarSeed: data.name,
archetype: data.archetype,
profilePicUrl: profilePicUrl.value,
eloRating: 1200,
wins: 0,
losses: 0,
winStreak: 0,
bestStreak: 0,
tier: 0,
}
return bot.value
}
function logout() {
pubkey.value = null
bot.value = null
profilePicUrl.value = null
}
return {
pubkey: readonly(pubkey),
bot: readonly(bot),
profilePicUrl: readonly(profilePicUrl),
isLoggedIn,
isLoading: readonly(isLoading),
hasExtension,
login,
registerBot,
logout,
}
}
// Fetch profile picture from a Nostr relay
async function fetchNostrProfilePic(pk: string): Promise<string | null> {
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
]
for (const relay of relays) {
try {
const pic = await queryRelay(relay, pk)
if (pic) return pic
} catch {
continue
}
}
return null
}
function queryRelay(url: string, pk: string): Promise<string | null> {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
ws.close()
resolve(null)
}, 3000)
const ws = new WebSocket(url)
const subId = Math.random().toString(36).slice(2, 10)
ws.onopen = () => {
// Request kind 0 (metadata) for this pubkey
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
const meta = JSON.parse(data[2].content)
clearTimeout(timeout)
ws.close()
resolve(meta.picture || null)
} else if (data[0] === 'EOSE') {
clearTimeout(timeout)
ws.close()
resolve(null)
}
} catch {
// ignore parse errors
}
}
ws.onerror = () => {
clearTimeout(timeout)
resolve(null)
}
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
import type { Archetype } from '../constants'
export const alien: Archetype = {
name: 'alien',
weight: 0.03,
dimensionOverrides: (tier) => ({
hw: 13 + tier,
hh: 11 + tier,
bw: 8 + tier * 2,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko, knockback, frame,
hx, hy, hh, hw, cx, hOff, by } = p
// Big oval eyes (huge, almond-shaped)
if (!ko) {
const eyeY = hy + Math.floor(hh * 0.3)
const leX = hx + 1
const reX = hx + Math.floor(hw * 0.55)
const eyeW = Math.floor(hw * 0.35)
const eyeH = Math.floor(hh * 0.3)
// Large dark eyes
for (let ey = eyeY; ey < eyeY + eyeH; ey++) {
const progress = (ey - eyeY) / eyeH
const rowW = Math.floor(eyeW * (1 - Math.abs(progress - 0.5) * 1.5))
for (let ex = 0; ex < rowW; ex++) {
px(leX + ex + Math.floor((eyeW - rowW) / 2), ey, '#112211', ox, oy)
px(reX + ex + Math.floor((eyeW - rowW) / 2), ey, '#112211', ox, oy)
}
}
// Glowing pupil
const pupY = eyeY + Math.floor(eyeH / 2)
px(leX + Math.floor(eyeW / 2), pupY, '#44ff44', ox, oy)
px(reX + Math.floor(eyeW / 2), pupY, '#44ff44', ox, oy)
// Pupil flicker
if (frame % 3 === 0) {
px(leX + Math.floor(eyeW / 2), pupY, '#88ff88', ox, oy)
px(reX + Math.floor(eyeW / 2), pupY, '#88ff88', ox, oy)
}
}
// Antenna (single, glowing tip)
if (!ko) {
const antX = cx + hOff
const antBase = hy - 1
for (let a = 0; a < 4; a++) {
const wobble = idle ? Math.round(Math.sin(t * Math.PI * 3 + a * 0.5) * 1) : 0
px(antX + wobble, antBase - a, '#44aa44', ox, oy)
}
// Glowing tip
px(antX, antBase - 4, '#88ff88', ox, oy)
if (idle && Math.sin(t * Math.PI * 6) > 0) {
px(antX - 1, antBase - 4, '#44ff44', ox, oy)
px(antX + 1, antBase - 4, '#44ff44', ox, oy)
}
}
// Small slit mouth
if (!ko) {
const mY = hy + Math.floor(hh * 0.7)
px(cx + hOff - 1, mY, '#224422', ox, oy)
px(cx + hOff, mY, '#224422', ox, oy)
px(cx + hOff + 1, mY, '#224422', ox, oy)
}
// Green tinted body patches
for (let iy = by + 1; iy < by + p.bh; iy += 3) {
px(p.bx + 1, iy, '#33aa55', ox, oy)
}
},
}
@@ -0,0 +1,355 @@
import type { Archetype } from '../constants'
export const elephant: Archetype = {
name: 'elephant', weight: 0.02,
dimensionOverrides: (tier) => ({ legW: 6 + tier, legH: 6 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Big ears
for (let iy = hy; iy < hy + hh; iy++) {
px(hx - 2, iy, '#999999', ox, oy); px(hx - 3, iy, '#888888', ox, oy)
px(hx + hw + 1, iy, '#999999', ox, oy); px(hx + hw + 2, iy, '#888888', ox, oy)
}
// Trunk
const trunkWave = idle ? Math.sin(t * Math.PI * 2) * 2 : 0
for (let i = 0; i < 5; i++) {
px(hx + Math.floor(hw / 2) + Math.round(trunkWave * (i / 5)), hy + hh + i, '#999999', ox, oy)
}
// Tusks
px(hx + 1, hy + hh - 1, '#ffffcc', ox, oy); px(hx + 1, hy + hh, '#ffffcc', ox, oy)
px(hx + hw - 2, hy + hh - 1, '#ffffcc', ox, oy); px(hx + hw - 2, hy + hh, '#ffffcc', ox, oy)
},
}
export const giraffe: Archetype = {
name: 'giraffe', weight: 0.02,
dimensionOverrides: () => ({ legH: 10 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Spots on body
const spots = [[bx + 2, by + 1], [bx + bw - 3, by + 2], [bx + 1, by + bh - 3], [bx + bw - 2, by + bh - 2]]
for (const [sx, sy] of spots) {
px(sx, sy, '#aa7722', ox, oy); px(sx + 1, sy, '#aa7722', ox, oy)
px(sx, sy + 1, '#aa7722', ox, oy)
}
// Ossicones (little horns)
px(hx + 2, hy - 1, '#886633', ox, oy); px(hx + 2, hy - 2, '#ffcc88', ox, oy)
px(hx + hw - 3, hy - 1, '#886633', ox, oy); px(hx + hw - 3, hy - 2, '#ffcc88', ox, oy)
// Long eyelashes
px(hx + 1, hy + Math.floor(hh * 0.3), '#000000', ox, oy)
px(hx + hw - 2, hy + Math.floor(hh * 0.3), '#000000', ox, oy)
},
}
export const hippo: Archetype = {
name: 'hippo', weight: 0.02,
dimensionOverrides: (tier) => ({ legW: 6 + tier }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw } = p
if (ko) return
// Wide mouth (when idle, mouth opens)
const mouthOpen = idle ? Math.sin(t * Math.PI) > 0.5 : false
if (mouthOpen) {
for (let iy = hy + Math.floor(hh * 0.6); iy < hy + hh + 2; iy++) {
for (let ix = hx; ix < hx + hw; ix++) px(ix, iy, '#ff6688', ox, oy)
}
// Teeth
px(hx + 1, hy + Math.floor(hh * 0.6), '#ffffff', ox, oy)
px(hx + hw - 2, hy + Math.floor(hh * 0.6), '#ffffff', ox, oy)
}
// Small ears on top
px(hx + 1, hy - 1, '#998877', ox, oy); px(hx + hw - 2, hy - 1, '#998877', ox, oy)
// Nostrils
px(hx + Math.floor(hw / 2) - 1, hy + Math.floor(hh * 0.5), '#553344', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy + Math.floor(hh * 0.5), '#553344', ox, oy)
},
}
export const lion: Archetype = {
name: 'lion', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Mane (circle of fur around head)
for (let angle = 0; angle < 12; angle++) {
const a = angle * Math.PI * 2 / 12
const mx = hx + Math.floor(hw / 2) + Math.round(Math.cos(a) * (hw / 2 + 2))
const my = hy + Math.floor(hh / 2) + Math.round(Math.sin(a) * (hh / 2 + 2))
px(mx, my, '#cc8822', ox, oy)
}
// Nose
px(hx + Math.floor(hw / 2), hy + Math.floor(hh * 0.55), '#332211', ox, oy)
// Tail with tuft
px(bx + bw + 1, by + bh - 2, '#ccaa44', ox, oy)
px(bx + bw + 2, by + bh - 3, '#ccaa44', ox, oy)
px(bx + bw + 3, by + bh - 3, '#cc8822', ox, oy)
px(bx + bw + 3, by + bh - 4, '#cc8822', ox, oy)
},
}
export const monkey: Archetype = {
name: 'monkey', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Big round ears
for (let d = -1; d <= 1; d++) {
px(hx - 2, hy + Math.floor(hh / 2) + d, '#cc9966', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2) + d, '#cc9966', ox, oy)
}
px(hx - 2, hy + Math.floor(hh / 2), '#ffaa88', ox, oy) // inner
px(hx + hw + 1, hy + Math.floor(hh / 2), '#ffaa88', ox, oy)
// Curled tail
const curl = idle ? Math.sin(t * Math.PI * 2) * 2 : 0
px(bx + bw + 1, by + bh - 1, '#886644', ox, oy)
px(bx + bw + 2, by + bh - 2 + Math.round(curl), '#886644', ox, oy)
px(bx + bw + 3, by + bh - 1 + Math.round(curl), '#886644', ox, oy)
px(bx + bw + 3, by + bh + Math.round(curl), '#886644', ox, oy)
// Belly patch
px(bx + Math.floor(bw / 2), by + Math.floor(bh / 2), '#ffcc99', ox, oy)
px(bx + Math.floor(bw / 2) + 1, by + Math.floor(bh / 2), '#ffcc99', ox, oy)
},
}
export const parrot: Archetype = {
name: 'parrot', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Colorful feather crest
const feathers = ['#ff0000', '#ffaa00', '#ffff00', '#00ff00', '#0088ff']
for (let i = 0; i < feathers.length; i++) px(hx + Math.floor(hw / 2), hy - 1 - i, feathers[i], ox, oy)
// Curved beak
px(hx + hw, hy + Math.floor(hh / 2), '#ff8800', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ff6600', ox, oy)
// Colorful body stripes
px(bx + 1, by + 1, '#ff0000', ox, oy); px(bx + 2, by + 1, '#ff0000', ox, oy)
px(bx + 1, by + 3, '#00ff00', ox, oy); px(bx + 2, by + 3, '#00ff00', ox, oy)
px(bx + 1, by + 5, '#0088ff', ox, oy); px(bx + 2, by + 5, '#0088ff', ox, oy)
// Tail feathers
const wave = idle ? Math.sin(t * Math.PI * 2) * 2 : 0
px(bx + bw + 1, by + bh - 2, '#ff0000', ox, oy)
px(bx + bw + 2, by + bh - 1 + Math.round(wave), '#00ff00', ox, oy)
px(bx + bw + 3, by + bh + Math.round(wave), '#0088ff', ox, oy)
},
}
export const raccoon: Archetype = {
name: 'raccoon', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Mask (black around eyes)
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
px(ix, hy + Math.floor(hh * 0.3), '#222222', ox, oy)
px(ix, hy + Math.floor(hh * 0.4), '#222222', ox, oy)
}
// Pointy ears
px(hx + 1, hy - 1, '#888877', ox, oy); px(hx + hw - 2, hy - 1, '#888877', ox, oy)
// Striped tail
for (let i = 0; i < 6; i++) {
const color = i % 2 === 0 ? '#888877' : '#333322'
px(bx + bw + 1 + Math.floor(i / 2), by + bh - 2 + (i % 3), color, ox, oy)
}
// Tiny hands
px(bx - 1, by + bh, '#444444', ox, oy); px(bx + bw, by + bh, '#444444', ox, oy)
},
}
export const snakeArch: Archetype = {
name: 'snake', weight: 0.02,
dimensionOverrides: () => ({ legH: 2, legW: 4 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Forked tongue
if (idle && Math.sin(t * Math.PI * 4) > 0) {
px(hx + hw, hy + Math.floor(hh / 2), '#ff0044', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2) - 1, '#ff0044', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ff0044', ox, oy)
}
// Diamond pattern on body
for (let ix = bx + 1; ix < bx + bw - 1; ix += 3) {
px(ix, by + Math.floor(bh / 2), '#ffcc44', ox, oy)
}
// Slit eyes
px(hx + 2, hy + Math.floor(hh * 0.35), '#ffcc00', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ffcc00', ox, oy)
// Coiled body underneath
const coil = Math.sin(t * Math.PI * 2) * 2
px(bx + 2, by + bh + 1 + Math.round(coil), '#448833', ox, oy)
px(bx + bw - 3, by + bh + 1 - Math.round(coil), '#448833', ox, oy)
},
}
export const turtle: Archetype = {
name: 'turtle', weight: 0.02,
dimensionOverrides: () => ({ legH: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p
if (ko) return
// Shell (dome on back)
for (let iy = by - 1; iy < by + bh + 1; iy++) {
for (let ix = bx - 1; ix < bx + bw + 1; ix++) px(ix, iy, '#448833', ox, oy)
}
// Shell pattern
px(bx + Math.floor(bw / 2), by + Math.floor(bh / 2), '#336622', ox, oy)
px(bx + 2, by + 2, '#336622', ox, oy); px(bx + bw - 3, by + 2, '#336622', ox, oy)
px(bx + 2, by + bh - 3, '#336622', ox, oy); px(bx + bw - 3, by + bh - 3, '#336622', ox, oy)
// Beak
px(hx + hw, hy + Math.floor(hh / 2), '#aaaa44', ox, oy)
},
}
export const whale: Archetype = {
name: 'whale', weight: 0.02,
dimensionOverrides: () => ({ legH: 2 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Belly (lighter underbelly)
for (let ix = bx + 1; ix < bx + bw - 1; ix++) {
px(ix, by + bh - 2, '#aabbcc', ox, oy); px(ix, by + bh - 1, '#99aabb', ox, oy)
}
// Tail fluke
px(bx + bw + 1, by + Math.floor(bh / 2) - 2, '#6688aa', ox, oy)
px(bx + bw + 2, by + Math.floor(bh / 2) - 3, '#6688aa', ox, oy)
px(bx + bw + 1, by + Math.floor(bh / 2) + 2, '#6688aa', ox, oy)
px(bx + bw + 2, by + Math.floor(bh / 2) + 3, '#6688aa', ox, oy)
// Spout (when idle)
if (idle && Math.sin(t * Math.PI * 2) > 0.5) {
for (let h = 0; h < 4; h++) {
px(cx + hOff, hy - 2 - h, '#aaddff', ox, oy)
}
px(cx + hOff - 1, hy - 5, '#aaddff', ox, oy)
px(cx + hOff + 1, hy - 5, '#aaddff', ox, oy)
}
// Tiny eye
px(hx + 1, hy + Math.floor(hh * 0.4), '#222222', ox, oy)
},
}
export const crocodile: Archetype = {
name: 'crocodile', weight: 0.02,
dimensionOverrides: () => ({ legH: 4 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Long snout
px(hx + hw, hy + Math.floor(hh / 2), '#557744', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2), '#557744', ox, oy)
px(hx + hw + 2, hy + Math.floor(hh / 2), '#446633', ox, oy)
// Teeth
px(hx + hw, hy + Math.floor(hh / 2) + 1, '#ffffff', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ffffff', ox, oy)
// Scaly back ridge
for (let ix = bx + 1; ix < bx + bw; ix += 2) {
px(ix, by - 1, '#446633', ox, oy)
}
// Thick tail
px(bx + bw + 1, by + bh - 2, '#557744', ox, oy)
px(bx + bw + 2, by + bh - 1, '#557744', ox, oy)
px(bx + bw + 3, by + bh, '#557744', ox, oy)
px(bx + bw + 4, by + bh, '#446633', ox, oy)
},
}
export const flamingo: Archetype = {
name: 'flamingo', weight: 0.02,
dimensionOverrides: () => ({ legH: 10, legW: 2 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Curved beak
px(hx + hw, hy + Math.floor(hh / 2), '#ff8844', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#222222', ox, oy)
// Wing (folded, pink gradient)
for (let iy = by + 1; iy < by + bh - 1; iy++) {
px(bx + bw, iy, '#ff88aa', ox, oy)
px(bx + bw + 1, iy, '#ff6699', ox, oy)
}
// Feather tuft on tail
px(bx + bw + 1, by + bh - 1, '#ff44aa', ox, oy)
px(bx + bw + 2, by + bh, '#ff44aa', ox, oy)
// Pink body tint
for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) {
px(ix, by + 1, '#ffaacc', ox, oy)
}
},
}
export const hedgehog: Archetype = {
name: 'hedgehog', weight: 0.02,
dimensionOverrides: () => ({ legH: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p
if (ko) return
// Spines on back
for (let ix = bx; ix < bx + bw; ix += 2) {
px(ix, by - 1, '#886644', ox, oy); px(ix, by - 2, '#aa8866', ox, oy)
}
for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) {
px(ix, by - 1, '#775533', ox, oy)
}
// Cute nose
px(hx + hw, hy + Math.floor(hh * 0.6), '#222222', ox, oy)
// Small round ears
px(hx + 1, hy - 1, '#ccaa88', ox, oy); px(hx + hw - 2, hy - 1, '#ccaa88', ox, oy)
// Tiny feet visible
px(bx, by + bh + 1, '#ccaa88', ox, oy); px(bx + bw - 1, by + bh + 1, '#ccaa88', ox, oy)
},
}
export const panda: Archetype = {
name: 'panda', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Black eye patches
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
px(hx + 2 + dx, hy + Math.floor(hh * 0.35) + dy, '#000000', ox, oy)
px(hx + hw - 3 + dx, hy + Math.floor(hh * 0.35) + dy, '#000000', ox, oy)
}
}
// Round ears
px(hx, hy - 1, '#000000', ox, oy); px(hx + 1, hy - 1, '#000000', ox, oy)
px(hx + hw - 1, hy - 1, '#000000', ox, oy); px(hx + hw - 2, hy - 1, '#000000', ox, oy)
// White belly patch
for (let iy = by + 2; iy < by + bh - 1; iy++) {
px(bx + Math.floor(bw / 2), iy, '#ffffff', ox, oy)
px(bx + Math.floor(bw / 2) + 1, iy, '#ffffff', ox, oy)
}
// Black arms/legs coloring
px(bx, by, '#000000', ox, oy); px(bx + bw - 1, by, '#000000', ox, oy)
},
}
export const hamster: Archetype = {
name: 'hamster', weight: 0.02,
dimensionOverrides: () => ({ legH: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw } = p
if (ko) return
// Puffy cheeks
px(hx - 1, hy + Math.floor(hh * 0.5), '#ffcc99', ox, oy)
px(hx - 2, hy + Math.floor(hh * 0.5), '#ffbb88', ox, oy)
px(hx + hw, hy + Math.floor(hh * 0.5), '#ffcc99', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh * 0.5), '#ffbb88', ox, oy)
// Cheek stuffing animation
if (idle && Math.sin(t * Math.PI * 2) > 0.7) {
px(hx - 3, hy + Math.floor(hh * 0.5), '#ffaa77', ox, oy)
px(hx + hw + 2, hy + Math.floor(hh * 0.5), '#ffaa77', ox, oy)
}
// Tiny round ears
px(hx + 1, hy - 1, '#ffbb88', ox, oy)
px(hx + hw - 2, hy - 1, '#ffbb88', ox, oy)
// Buck teeth
px(hx + Math.floor(hw / 2), hy + hh, '#ffffff', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy + hh, '#ffffff', ox, oy)
// Tiny stub tail
px(p.bx + p.bw + 1, p.by + p.bh - 1, '#ffcc99', ox, oy)
},
}
@@ -0,0 +1,346 @@
import type { Archetype } from '../constants'
export const chef: Archetype = {
name: 'chef', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p
if (ko) return
// Chef hat (tall white)
for (let h = 0; h < 6; h++) {
const w = h < 3 ? hw + 2 : hw - 2
for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) {
px(ix, hy - 1 - h, '#ffffff', ox, oy)
}
}
// Apron
for (let iy = by + 2; iy < by + bh + 2; iy++) {
for (let ix = bx + 1; ix < bx + bw - 1; ix++) px(ix, iy, '#ffffff', ox, oy)
}
// Apron string
px(bx + Math.floor(bw / 2), by + 2, '#cccccc', ox, oy)
// Mustache
px(hx + 1, hy + Math.floor(hh * 0.6), '#443322', ox, oy)
px(hx + 2, hy + Math.floor(hh * 0.6), '#443322', ox, oy)
px(hx + hw - 2, hy + Math.floor(hh * 0.6), '#443322', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.6), '#443322', ox, oy)
},
}
export const firefighter: Archetype = {
name: 'firefighter', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Helmet
for (let ix = hx - 1; ix < hx + hw + 1; ix++) px(ix, hy - 1, '#ff2222', ox, oy)
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#ff2222', ox, oy)
// Helmet shield
px(hx + Math.floor(hw / 2), hy - 1, '#ffcc00', ox, oy)
// Yellow stripes on body
for (let ix = bx; ix < bx + bw; ix++) {
px(ix, by + Math.floor(bh / 3), '#ffcc00', ox, oy)
px(ix, by + Math.floor(bh * 2 / 3), '#ffcc00', ox, oy)
}
// Oxygen tank on back
px(bx + bw + 1, by + 1, '#444444', ox, oy)
px(bx + bw + 1, by + 2, '#444444', ox, oy)
px(bx + bw + 1, by + 3, '#444444', ox, oy)
},
}
export const astronautArch: Archetype = {
name: 'astronaut', weight: 0.02, canHaveMohawk: false, canHaveVisor: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Helmet dome
for (let ix = hx - 1; ix < hx + hw + 1; ix++) {
px(ix, hy - 1, '#ffffff', ox, oy)
px(ix, hy + hh, '#ffffff', ox, oy)
}
px(hx - 1, hy, '#ffffff', ox, oy); px(hx + hw, hy, '#ffffff', ox, oy)
px(hx - 1, hy + hh - 1, '#ffffff', ox, oy); px(hx + hw, hy + hh - 1, '#ffffff', ox, oy)
// Visor (gold tint)
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
for (let iy = hy + 1; iy < hy + Math.floor(hh * 0.5); iy++) {
px(ix, iy, '#ffcc44', ox, oy)
}
}
// Backpack
px(bx + bw + 1, by, '#cccccc', ox, oy); px(bx + bw + 1, by + 1, '#cccccc', ox, oy)
px(bx + bw + 1, by + 2, '#cccccc', ox, oy); px(bx + bw + 2, by + 1, '#aaaaaa', ox, oy)
// Flag patch
px(bx + 1, by + 1, '#ff0000', ox, oy); px(bx + 2, by + 1, '#ffffff', ox, oy); px(bx + 3, by + 1, '#0000ff', ox, oy)
},
}
export const clown: Archetype = {
name: 'clown', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Red nose
px(cx + hOff, hy + Math.floor(hh * 0.55), '#ff0000', ox, oy)
px(cx + hOff + 1, hy + Math.floor(hh * 0.55), '#ff0000', ox, oy)
// Rainbow wig
const colors = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff']
for (let i = 0; i < colors.length; i++) {
px(hx - 1 + i, hy - 1, colors[i], ox, oy)
px(hx + hw - colors.length + i, hy - 1, colors[colors.length - 1 - i], ox, oy)
}
// Ruffle collar
for (let ix = bx - 1; ix < bx + bw + 1; ix++) {
px(ix, by - 1, (ix % 2 === 0) ? '#ffffff' : '#ff4444', ox, oy)
}
// Big shoes
px(bx - 2, by + bh + 5, '#ff0000', ox, oy); px(bx - 3, by + bh + 5, '#ff0000', ox, oy)
px(bx + bw + 1, by + bh + 5, '#ff0000', ox, oy); px(bx + bw + 2, by + bh + 5, '#ff0000', ox, oy)
// Flower on chest
const flowerBlink = Math.sin(t * Math.PI * 3) > 0
px(cx + hOff, by + 1, flowerBlink ? '#ff44ff' : '#ffff00', ox, oy)
},
}
export const detective: Archetype = {
name: 'detective', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Fedora
for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#443322', ox, oy)
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#554433', ox, oy)
for (let ix = hx + 1; ix < hx + hw - 1; ix++) px(ix, hy - 3, '#554433', ox, oy)
// Trench coat
for (let iy = by; iy < by + bh + 3; iy++) {
px(bx - 1, iy, '#aa9966', ox, oy); px(bx + bw, iy, '#aa9966', ox, oy)
}
// Belt
for (let ix = bx; ix < bx + bw; ix++) px(ix, by + Math.floor(bh * 0.7), '#554433', ox, oy)
// Magnifying glass
px(bx + bw + 2, by + Math.floor(bh / 2), '#888888', ox, oy)
px(bx + bw + 3, by + Math.floor(bh / 2) - 1, '#aaddff', ox, oy)
px(bx + bw + 3, by + Math.floor(bh / 2), '#aaddff', ox, oy)
},
}
export const nurse: Archetype = {
name: 'nurse', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Nurse cap
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#ffffff', ox, oy)
px(cx + hOff, hy - 1, '#ff0000', ox, oy) // red cross
px(cx + hOff - 1, hy - 2, '#ff0000', ox, oy)
px(cx + hOff + 1, hy - 2, '#ff0000', ox, oy)
px(cx + hOff, hy - 2, '#ff0000', ox, oy)
// White coat
for (let iy = by; iy < by + bh; iy++) {
px(bx, iy, '#ffffff', ox, oy); px(bx + bw - 1, iy, '#ffffff', ox, oy)
}
// Stethoscope
px(cx + hOff - 1, by + 1, '#444444', ox, oy)
px(cx + hOff - 2, by + 2, '#444444', ox, oy)
px(cx + hOff - 2, by + 3, '#888888', ox, oy)
},
}
export const lumberjack: Archetype = {
name: 'lumberjack', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Beanie
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#ff2222', ox, oy)
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#ff2222', ox, oy)
// Plaid pattern on body
for (let iy = by; iy < by + bh; iy += 2) {
for (let ix = bx; ix < bx + bw; ix += 2) px(ix, iy, '#cc2222', ox, oy)
}
for (let iy = by + 1; iy < by + bh; iy += 2) {
for (let ix = bx + 1; ix < bx + bw; ix += 2) px(ix, iy, '#222222', ox, oy)
}
// Big beard
for (let iy = hy + Math.floor(hh * 0.6); iy < hy + hh + 2; iy++) {
px(hx + 1, iy, '#884422', ox, oy); px(hx + 2, iy, '#884422', ox, oy)
px(hx + hw - 2, iy, '#884422', ox, oy); px(hx + hw - 3, iy, '#884422', ox, oy)
}
// Axe on back
px(bx + bw + 1, by - 2, '#886633', ox, oy)
px(bx + bw + 1, by - 1, '#886633', ox, oy)
px(bx + bw + 1, by, '#886633', ox, oy)
px(bx + bw + 2, by - 2, '#888888', ox, oy)
px(bx + bw + 2, by - 3, '#888888', ox, oy)
},
}
export const scientist: Archetype = {
name: 'scientist', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Lab coat
for (let iy = by; iy < by + bh + 2; iy++) {
px(bx - 1, iy, '#ffffff', ox, oy); px(bx + bw, iy, '#ffffff', ox, oy)
}
// Safety goggles
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
px(ix, hy + Math.floor(hh * 0.3), '#88ccff', ox, oy)
}
px(hx, hy + Math.floor(hh * 0.3), '#888888', ox, oy)
px(hx + hw - 1, hy + Math.floor(hh * 0.3), '#888888', ox, oy)
// Beaker in hand (bubbling)
const bubble = Math.sin(t * Math.PI * 4) > 0
px(bx + bw + 2, by + bh - 4, '#88ffcc', ox, oy)
px(bx + bw + 2, by + bh - 3, '#88ffcc', ox, oy)
px(bx + bw + 2, by + bh - 2, '#44cc88', ox, oy)
if (bubble) px(bx + bw + 2, by + bh - 5, '#aaffdd', ox, oy)
// Wild hair
px(hx - 1, hy, '#ffffff', ox, oy); px(hx + hw, hy, '#ffffff', ox, oy)
px(hx, hy - 1, '#ffffff', ox, oy); px(hx + hw - 1, hy - 1, '#ffffff', ox, oy)
},
}
export const wrestler: Archetype = {
name: 'wrestler', weight: 0.02, canHaveMohawk: false,
dimensionOverrides: (tier) => ({ legW: 5 + tier }),
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Luchador mask
for (let ix = hx; ix < hx + hw; ix++) {
for (let iy = hy; iy < hy + hh; iy++) px(ix, iy, '#ff0044', ox, oy)
}
// Eye holes
px(hx + 2, hy + Math.floor(hh * 0.35), '#000000', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#000000', ox, oy)
// Mouth hole
px(hx + Math.floor(hw / 2), hy + Math.floor(hh * 0.7), '#000000', ox, oy)
// Championship belt
for (let ix = bx; ix < bx + bw; ix++) px(ix, by + bh - 2, '#ffcc00', ox, oy)
px(bx + Math.floor(bw / 2), by + bh - 2, '#ffffff', ox, oy)
// Wrist bands
px(bx - 1, by + bh - 1, '#ff0044', ox, oy); px(bx + bw, by + bh - 1, '#ff0044', ox, oy)
},
}
export const boxer: Archetype = {
name: 'boxer', weight: 0.02,
drawFeatures: (p) => {
const { px, pal, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, armLx, armRx, armAttach, armH } = p
if (ko) return
// Boxing gloves (big circles at arm ends)
const gloveY = armAttach + armH - 2
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy <= 5) {
px(armLx + dx + p.hOff, gloveY + dy, '#ff0000', p.ox, p.oy)
px(armRx + dx + p.hOff, gloveY + dy, '#ff0000', p.ox, p.oy)
}
}
}
// Shorts
for (let ix = bx; ix < bx + bw; ix++) {
px(ix, by + bh - 1, '#ffcc00', ox, oy)
px(ix, by + bh, '#ffcc00', ox, oy)
}
// Headband
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy + 1, '#ff0000', ox, oy)
},
}
export const gladiator: Archetype = {
name: 'gladiator', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Helmet with plume
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#ccaa44', ox, oy)
for (let h = 0; h < 4; h++) px(hx + Math.floor(hw / 2), hy - 2 - h, '#ff2222', ox, oy)
// Chest plate
for (let iy = by; iy < by + 3; iy++) {
for (let ix = bx + 1; ix < bx + bw - 1; ix++) px(ix, iy, '#ccaa44', ox, oy)
}
// Shield (on left arm)
for (let iy = by + 1; iy < by + 5; iy++) {
for (let ix = bx - 4; ix < bx - 1; ix++) px(ix, iy, '#886633', ox, oy)
}
px(bx - 3, by + 3, '#ccaa44', ox, oy) // boss on shield
// Sandals
px(bx - 1, by + bh + 5, '#886633', ox, oy); px(bx + bw, by + bh + 5, '#886633', ox, oy)
},
}
export const samuraiArch: Archetype = {
name: 'samurai', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Kabuto helmet
for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#886633', ox, oy)
px(cx + hOff, hy - 2, '#886633', ox, oy)
// Crescent moon ornament
px(cx + hOff - 2, hy - 3, '#ffcc00', ox, oy)
px(cx + hOff, hy - 4, '#ffcc00', ox, oy)
px(cx + hOff + 2, hy - 3, '#ffcc00', ox, oy)
// Armor plates
for (let iy = by; iy < by + bh; iy += 2) {
for (let ix = bx; ix < bx + bw; ix++) px(ix, iy, '#445566', ox, oy)
}
// Katana on back
px(bx + bw + 1, by - 3, '#888888', ox, oy)
for (let h = 0; h < 6; h++) px(bx + bw + 1, by - 2 + h, '#886633', ox, oy)
},
}
export const vikingArch: Archetype = {
name: 'viking', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Horned helmet
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 1, '#888888', ox, oy)
px(hx - 1, hy - 1, '#ccaa44', ox, oy); px(hx - 2, hy - 2, '#ccaa44', ox, oy); px(hx - 3, hy - 3, '#ccaa44', ox, oy)
px(hx + hw, hy - 1, '#ccaa44', ox, oy); px(hx + hw + 1, hy - 2, '#ccaa44', ox, oy); px(hx + hw + 2, hy - 3, '#ccaa44', ox, oy)
// Big beard
for (let iy = hy + Math.floor(hh * 0.5); iy < hy + hh + 3; iy++) {
const bw2 = Math.max(1, 3 - (iy - hy - Math.floor(hh * 0.5)))
for (let ix = hx + Math.floor(hw / 2) - bw2; ix <= hx + Math.floor(hw / 2) + bw2; ix++) {
px(ix, iy, '#cc8833', ox, oy)
}
}
// Fur vest
for (let ix = bx; ix < bx + bw; ix += 2) {
px(ix, by, '#886644', ox, oy); px(ix, by + 1, '#776633', ox, oy)
}
// Shield on back
px(bx + bw + 1, by + 2, '#886633', ox, oy); px(bx + bw + 2, by + 2, '#886633', ox, oy)
px(bx + bw + 1, by + 3, '#886633', ox, oy); px(bx + bw + 2, by + 3, '#886633', ox, oy)
},
}
export const knight: Archetype = {
name: 'knight', weight: 0.02, canHaveMohawk: false, canHaveVisor: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Full helmet
for (let ix = hx; ix < hx + hw; ix++) {
for (let iy = hy; iy < hy + hh; iy++) px(ix, iy, '#aaaaaa', ox, oy)
}
// Visor slit
for (let ix = hx + 1; ix < hx + hw - 1; ix++) px(ix, hy + Math.floor(hh * 0.4), '#333333', ox, oy)
// Plume
for (let h = 0; h < 4; h++) px(cx + hOff, hy - 1 - h, '#ff0000', ox, oy)
// Full body armor
for (let ix = bx; ix < bx + bw; ix++) {
for (let iy = by; iy < by + bh; iy++) px(ix, iy, '#999999', ox, oy)
}
// Cross on chest
px(cx + hOff, by + 2, '#ff0000', ox, oy)
px(cx + hOff - 1, by + 3, '#ff0000', ox, oy)
px(cx + hOff, by + 3, '#ff0000', ox, oy)
px(cx + hOff + 1, by + 3, '#ff0000', ox, oy)
px(cx + hOff, by + 4, '#ff0000', ox, oy)
},
}
@@ -0,0 +1,352 @@
import type { Archetype } from '../constants'
export const minotaur: Archetype = {
name: 'minotaur', weight: 0.02, canHaveHorns: false,
dimensionOverrides: (tier) => ({ legW: 5 + tier, legH: 7 + tier }),
drawFeatures: (p) => {
const { px, pal, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Big curved horns
for (let h = 0; h < 5; h++) {
px(hx - 1 - h, hy - h, '#aa8844', ox, oy)
px(hx + hw + h, hy - h, '#aa8844', ox, oy)
}
px(hx - 6, hy - 4, '#ffcc88', ox, oy); px(hx + hw + 5, hy - 4, '#ffcc88', ox, oy)
// Nose ring
px(hx + Math.floor(hw / 2), hy + hh - 1, '#ffcc00', ox, oy)
px(hx + Math.floor(hw / 2) - 1, hy + hh, '#ffcc00', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy + hh, '#ffcc00', ox, oy)
// Furry chest
for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) px(ix, by + 1, '#886644', ox, oy)
},
}
export const unicorn: Archetype = {
name: 'unicorn', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh, feetY, globalY, vBounce } = p
if (ko) return
// Spiral horn
for (let h = 0; h < 7; h++) {
const color = h % 2 === 0 ? '#ffaaff' : '#ffffff'
px(cx + hOff, hy - 1 - h, color, ox, oy)
}
// Rainbow mane
const rainbow = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff']
for (let i = 0; i < rainbow.length; i++) {
px(hx - 1, hy + 1 + i, rainbow[i], ox, oy)
}
// Sparkle trail
if (idle) {
const sparkX = bx - 3 - Math.floor(t * 5) % 8
const sparkY = by + Math.floor(bh / 2) + Math.round(Math.sin(t * Math.PI * 3 + 1) * 3)
px(sparkX, sparkY, '#ffff88', ox, oy)
px(sparkX - 3, sparkY + 2, '#ffaaff', ox, oy)
}
},
}
export const phoenix: Archetype = {
name: 'phoenix', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Flame wings
const wingFlap = idle ? Math.sin(t * Math.PI * 3) * 3 : 0
const flames = ['#ff4400', '#ff8800', '#ffcc00', '#ffee88']
for (let f = 0; f < 4; f++) {
px(bx - 2 - f, by + 1 + Math.round(wingFlap) + f, flames[f], ox, oy)
px(bx + bw + 1 + f, by + 1 - Math.round(wingFlap) + f, flames[f], ox, oy)
}
// Flame tail
for (let i = 0; i < 5; i++) {
px(bx + bw + 1 + i, by + bh - 1 + Math.round(Math.sin(t * Math.PI * 4 + i) * 2), flames[i % 4], ox, oy)
}
// Crown feathers
px(cx + hOff - 1, hy - 2, '#ff4400', ox, oy)
px(cx + hOff, hy - 3, '#ff8800', ox, oy)
px(cx + hOff + 1, hy - 2, '#ff4400', ox, oy)
// Beak
px(hx + hw, hy + Math.floor(hh / 2), '#ffaa00', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2), '#ff8800', ox, oy)
},
}
export const dragonArch: Archetype = {
name: 'dragon', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, atk, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Horns
px(hx, hy - 1, '#886644', ox, oy); px(hx - 1, hy - 2, '#886644', ox, oy)
px(hx + hw - 1, hy - 1, '#886644', ox, oy); px(hx + hw, hy - 2, '#886644', ox, oy)
// Spiky back ridge
for (let i = 0; i < 5; i++) {
px(bx + bw + 1 + i, by + i * 2, '#44aa22', ox, oy)
px(bx + bw + 1 + i, by + i * 2 + 1, '#338811', ox, oy)
}
// Wings (folded)
for (let w = 0; w < 3; w++) {
px(bx - 1 - w, by + w, '#44aa44', ox, oy)
px(bx - 1 - w, by + w + 1, '#338833', ox, oy)
}
// Fire breath on attack
if (atk) {
const colors = ['#ff4400', '#ff8800', '#ffcc00']
for (let f = 0; f < 6; f++) {
px(hx + hw + f, hy + Math.floor(hh / 2) + Math.round(Math.sin(t * 10 + f) * 2), colors[f % 3], ox, oy)
}
}
},
}
export const mermaid: Archetype = {
name: 'mermaid', weight: 0.02,
dimensionOverrides: () => ({ legH: 4, legW: 6 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, feetY, globalY, vBounce } = p
if (ko) return
// Tail fin at feet (replaces legs visually)
const tailY = feetY + vBounce + globalY
px(bx + Math.floor(bw / 2) - 4, tailY + 2, '#44aacc', ox, oy)
px(bx + Math.floor(bw / 2) + 3, tailY + 2, '#44aacc', ox, oy)
px(bx + Math.floor(bw / 2) - 5, tailY + 3, '#228899', ox, oy)
px(bx + Math.floor(bw / 2) + 4, tailY + 3, '#228899', ox, oy)
// Scale pattern on body
for (let iy = by + 2; iy < by + bh; iy += 2) {
for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) {
px(ix, iy, '#55ccaa', ox, oy)
}
}
// Shell crown
px(hx + Math.floor(hw / 2), hy - 1, '#ffaacc', ox, oy)
px(hx + Math.floor(hw / 2) - 1, hy - 1, '#ff88aa', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy - 1, '#ff88aa', ox, oy)
// Flowing hair
if (idle) {
const wave = Math.sin(t * Math.PI * 2) * 2
for (let h = 0; h < 4; h++) {
px(hx - 1, hy + h + Math.round(wave), '#44ccff', ox, oy)
}
}
},
}
export const griffin: Archetype = {
name: 'griffin', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw } = p
if (ko) return
// Eagle head features: beak
px(hx + hw, hy + Math.floor(hh / 2), '#ffaa00', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2) + 1, '#ff8800', ox, oy)
// Wings
const flap = idle ? Math.sin(t * Math.PI * 2) * 4 : 0
for (let w = 0; w < 5; w++) {
px(bx - 1 - w, by + Math.round(flap) + w, '#886644', ox, oy)
px(bx + bw + w, by - Math.round(flap) + w, '#886644', ox, oy)
}
// Feathered chest
for (let iy = by; iy < by + 3; iy++) {
for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) px(ix, iy, '#ddbb88', ox, oy)
}
// Lion tail
px(bx + bw + 1, by + bh - 2, '#aa8844', ox, oy)
px(bx + bw + 2, by + bh - 3, '#aa8844', ox, oy)
px(bx + bw + 3, by + bh - 3, '#cc9955', ox, oy)
},
}
export const cyclops: Archetype = {
name: 'cyclops', weight: 0.02, canHaveVisor: false,
dimensionOverrides: (tier) => ({ legW: 5 + tier }),
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// One big eye (covers normal eyes)
const eyeX = cx + hOff, eyeY = hy + Math.floor(hh * 0.35)
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy <= 5) px(eyeX + dx, eyeY + dy, '#ffffff', ox, oy)
}
}
px(eyeX, eyeY, '#ff0000', ox, oy)
px(eyeX + 1, eyeY, '#880000', ox, oy)
// Brow ridge
for (let i = -3; i <= 3; i++) px(eyeX + i, eyeY - 3, '#886644', ox, oy)
},
}
export const gargoyle: Archetype = {
name: 'gargoyle', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw } = p
if (ko) return
// Small horns
px(hx, hy - 1, '#666666', ox, oy); px(hx - 1, hy - 1, '#555555', ox, oy)
px(hx + hw - 1, hy - 1, '#666666', ox, oy); px(hx + hw, hy - 1, '#555555', ox, oy)
// Bat wings
const flap = idle ? Math.sin(t * Math.PI * 1.5) * 3 : 0
for (let w = 0; w < 6; w++) {
px(bx - 1 - w, by + 2 + Math.round(flap) + Math.floor(w / 2), '#555555', ox, oy)
px(bx + bw + w, by + 2 - Math.round(flap) + Math.floor(w / 2), '#555555', ox, oy)
}
// Stone texture
for (let ix = bx + 1; ix < bx + bw - 1; ix += 3) {
for (let iy = by + 1; iy < by + bh; iy += 3) px(ix, iy, '#777777', ox, oy)
}
// Glowing eyes
px(hx + 2, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy)
},
}
export const golem: Archetype = {
name: 'golem', weight: 0.02,
dimensionOverrides: (tier) => ({ legW: 6 + tier, legH: 7 }),
drawFeatures: (p) => {
const { px, ox, oy, t, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Cracks/rune patterns
const rune = '#44aaff'
px(cx + hOff, by + 2, rune, ox, oy); px(cx + hOff, by + 4, rune, ox, oy)
px(cx + hOff - 2, by + 3, rune, ox, oy); px(cx + hOff + 2, by + 3, rune, ox, oy)
// Glowing core
const pulse = Math.sin(t * Math.PI * 2) > 0 ? '#44aaff' : '#2266aa'
px(cx + hOff, by + Math.floor(bh / 2), pulse, ox, oy)
px(cx + hOff - 1, by + Math.floor(bh / 2), pulse, ox, oy)
px(cx + hOff + 1, by + Math.floor(bh / 2), pulse, ox, oy)
// Rocky texture
for (let ix = bx; ix < bx + bw; ix += 4) px(ix, by + bh - 1, '#999999', ox, oy)
// Forehead rune
px(cx + hOff, hy + 1, rune, ox, oy)
},
}
export const vampire: Archetype = {
name: 'vampire', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Cape
for (let iy = by; iy < by + bh + 3; iy++) {
px(bx - 1, iy, '#440000', ox, oy); px(bx - 2, iy + 1, '#330000', ox, oy)
px(bx + bw, iy, '#440000', ox, oy); px(bx + bw + 1, iy + 1, '#330000', ox, oy)
}
// Widow's peak hair
px(hx + Math.floor(hw / 2), hy - 1, '#111111', ox, oy)
px(hx + Math.floor(hw / 2) - 1, hy, '#111111', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy, '#111111', ox, oy)
// Fangs
px(hx + 2, hy + hh, '#ffffff', ox, oy)
px(hx + hw - 3, hy + hh, '#ffffff', ox, oy)
// Red eyes
px(hx + 2, hy + Math.floor(hh * 0.35), '#ff0000', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ff0000', ox, oy)
},
}
export const werewolf: Archetype = {
name: 'werewolf', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Fur tufts everywhere
for (let ix = bx; ix < bx + bw; ix += 2) px(ix, by - 1, '#665544', ox, oy)
for (let iy = by; iy < by + bh; iy += 3) {
px(bx - 1, iy, '#665544', ox, oy); px(bx + bw, iy, '#665544', ox, oy)
}
// Pointy ears
px(hx, hy - 1, '#665544', ox, oy); px(hx - 1, hy - 2, '#665544', ox, oy)
px(hx + hw - 1, hy - 1, '#665544', ox, oy); px(hx + hw, hy - 2, '#665544', ox, oy)
// Snout
px(hx + Math.floor(hw / 2), hy + hh - 1, '#553322', ox, oy)
px(hx + Math.floor(hw / 2), hy + hh, '#553322', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy + hh, '#222222', ox, oy)
// Claws
px(bx - 1, by + bh, '#cccccc', ox, oy); px(bx + bw, by + bh, '#cccccc', ox, oy)
},
}
export const zombie: Archetype = {
name: 'zombie', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Green skin patches
px(hx + 2, hy + 1, '#44aa44', ox, oy); px(hx + hw - 3, hy + 2, '#44aa44', ox, oy)
px(bx + 2, by + 2, '#44aa44', ox, oy); px(bx + bw - 3, by + bh - 2, '#44aa44', ox, oy)
// Exposed ribs
for (let r = 0; r < 3; r++) {
px(bx + 1, by + 2 + r * 2, '#ddddcc', ox, oy)
px(bx + 2, by + 2 + r * 2, '#ddddcc', ox, oy)
}
// Droopy eye
px(hx + 2, hy + Math.floor(hh * 0.5), '#ff4444', ox, oy)
// Torn clothes
px(bx + bw - 1, by + bh - 1, '#554433', ox, oy)
px(bx + bw - 2, by + bh, '#554433', ox, oy)
// Brain showing
px(hx + Math.floor(hw / 2), hy, '#ff88aa', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy, '#ff88aa', ox, oy)
},
}
export const witch: Archetype = {
name: 'witch', weight: 0.02, canHaveMohawk: false, canHaveVisor: false,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p
if (ko) return
// Pointed hat
for (let h = 0; h < 7; h++) {
const w = Math.max(1, 4 - h)
for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) {
px(ix, hy - 1 - h, '#220044', ox, oy)
}
}
// Hat brim
for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#330055', ox, oy)
// Wart on nose
px(hx + Math.floor(hw / 2) + 1, hy + Math.floor(hh * 0.6), '#448833', ox, oy)
// Broom (floating next to body)
if (idle) {
px(bx - 3, by + bh - 2, '#886633', ox, oy)
px(bx - 3, by + bh - 1, '#886633', ox, oy)
px(bx - 3, by + bh, '#886633', ox, oy)
px(bx - 4, by + bh + 1, '#aa9944', ox, oy)
px(bx - 3, by + bh + 1, '#aa9944', ox, oy)
px(bx - 2, by + bh + 1, '#aa9944', ox, oy)
}
// Cat familiar (tiny, near feet)
px(bx - 2, by + bh + 3, '#111111', ox, oy)
px(bx - 1, by + bh + 3, '#111111', ox, oy)
px(bx - 2, by + bh + 2, '#111111', ox, oy)
},
}
export const demon: Archetype = {
name: 'demon', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Demon horns (curved forward)
px(hx, hy - 1, '#cc2222', ox, oy); px(hx - 1, hy - 2, '#cc2222', ox, oy); px(hx, hy - 3, '#cc2222', ox, oy)
px(hx + hw - 1, hy - 1, '#cc2222', ox, oy); px(hx + hw, hy - 2, '#cc2222', ox, oy); px(hx + hw - 1, hy - 3, '#cc2222', ox, oy)
// Pointed tail
px(bx + bw + 1, by + bh - 1, '#cc2222', ox, oy)
px(bx + bw + 2, by + bh - 2, '#cc2222', ox, oy)
px(bx + bw + 3, by + bh - 3, '#cc2222', ox, oy)
px(bx + bw + 4, by + bh - 3, '#ff4444', ox, oy) // arrow tip
px(bx + bw + 3, by + bh - 4, '#ff4444', ox, oy)
// Glowing eyes
px(hx + 2, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#ffaa00', ox, oy)
// Dark aura
if (p.idle) {
const flicker = Math.sin(t * Math.PI * 4) > 0
if (flicker) {
px(bx - 1, by - 1, '#440000', ox, oy); px(bx + bw, by - 1, '#440000', ox, oy)
}
}
},
}
@@ -0,0 +1,335 @@
import type { Archetype } from '../constants'
export const robot: Archetype = {
name: 'robot', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p
if (ko) return
// Antenna on head
px(cx + hOff, hy - 3, '#888888', ox, oy)
px(cx + hOff, hy - 4, '#888888', ox, oy)
px(cx + hOff, hy - 5, idle ? '#ff0000' : '#00ff00', ox, oy)
// Panel lines on body
for (let ix = bx + 2; ix < bx + bw - 2; ix += 3) {
px(ix, by + Math.floor(bh / 2), '#666666', ox, oy)
}
// Chest light
const blink = Math.sin(t * Math.PI * 4) > 0
px(cx + hOff, by + 2, blink ? '#00ff44' : '#004411', ox, oy)
px(cx + hOff + 1, by + 2, blink ? '#00ff44' : '#004411', ox, oy)
// Bolts on joints
px(bx, by, '#aaaaaa', ox, oy); px(bx + bw - 1, by, '#aaaaaa', ox, oy)
px(bx, by + bh - 1, '#aaaaaa', ox, oy); px(bx + bw - 1, by + bh - 1, '#aaaaaa', ox, oy)
},
}
export const android: Archetype = {
name: 'android', weight: 0.02,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff, bx, by, bw, bh } = p
if (ko) return
// Glowing circuit lines on body
const glow = Math.sin(t * Math.PI * 2) * 0.5 + 0.5 > 0.5 ? '#44ffff' : '#228888'
for (let iy = by + 1; iy < by + bh; iy += 2) {
px(cx + hOff, iy, glow, ox, oy)
}
// Ear sensors
px(hx - 1, hy + Math.floor(hh / 2), '#44ffff', ox, oy)
px(hx + hw, hy + Math.floor(hh / 2), '#44ffff', ox, oy)
// Visor line across eyes
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
px(ix, hy + Math.floor(hh * 0.35), '#44ffff', ox, oy)
}
},
}
export const droneBug: Archetype = {
name: 'drone', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Propellers on top (spinning)
const spin = Math.floor(t * 20) % 2
if (spin === 0) {
for (let i = -8; i <= 8; i++) px(cx + hOff + i, by - 5, '#666666', ox, oy)
} else {
for (let i = -2; i <= 2; i++) px(cx + hOff + i, by - 5, '#666666', ox, oy)
}
// Camera lens on front
px(bx + bw - 1, by + Math.floor(bh / 2), '#ff0000', ox, oy)
px(bx + bw, by + Math.floor(bh / 2), '#ff0000', ox, oy)
// LED strip on bottom
const led = Math.floor(t * 6) % 3
px(bx + 2 + led * 3, by + bh, '#00ff00', ox, oy)
},
}
export const toaster: Archetype = {
name: 'toaster', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
dimensionOverrides: () => ({ legH: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, cx, hOff, hy } = p
if (ko) return
// Toast popping out of top
const pop = idle ? Math.sin(t * Math.PI * 2) * 3 : 0
px(cx + hOff - 2, hy - 3 + pop, '#dda855', ox, oy)
px(cx + hOff - 1, hy - 3 + pop, '#dda855', ox, oy)
px(cx + hOff, hy - 3 + pop, '#cc9944', ox, oy)
px(cx + hOff + 1, hy - 3 + pop, '#dda855', ox, oy)
px(cx + hOff - 2, hy - 4 + pop, '#cc9944', ox, oy)
px(cx + hOff + 1, hy - 4 + pop, '#cc9944', ox, oy)
// Dial on side
px(bx, by + Math.floor(bh / 2), '#888888', ox, oy)
// Lever
px(bx + bw, by + 2, '#666666', ox, oy)
px(bx + bw, by + 3, '#666666', ox, oy)
// Chrome stripe
for (let ix = bx + 1; ix < bx + bw - 1; ix++) {
px(ix, by + bh - 2, '#cccccc', ox, oy)
}
},
}
export const tvHead: Archetype = {
name: 'tv_head', weight: 0.02, canHaveMohawk: false, canHaveVisor: false,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Static/scan lines on face
for (let iy = hy + 1; iy < hy + hh - 1; iy += 2) {
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
const flicker = Math.random() > 0.5 ? '#224488' : '#113366'
px(ix, iy, flicker, ox, oy)
}
}
// Antenna ears
px(hx, hy - 1, '#888888', ox, oy)
px(hx - 1, hy - 2, '#888888', ox, oy)
px(hx + hw - 1, hy - 1, '#888888', ox, oy)
px(hx + hw, hy - 2, '#888888', ox, oy)
// Power button
px(hx + hw, hy + hh - 2, '#ff0000', ox, oy)
},
}
export const calculator: Archetype = {
name: 'calculator', weight: 0.02, canHaveMohawk: false,
dimensionOverrides: () => ({ legH: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Number display on head
const num = Math.floor(t * 3) % 10
const digits = ['1','3','3','7','4','2','0','6','9','5']
// Just draw a green rect for display
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
px(ix, hy + 1, '#003300', ox, oy)
px(ix, hy + 2, '#003300', ox, oy)
}
px(hx + 2, hy + 1, '#00ff00', ox, oy)
px(hx + hw - 3, hy + 1, '#00ff00', ox, oy)
// Button grid on body
for (let gx = 0; gx < 3; gx++) {
for (let gy = 0; gy < 3; gy++) {
px(bx + 2 + gx * 3, by + 1 + gy * 3, '#888888', ox, oy)
}
}
},
}
export const satellite: Archetype = {
name: 'satellite', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Solar panels (wide rectangles extending from body)
for (let iy = by + 1; iy < by + 4; iy++) {
for (let ix = bx - 8; ix < bx - 1; ix++) px(ix, iy, '#2244aa', ox, oy)
for (let ix = bx + bw + 1; ix < bx + bw + 8; ix++) px(ix, iy, '#2244aa', ox, oy)
}
// Dish on top
for (let i = -3; i <= 3; i++) px(cx + hOff + i, by - 2, '#cccccc', ox, oy)
px(cx + hOff, by - 3, '#cccccc', ox, oy)
// Blinking light
const blink = Math.sin(t * Math.PI * 3) > 0
px(cx + hOff, by - 4, blink ? '#ff0000' : '#440000', ox, oy)
},
}
export const mech: Archetype = {
name: 'mech', weight: 0.02,
dimensionOverrides: (tier) => ({ legW: 6 + tier, legH: 8 + tier }),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Shoulder pads
for (let ix = bx - 3; ix < bx; ix++) { px(ix, by, '#888888', ox, oy); px(ix, by + 1, '#666666', ox, oy) }
for (let ix = bx + bw; ix < bx + bw + 3; ix++) { px(ix, by, '#888888', ox, oy); px(ix, by + 1, '#666666', ox, oy) }
// Cockpit window on chest
for (let iy = by + 2; iy < by + 5; iy++) {
px(cx + hOff - 1, iy, '#44aaff', ox, oy)
px(cx + hOff, iy, '#88ccff', ox, oy)
px(cx + hOff + 1, iy, '#44aaff', ox, oy)
}
// Exhaust pipes on back
px(bx + bw + 1, by + bh - 3, '#555555', ox, oy)
px(bx + bw + 1, by + bh - 2, '#555555', ox, oy)
px(bx + bw + 1, by + bh - 1, '#555555', ox, oy)
},
}
export const ledCube: Archetype = {
name: 'led_cube', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, bx, by, bw, bh, hx, hy, hh, hw } = p
if (ko) return
// Pulsing colored LEDs all over body
const colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff']
for (let ix = bx + 1; ix < bx + bw - 1; ix += 2) {
for (let iy = by + 1; iy < by + bh - 1; iy += 2) {
const ci = (ix + iy + Math.floor(t * 4)) % colors.length
px(ix, iy, colors[ci], ox, oy)
}
}
// Face LEDs
const faceColor = colors[Math.floor(t * 2) % colors.length]
px(hx + 2, hy + 2, faceColor, ox, oy)
px(hx + hw - 3, hy + 2, faceColor, ox, oy)
},
}
export const circuit: Archetype = {
name: 'circuit', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, bx, by, bw, bh, cx, hOff, hy, hh } = p
if (ko) return
// PCB traces on body
const trace = '#44aa22'
for (let ix = bx + 1; ix < bx + bw; ix += 3) {
for (let iy = by; iy < by + bh; iy++) px(ix, iy, trace, ox, oy)
}
for (let iy = by + 2; iy < by + bh; iy += 3) {
for (let ix = bx; ix < bx + bw; ix++) px(ix, iy, trace, ox, oy)
}
// Chip on body center
px(cx + hOff - 1, by + Math.floor(bh / 2), '#222222', ox, oy)
px(cx + hOff, by + Math.floor(bh / 2), '#222222', ox, oy)
px(cx + hOff + 1, by + Math.floor(bh / 2), '#222222', ox, oy)
// Solder points
px(bx + 2, by + 1, '#cccccc', ox, oy)
px(bx + bw - 3, by + bh - 2, '#cccccc', ox, oy)
},
}
export const antennaBug: Archetype = {
name: 'antenna_bot', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hw, cx, hOff } = p
if (ko) return
// Multiple antennae
const wobble = idle ? Math.sin(t * Math.PI * 3) * 2 : 0
for (let a = 0; a < 3; a++) {
const ax = hx + 1 + a * Math.floor((hw - 2) / 2)
for (let h = 0; h < 4 + a; h++) {
px(ax, hy - 1 - h + Math.round(wobble * (a === 1 ? -1 : 1)), '#888888', ox, oy)
}
const tipColor = ['#ff0000', '#00ff00', '#0000ff'][a]
px(ax, hy - 5 - a + Math.round(wobble * (a === 1 ? -1 : 1)), tipColor, ox, oy)
}
},
}
export const microwave: Archetype = {
name: 'microwave', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
dimensionOverrides: () => ({ legH: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Door window on face
for (let ix = hx + 1; ix < hx + hw - 2; ix++) {
for (let iy = hy + 1; iy < hy + hh - 1; iy++) {
px(ix, iy, '#223344', ox, oy)
}
}
// Spinning plate inside (when idle)
const spin = Math.floor(t * 4) % 4
const plateX = hx + Math.floor(hw / 2) + (spin < 2 ? -1 : 1)
px(plateX, hy + Math.floor(hh / 2), '#ffee88', ox, oy)
// Buttons on right side
px(hx + hw - 1, hy + 1, '#ff0000', ox, oy)
px(hx + hw - 1, hy + 3, '#00ff00', ox, oy)
// Handle
px(bx + bw, by + Math.floor(bh / 2), '#aaaaaa', ox, oy)
px(bx + bw, by + Math.floor(bh / 2) + 1, '#aaaaaa', ox, oy)
},
}
export const cyberdog: Archetype = {
name: 'cyberdog', weight: 0.02,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff, feetY, globalY, vBounce } = p
if (ko) return
// Floppy robo-ears with LEDs
px(hx - 1, hy + 1, '#888888', ox, oy); px(hx - 1, hy + 2, '#888888', ox, oy)
px(hx - 1, hy + 3, '#00ff00', ox, oy)
px(hx + hw, hy + 1, '#888888', ox, oy); px(hx + hw, hy + 2, '#888888', ox, oy)
px(hx + hw, hy + 3, '#00ff00', ox, oy)
// Robo-tail (wagging)
const wag = idle ? Math.sin(t * Math.PI * 4) * 4 : 0
px(bx + bw + 1, by + 2, '#888888', ox, oy)
px(bx + bw + 2, by + 1 + Math.round(wag), '#888888', ox, oy)
px(bx + bw + 3, by + Math.round(wag), '#ff4444', ox, oy)
// Snout
px(hx + Math.floor(hw / 2), hy + hh - 1, '#444444', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy + hh - 1, '#444444', ox, oy)
px(hx + Math.floor(hw / 2), hy + hh, '#222222', ox, oy)
},
}
export const robocat: Archetype = {
name: 'robocat', weight: 0.02,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Pointy metal ears
px(hx + 1, hy - 1, '#aaaaaa', ox, oy); px(hx, hy - 2, '#aaaaaa', ox, oy)
px(hx + hw - 2, hy - 1, '#aaaaaa', ox, oy); px(hx + hw - 1, hy - 2, '#aaaaaa', ox, oy)
// Whisker sensors
for (let w = 1; w <= 3; w++) {
px(hx - w, hy + Math.floor(hh * 0.6) + (w === 2 ? -1 : w === 3 ? 1 : 0), '#cccccc', ox, oy)
px(hx + hw + w - 1, hy + Math.floor(hh * 0.6) + (w === 2 ? -1 : w === 3 ? 1 : 0), '#cccccc', ox, oy)
}
// Curled tail with LED tip
const curl = Math.sin(t * Math.PI * 2) * 2
px(bx + bw + 1, by + bh - 2, '#888888', ox, oy)
px(bx + bw + 2, by + bh - 3, '#888888', ox, oy)
px(bx + bw + 2, by + bh - 4 + Math.round(curl), '#ff00ff', ox, oy)
},
}
export const ufoBot: Archetype = {
name: 'ufo_bot', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, bx, by, bw, bh, cx, hOff, hy } = p
if (ko) return
// Dome on top
for (let i = -2; i <= 2; i++) px(cx + hOff + i, hy - 2, '#88ffcc', ox, oy)
for (let i = -1; i <= 1; i++) px(cx + hOff + i, hy - 3, '#aaffdd', ox, oy)
px(cx + hOff, hy - 4, '#ccffee', ox, oy)
// Rotating lights around body
const phase = Math.floor(t * 6) % 6
const colors = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']
for (let i = 0; i < 6; i++) {
const angle = (i + phase) * Math.PI * 2 / 6
const lx = cx + hOff + Math.round(Math.cos(angle) * (bw / 2 + 2))
const ly = by + Math.floor(bh / 2) + Math.round(Math.sin(angle) * 2)
px(lx, ly, colors[i], ox, oy)
}
// Tractor beam below (when idle)
if (p.idle) {
for (let iy = by + bh + 1; iy < by + bh + 4; iy++) {
px(cx + hOff, iy, '#44ff88', ox, oy)
}
}
},
}
@@ -0,0 +1,373 @@
import type { Archetype } from '../constants'
export const sockPuppet: Archetype = {
name: 'sock_puppet', weight: 0.02, canHaveMohawk: false,
dimensionOverrides: () => ({ legH: 2 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Googly eyes (one bigger)
px(hx + 2, hy + 2, '#ffffff', ox, oy); px(hx + 3, hy + 2, '#ffffff', ox, oy)
px(hx + 2, hy + 3, '#ffffff', ox, oy); px(hx + 3, hy + 3, '#000000', ox, oy)
px(hx + hw - 3, hy + 1, '#ffffff', ox, oy); px(hx + hw - 2, hy + 1, '#ffffff', ox, oy)
px(hx + hw - 3, hy + 2, '#ffffff', ox, oy); px(hx + hw - 2, hy + 2, '#000000', ox, oy)
px(hx + hw - 3, hy + 3, '#ffffff', ox, oy); px(hx + hw - 2, hy + 3, '#ffffff', ox, oy)
// Mouth (flapping)
const flap = idle ? Math.sin(t * Math.PI * 3) > 0 : false
if (flap) {
for (let ix = hx + 1; ix < hx + hw - 1; ix++) px(ix, hy + hh, '#ff4466', ox, oy)
}
// Yarn hair
px(cx + hOff - 1, hy - 1, '#ff8844', ox, oy)
px(cx + hOff, hy - 2, '#ff8844', ox, oy)
px(cx + hOff + 1, hy - 1, '#ff8844', ox, oy)
},
}
export const trafficCone: Archetype = {
name: 'traffic_cone', weight: 0.02, canHaveMohawk: false, canHaveHorns: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Cone shape above head (orange + white stripes)
for (let h = 0; h < 8; h++) {
const w = Math.max(1, 8 - h)
const color = (h % 3 === 0) ? '#ffffff' : '#ff6600'
for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) {
px(ix, hy - 1 - h, color, ox, oy)
}
}
// Tip
px(cx + hOff, hy - 9, '#ff4400', ox, oy)
// Base (wider at head level)
for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#ff6600', ox, oy)
},
}
export const toiletMan: Archetype = {
name: 'toilet_man', weight: 0.02, canHaveMohawk: false,
dimensionOverrides: () => ({ legH: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Toilet seat around head
for (let ix = hx - 1; ix < hx + hw + 1; ix++) {
px(ix, hy - 1, '#ffffff', ox, oy); px(ix, hy + hh, '#ffffff', ox, oy)
}
px(hx - 1, hy, '#ffffff', ox, oy); px(hx + hw, hy, '#ffffff', ox, oy)
px(hx - 1, hy + hh - 1, '#ffffff', ox, oy); px(hx + hw, hy + hh - 1, '#ffffff', ox, oy)
// Tank on back
px(bx + bw + 1, by, '#ffffff', ox, oy); px(bx + bw + 1, by + 1, '#ffffff', ox, oy)
px(bx + bw + 1, by + 2, '#ffffff', ox, oy)
// Handle
px(bx + bw + 2, by, '#cccccc', ox, oy)
// Water splash eyes
px(hx + 2, hy + Math.floor(hh * 0.35), '#4488ff', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#4488ff', ox, oy)
},
}
export const potato: Archetype = {
name: 'potato', weight: 0.02,
dimensionOverrides: () => ({ legH: 3, legW: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p
if (ko) return
// Eyes (spots) on body
px(bx + 2, by + 2, '#443322', ox, oy)
px(bx + bw - 3, by + 3, '#443322', ox, oy)
px(bx + 1, by + bh - 3, '#443322', ox, oy)
// Sprout on top
px(hx + Math.floor(hw / 2), hy - 1, '#44aa22', ox, oy)
px(hx + Math.floor(hw / 2), hy - 2, '#44aa22', ox, oy)
px(hx + Math.floor(hw / 2) - 1, hy - 3, '#66cc44', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy - 3, '#66cc44', ox, oy)
// Dirt spots
px(bx + Math.floor(bw / 2), by + bh - 1, '#553311', ox, oy)
},
}
export const cloudMan: Archetype = {
name: 'cloud_man', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Fluffy cloud outline
for (let ix = bx - 2; ix < bx + bw + 2; ix++) px(ix, by - 1, '#ffffff', ox, oy)
for (let ix = bx - 1; ix < bx + bw + 1; ix++) px(ix, by - 2, '#ffffff', ox, oy)
// Rain drops below (when idle)
if (idle) {
const phase = Math.floor(t * 6) % 4
for (let d = 0; d < 3; d++) {
const dx = bx + 1 + d * Math.floor(bw / 3)
const dy = by + bh + 1 + (phase + d) % 4
px(dx, dy, '#4488ff', ox, oy)
}
}
// Poofy top
px(cx + hOff - 2, hy - 1, '#ffffff', ox, oy)
px(cx + hOff, hy - 2, '#ffffff', ox, oy)
px(cx + hOff + 2, hy - 1, '#ffffff', ox, oy)
},
}
export const rockMan: Archetype = {
name: 'rock_man', weight: 0.02,
dimensionOverrides: () => ({ legH: 4 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw } = p
if (ko) return
// Cracks on body
px(bx + 2, by + 1, '#555555', ox, oy); px(bx + 3, by + 2, '#555555', ox, oy)
px(bx + 4, by + 2, '#555555', ox, oy); px(bx + 5, by + 3, '#555555', ox, oy)
px(bx + bw - 3, by + bh - 3, '#555555', ox, oy); px(bx + bw - 4, by + bh - 2, '#555555', ox, oy)
// Mossy patches
px(bx + 1, by, '#448833', ox, oy); px(bx + 2, by, '#558844', ox, oy)
px(bx + bw - 2, by + bh - 1, '#448833', ox, oy)
// Crystal embedded
px(bx + Math.floor(bw / 2), by + Math.floor(bh / 2), '#88aaff', ox, oy)
px(bx + Math.floor(bw / 2) + 1, by + Math.floor(bh / 2), '#aaccff', ox, oy)
// Stone face texture
px(hx + 1, hy + hh - 1, '#777777', ox, oy); px(hx + hw - 2, hy + hh - 1, '#777777', ox, oy)
},
}
export const balloonMan: Archetype = {
name: 'balloon_man', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Balloon on string above head
const bob = idle ? Math.sin(t * Math.PI * 2) * 3 : 0
// String
for (let h = 0; h < 4; h++) px(cx + hOff, hy - 2 - h, '#888888', ox, oy)
// Balloon
const bY = hy - 6 + Math.round(bob)
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy <= 5) px(cx + hOff + dx, bY + dy, '#ff4488', ox, oy)
}
}
// Knot
px(cx + hOff, bY + 3, '#cc2266', ox, oy)
// Highlight
px(cx + hOff - 1, bY - 1, '#ffaacc', ox, oy)
},
}
export const trashCan: Archetype = {
name: 'trash_can', weight: 0.02, canHaveMohawk: false,
dimensionOverrides: () => ({ legH: 2 }),
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Lid (on top of head, slightly ajar when idle)
const lidAngle = idle ? Math.sin(t * Math.PI * 2) * 2 : 0
for (let ix = hx - 1; ix < hx + hw + 1; ix++) {
px(ix, hy - 1 + Math.round(lidAngle > 1 ? -1 : 0), '#888888', ox, oy)
}
// Handle on lid
px(hx + Math.floor(hw / 2), hy - 2, '#aaaaaa', ox, oy)
// Garbage sticking out when lid is open
if (lidAngle > 1) {
px(hx + 2, hy - 2, '#ff4444', ox, oy) // apple core
px(hx + hw - 3, hy - 2, '#ffcc44', ox, oy) // banana peel
}
// Dented texture
px(bx + 2, by + 3, '#666666', ox, oy); px(bx + bw - 3, by + bh - 3, '#666666', ox, oy)
// Flies (tiny dots)
if (idle) {
const flyX = bx + Math.floor(bw / 2) + Math.round(Math.sin(t * Math.PI * 5) * 4)
const flyY = hy - 3 + Math.round(Math.cos(t * Math.PI * 7) * 2)
px(flyX, flyY, '#222222', ox, oy)
}
},
}
export const rubberDuckArch: Archetype = {
name: 'rubber_duck', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Beak
px(hx + hw, hy + Math.floor(hh / 2), '#ff8800', ox, oy)
px(hx + hw + 1, hy + Math.floor(hh / 2), '#ff6600', ox, oy)
// Highlight on body (shiny rubber)
px(bx + 2, by + 1, '#ffee88', ox, oy)
px(bx + 3, by + 1, '#ffee88', ox, oy)
px(bx + 2, by + 2, '#ffee88', ox, oy)
// Bobbing motion water ripples
if (idle) {
const ripple = Math.sin(t * Math.PI * 2)
px(bx - 2, by + bh + 1, ripple > 0 ? '#88ccff' : '#4488cc', ox, oy)
px(bx + bw + 1, by + bh + 1, ripple < 0 ? '#88ccff' : '#4488cc', ox, oy)
}
// Crown (bath time king!)
px(hx + Math.floor(hw / 2) - 1, hy - 1, '#ffcc00', ox, oy)
px(hx + Math.floor(hw / 2), hy - 2, '#ffcc00', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy - 1, '#ffcc00', ox, oy)
},
}
export const snowman: Archetype = {
name: 'snowman', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Top hat
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
px(ix, hy - 1, '#222222', ox, oy); px(ix, hy - 2, '#222222', ox, oy)
px(ix, hy - 3, '#222222', ox, oy)
}
for (let ix = hx - 1; ix < hx + hw + 1; ix++) px(ix, hy - 1, '#333333', ox, oy)
// Carrot nose
px(hx + Math.floor(hw / 2), hy + Math.floor(hh * 0.5), '#ff8800', ox, oy)
px(hx + Math.floor(hw / 2) + 1, hy + Math.floor(hh * 0.5), '#ff6600', ox, oy)
// Coal buttons
px(cx + hOff, by + 2, '#222222', ox, oy)
px(cx + hOff, by + Math.floor(bh / 2), '#222222', ox, oy)
px(cx + hOff, by + bh - 2, '#222222', ox, oy)
// Scarf
for (let ix = bx - 1; ix < bx + bw + 1; ix++) px(ix, by - 1, '#ff0000', ox, oy)
px(bx - 1, by, '#ff0000', ox, oy); px(bx - 1, by + 1, '#ff0000', ox, oy)
// Stick arms
px(bx - 2, by + Math.floor(bh / 2), '#886633', ox, oy)
px(bx - 3, by + Math.floor(bh / 2) - 1, '#886633', ox, oy)
px(bx + bw + 1, by + Math.floor(bh / 2), '#886633', ox, oy)
px(bx + bw + 2, by + Math.floor(bh / 2) - 1, '#886633', ox, oy)
},
}
export const scarecrow: Archetype = {
name: 'scarecrow', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, t, idle, ko, hx, hy, hh, hw, bx, by, bw, bh } = p
if (ko) return
// Straw hat
for (let ix = hx - 3; ix < hx + hw + 3; ix++) px(ix, hy - 1, '#ddbb66', ox, oy)
for (let ix = hx; ix < hx + hw; ix++) px(ix, hy - 2, '#ccaa55', ox, oy)
// Button eyes
px(hx + 2, hy + Math.floor(hh * 0.35), '#444444', ox, oy)
px(hx + hw - 3, hy + Math.floor(hh * 0.35), '#444444', ox, oy)
// Stitched mouth
for (let ix = hx + 1; ix < hx + hw - 1; ix += 2) {
px(ix, hy + Math.floor(hh * 0.7), '#444444', ox, oy)
}
// Straw poking out
px(bx - 1, by + bh - 1, '#ddbb66', ox, oy)
px(bx + bw, by + bh - 1, '#ddbb66', ox, oy)
px(hx - 1, hy + Math.floor(hh / 2), '#ddbb66', ox, oy)
// Patched clothes
px(bx + 2, by + 2, '#886644', ox, oy); px(bx + 3, by + 2, '#886644', ox, oy)
px(bx + 2, by + 3, '#886644', ox, oy); px(bx + 3, by + 3, '#886644', ox, oy)
// Crow on shoulder
px(bx + bw + 1, by - 1, '#222222', ox, oy); px(bx + bw + 2, by - 1, '#222222', ox, oy)
px(bx + bw + 1, by - 2, '#222222', ox, oy)
},
}
export const jackOLantern: Archetype = {
name: 'jack_o_lantern', weight: 0.02, canHaveMohawk: false, canHaveVisor: false,
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, cx, hOff } = p
if (ko) return
// Stem
px(cx + hOff, hy - 1, '#44aa22', ox, oy); px(cx + hOff, hy - 2, '#44aa22', ox, oy)
// Carved face (replaces normal face)
const glow = Math.sin(t * Math.PI * 3) > 0 ? '#ffcc00' : '#ff8800'
// Triangle eyes
px(hx + 2, hy + 2, glow, ox, oy)
px(hx + 1, hy + 3, glow, ox, oy); px(hx + 2, hy + 3, glow, ox, oy); px(hx + 3, hy + 3, glow, ox, oy)
px(hx + hw - 3, hy + 2, glow, ox, oy)
px(hx + hw - 4, hy + 3, glow, ox, oy); px(hx + hw - 3, hy + 3, glow, ox, oy); px(hx + hw - 2, hy + 3, glow, ox, oy)
// Jagged mouth
for (let ix = hx + 1; ix < hx + hw - 1; ix++) {
px(ix, hy + Math.floor(hh * 0.7), glow, ox, oy)
if (ix % 2 === 0) px(ix, hy + Math.floor(hh * 0.7) - 1, glow, ox, oy)
}
// Ridges
for (let iy = hy; iy < hy + hh; iy += 2) {
px(hx, iy, '#cc6600', ox, oy); px(hx + hw - 1, iy, '#cc6600', ox, oy)
}
},
}
export const gardenGnome: Archetype = {
name: 'garden_gnome', weight: 0.02, canHaveMohawk: false,
dimensionOverrides: () => ({ legH: 4 }),
drawFeatures: (p) => {
const { px, ox, oy, ko, hx, hy, hh, hw, bx, by, bw, bh, cx, hOff } = p
if (ko) return
// Pointy red hat
for (let h = 0; h < 6; h++) {
const w = Math.max(1, 4 - h)
for (let ix = cx + hOff - Math.floor(w / 2); ix < cx + hOff + Math.ceil(w / 2); ix++) {
px(ix, hy - 1 - h, '#ff0000', ox, oy)
}
}
// Big white beard
for (let iy = hy + Math.floor(hh * 0.5); iy < hy + hh + 4; iy++) {
const bw2 = Math.max(1, 4 - (iy - hy - Math.floor(hh * 0.5)))
for (let ix = cx + hOff - bw2; ix <= cx + hOff + bw2; ix++) {
px(ix, iy, '#ffffff', ox, oy)
}
}
// Rosy cheeks
px(hx + 1, hy + Math.floor(hh * 0.5), '#ff8888', ox, oy)
px(hx + hw - 2, hy + Math.floor(hh * 0.5), '#ff8888', ox, oy)
// Belt with buckle
for (let ix = bx; ix < bx + bw; ix++) px(ix, by + Math.floor(bh * 0.7), '#886633', ox, oy)
px(cx + hOff, by + Math.floor(bh * 0.7), '#ffcc00', ox, oy)
// Fishing rod or lantern
px(bx + bw + 1, by + 2, '#886633', ox, oy)
px(bx + bw + 1, by + 3, '#886633', ox, oy)
px(bx + bw + 2, by + 3, '#ffcc00', ox, oy)
},
}
export const lampPost: Archetype = {
name: 'lamp_post', weight: 0.02, canHaveMohawk: false,
dimensionOverrides: () => ({ legH: 2, legW: 3 }),
drawFeatures: (p) => {
const { px, ox, oy, t, ko, hx, hy, hh, hw, cx, hOff, bx, by } = p
if (ko) return
// Lamp shade on top
for (let ix = hx - 2; ix < hx + hw + 2; ix++) px(ix, hy - 1, '#445566', ox, oy)
// Light glow
const glow = Math.sin(t * Math.PI * 2) > 0 ? '#ffee88' : '#ffcc44'
for (let ix = hx - 1; ix < hx + hw + 1; ix++) px(ix, hy - 2, glow, ox, oy)
px(hx + Math.floor(hw / 2), hy - 3, glow, ox, oy)
// Moths circling (tiny dots)
const moth1X = cx + hOff + Math.round(Math.sin(t * Math.PI * 4) * 5)
const moth1Y = hy - 3 + Math.round(Math.cos(t * Math.PI * 4) * 3)
px(moth1X, moth1Y, '#ccccaa', ox, oy)
const moth2X = cx + hOff + Math.round(Math.cos(t * Math.PI * 3) * 4)
const moth2Y = hy - 2 + Math.round(Math.sin(t * Math.PI * 3) * 2)
px(moth2X, moth2Y, '#ccccaa', ox, oy)
// Base plate
for (let ix = bx - 1; ix < bx + p.bw + 1; ix++) px(ix, by + p.bh + 1, '#445566', ox, oy)
},
}
export const broomMan: Archetype = {
name: 'broom_man', weight: 0.02, canHaveMohawk: false,
drawFeatures: (p) => {
const { px, ox, oy, ko, bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY } = p
if (ko) return
// Bristles at feet
const footY = feetY + globalY + p.vBounce
for (let ix = bx - 2; ix < bx + bw + 2; ix++) {
px(ix, footY + 1, '#ccaa55', ox, oy)
px(ix, footY + 2, '#bbaa44', ox, oy)
}
// Handle extends up through head
for (let iy = hy - 4; iy < hy; iy++) {
px(cx + hOff, iy, '#886633', ox, oy)
}
// Dust cloud when moving
if (!p.idle && !p.knockback) {
px(bx - 3, footY + 2, '#ccccaa', ox, oy)
px(bx - 4, footY + 1, '#ccccaa', ox, oy)
}
},
}
@@ -0,0 +1,60 @@
import type { Archetype } from '../constants'
export const bee: Archetype = {
name: 'bee',
weight: 0.03,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko, knockback, win,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, bounce } = p
// Yellow/black stripes on body
for (let iy = by; iy < by + bh; iy++) {
if ((iy - by) % 3 === 0) {
for (let ix = bx + 1; ix < bx + bw - 1; ix++) {
px(ix, iy, '#ffcc00', ox, oy)
}
}
}
// Wings (transparent, buzzing)
if (!ko && !knockback) {
const wingPhase = idle || win ? Math.sin(t * Math.PI * 8) * 3 : 0
// Left wing
const lwx = bx - 3
const lwy = by - 2 + Math.round(wingPhase)
px(lwx, lwy, '#aaddff', ox, oy)
px(lwx - 1, lwy, '#88bbee', ox, oy)
px(lwx - 2, lwy - 1, '#88bbee', ox, oy)
px(lwx, lwy - 1, '#aaddff', ox, oy)
px(lwx - 1, lwy - 1, '#aaddff', ox, oy)
// Right wing
const rwx = bx + bw + 2
const rwy = by - 2 - Math.round(wingPhase)
px(rwx, rwy, '#aaddff', ox, oy)
px(rwx + 1, rwy, '#88bbee', ox, oy)
px(rwx + 2, rwy - 1, '#88bbee', ox, oy)
px(rwx, rwy - 1, '#aaddff', ox, oy)
px(rwx + 1, rwy - 1, '#aaddff', ox, oy)
}
// Stinger at bottom
if (!ko) {
const stingX = cx + hOff
const stingY = feetY + globalY + 2
px(stingX, stingY, '#222222', ox, oy)
px(stingX, stingY + 1, '#111111', ox, oy)
px(stingX, stingY + 2, '#ffcc00', ox, oy)
}
// Antennae (short, bobbing)
if (!ko) {
const antWobble = idle ? Math.round(Math.sin(t * Math.PI * 3) * 1) : 0
px(hx + 2, hy - 1, '#222222', ox, oy)
px(hx + 1, hy - 2 + antWobble, '#222222', ox, oy)
px(hx + 0, hy - 3 + antWobble, '#ffcc00', ox, oy)
px(hx + hw - 3, hy - 1, '#222222', ox, oy)
px(hx + hw - 2, hy - 2 - antWobble, '#222222', ox, oy)
px(hx + hw - 1, hy - 3 - antWobble, '#ffcc00', ox, oy)
}
},
}
@@ -0,0 +1,53 @@
import type { Archetype } from '../constants'
export const blob: Archetype = {
name: 'blob',
weight: 0.14,
canHaveMohawk: false,
dimensionOverrides: (tier) => ({
bw: 12 + tier * 2,
bh: 10 + tier,
hw: 12 + tier,
hh: 11 + tier,
legH: 4,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, hit, ko,
cx, hOff, by, bw, bh, hx, hw, hy, hh, globalY } = p
// Amorphous body -- wobbly outline
const wobAmp = idle ? 1 : hit ? 2 : 0
for (let s = 0; s < 8; s++) {
const sa = s * Math.PI * 2 / 8 + t * 0.3
const sr = Math.floor(bw / 2) + 2 + Math.round(Math.sin(sa * 3 + t * Math.PI * 4) * wobAmp)
const sx = cx + hOff + Math.round(Math.cos(sa) * sr)
const sy = by + Math.floor(bh / 2) + Math.round(Math.sin(sa) * (bh / 2)) + globalY
px(sx, sy, pal.light, ox, oy)
px(sx, sy + 1, pal.light, ox, oy)
}
// Googly eyes -- oversized, bouncy
if (!ko) {
const geS = 3 + Math.floor(tier * 0.3)
const geLx = hx + 1
const geRx = hx + hw - geS - 1
const geY = hy + Math.floor(hh * 0.2)
// Big white circles
for (let ey = geY; ey < geY + geS; ey++) {
for (let ex = geLx; ex < geLx + geS; ex++) px(ex, ey, '#ffffff', ox, oy)
for (let ex = geRx; ex < geRx + geS; ex++) px(ex, ey, '#ffffff', ox, oy)
}
// Bouncing pupils
const pupOff = idle ? Math.round(Math.sin(t * Math.PI * 2) * 1) : 0
px(geLx + Math.floor(geS / 2) + pupOff, geY + geS - 2, '#000000', ox, oy)
px(geLx + Math.floor(geS / 2) + pupOff + 1, geY + geS - 2, '#000000', ox, oy)
px(geRx + Math.floor(geS / 2) - pupOff, geY + geS - 2, '#000000', ox, oy)
px(geRx + Math.floor(geS / 2) - pupOff + 1, geY + geS - 2, '#000000', ox, oy)
}
// Drool / slime drip
if (idle || hit) {
const drY = hy + Math.floor(hh * 0.8) + Math.round(t * 2)
px(cx + hOff, drY, pal.accLight, ox, oy)
px(cx + hOff, drY + 1, pal.accLight, ox, oy)
}
},
}
@@ -0,0 +1,47 @@
import type { Archetype } from '../constants'
export const cactus: Archetype = {
name: 'cactus',
weight: 0.03,
canHaveMohawk: false,
dimensionOverrides: () => ({
bw: 14,
armW: 2,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, tier, ko,
bx, by, bw, bh, hx, hy, hh } = p
// Green body override tint
const green = '#2d8a4e'
const darkGreen = '#1a5e33'
// Spikes all over body
if (!ko) {
for (let s = 0; s < 8 + tier; s++) {
const sx = bx + Math.floor(Math.random() * bw)
const sy = by + Math.floor(Math.random() * bh)
const sDir = sx < bx + bw / 2 ? -1 : 1
px(sx + sDir * 1, sy, '#cccc44', ox, oy)
px(sx + sDir * 2, sy, '#aaaa33', ox, oy)
}
}
// Spikes on head
for (let s = 0; s < 5; s++) {
const sx = hx + 1 + Math.floor(s * (p.hw - 2) / 4)
px(sx, hy - 1, '#cccc44', ox, oy)
px(sx, hy - 2, '#aaaa33', ox, oy)
}
// Small flower on top
const flowerY = hy - 3
const flowerX = hx + Math.floor(p.hw / 2) + 2
px(flowerX, flowerY, '#ff66aa', ox, oy)
px(flowerX - 1, flowerY, '#ff88cc', ox, oy)
px(flowerX + 1, flowerY, '#ff88cc', ox, oy)
px(flowerX, flowerY - 1, '#ff88cc', ox, oy)
px(flowerX, flowerY + 1, '#ff88cc', ox, oy)
px(flowerX, flowerY, '#ffff00', ox, oy) // center
},
}
@@ -0,0 +1,54 @@
import type { Archetype } from '../constants'
export const cat: Archetype = {
name: 'cat',
weight: 0.04,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, idle, ko, knockback, atk, special,
cx, hOff, hx, hw, hy, hh, bw, feetY, globalY, bounce } = p
if (ko) return
// Pointed ears (triangles above head)
px(hx, hy - 1, pal.body, ox, oy)
px(hx - 1, hy - 2, pal.body, ox, oy)
px(hx - 2, hy - 3, pal.dark, ox, oy)
px(hx, hy - 2, '#ffaaaa', ox, oy) // inner ear pink
px(hx + hw - 1, hy - 1, pal.body, ox, oy)
px(hx + hw, hy - 2, pal.body, ox, oy)
px(hx + hw + 1, hy - 3, pal.dark, ox, oy)
px(hx + hw - 1, hy - 2, '#ffaaaa', ox, oy)
// Whiskers (3 per side)
const wY = hy + Math.floor(hh * 0.5)
for (let w = 0; w < 3; w++) {
const wd = w - 1
px(hx - 2 - w, wY + wd, pal.out, ox, oy)
px(hx - 3 - w, wY + wd, pal.out, ox, oy)
px(hx + hw + 1 + w, wY + wd, pal.out, ox, oy)
px(hx + hw + 2 + w, wY + wd, pal.out, ox, oy)
}
// Slit eyes override (narrow pupils)
const eyeY = hy + Math.floor(hh * 0.35)
const leX = hx + Math.floor(hw * 0.25)
const reX = hx + Math.floor(hw * 0.65)
if (atk || special) {
px(leX, eyeY, '#ffcc00', ox, oy)
px(reX, eyeY, '#ffcc00', ox, oy)
}
// Curled tail
if (!knockback) {
const tailDir = -1
const curlPhase = idle ? t * Math.PI * 2 : 0
for (let tt = 1; tt <= 5; tt++) {
const curlX = Math.round(Math.sin(curlPhase + tt * 0.5) * 2)
px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt) + curlX, feetY - tt + globalY, pal.body, ox, oy)
}
// Curl at tip
px(cx + hOff + tailDir * (Math.floor(bw / 2) + 5) + 2, feetY - 6 + globalY, pal.dark, ox, oy)
px(cx + hOff + tailDir * (Math.floor(bw / 2) + 5) + 3, feetY - 5 + globalY, pal.dark, ox, oy)
}
},
}
@@ -0,0 +1,67 @@
import type { Archetype } from '../constants'
export const cowboy: Archetype = {
name: 'cowboy',
weight: 0.03,
canHaveVisor: false,
canHaveMohawk: false,
drawFeatures: (p) => {
const { px, pal, ox, oy, tier, ko, idle, t, bounce,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, vBounce, ll, rl, legW } = p
// Cowboy hat (wide brim, tall crown)
if (!ko) {
const hatY = hy - 1
// Wide brim
for (let hbx = hx - 4; hbx < hx + hw + 4; hbx++) {
px(hbx, hatY, '#8B6914', ox, oy)
px(hbx, hatY + 1, '#7a5c12', ox, oy)
}
// Crown (tall rectangle)
for (let hcy = hatY - 4; hcy < hatY; hcy++) {
for (let hcx = hx + 1; hcx < hx + hw - 1; hcx++) {
px(hcx, hcy, '#8B6914', ox, oy)
}
}
// Hat band
for (let hcx = hx + 1; hcx < hx + hw - 1; hcx++) {
px(hcx, hatY - 1, '#cc8833', ox, oy)
}
// Dent in top
px(cx + hOff, hatY - 4, '#7a5c12', ox, oy)
}
// Bandana (around neck)
if (!ko) {
const bandY = by
for (let bx2 = bx; bx2 < bx + bw; bx2++) {
px(bx2, bandY, '#cc3333', ox, oy)
}
// Hanging triangle
px(cx + hOff, bandY + 1, '#cc3333', ox, oy)
px(cx + hOff - 1, bandY + 1, '#aa2222', ox, oy)
px(cx + hOff + 1, bandY + 1, '#aa2222', ox, oy)
px(cx + hOff, bandY + 2, '#882222', ox, oy)
}
// Boots with spurs
if (!ko && !p.knockback) {
const bootY = feetY + vBounce + globalY
// Left boot
px(ll - 1, bootY, '#663311', ox, oy)
px(ll + legW, bootY, '#663311', ox, oy)
px(ll + legW + 1, bootY + 1, '#ffd700', ox, oy) // spur
// Right boot
px(rl - 1, bootY, '#663311', ox, oy)
px(rl + legW, bootY, '#663311', ox, oy)
px(rl + legW + 1, bootY + 1, '#ffd700', ox, oy) // spur
}
// Belt buckle (big oval)
const beltY = by + bh - 2
px(cx + hOff - 1, beltY, '#ffd700', ox, oy)
px(cx + hOff, beltY, '#ffee44', ox, oy)
px(cx + hOff + 1, beltY, '#ffd700', ox, oy)
px(cx + hOff, beltY - 1, '#ffd700', ox, oy)
},
}
@@ -0,0 +1,43 @@
import type { Archetype } from '../constants'
export const cyborg: Archetype = {
name: 'cyborg',
weight: 0.14,
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, t, atk, special, ko, knockback,
bx, by, bw, bh, hx, hw, hy, hh } = p
// Half-human half-robot: left side robot, right side organic
// Robot half -- metal plating on left arm/body
for (let iy = by; iy < by + bh; iy++) {
px(bx, iy, '#888888', ox, oy)
px(bx + 1, iy, '#666666', ox, oy)
}
// Exposed wiring
if (!ko) {
px(bx + 2, by + 2, '#ff2200', ox, oy)
px(bx + 2, by + 4, '#00ff44', ox, oy)
px(bx + 2, by + 6, '#0088ff', ox, oy)
}
// Robot eye (left eye is mechanical)
const cyEyeY = hy + Math.floor(hh * 0.35)
if (!ko && !knockback) {
const cyEyeX = hx + Math.floor(hw * 0.2)
px(cyEyeX - 1, cyEyeY - 1, '#444444', ox, oy)
px(cyEyeX + 1, cyEyeY - 1, '#444444', ox, oy)
px(cyEyeX, cyEyeY, '#ff0000', ox, oy)
// Glowing scan line
if (p.frame % 2 === 0) px(cyEyeX - 1, cyEyeY, '#ff0000', ox, oy)
}
// Metal jaw plate
const jawY = hy + Math.floor(hh * 0.6)
for (let jx = hx; jx < hx + Math.floor(hw / 2); jx++) {
px(jx, jawY, '#777777', ox, oy)
}
// Sparking joint
if ((atk || special) && t > 0.3) {
px(bx + Math.floor(bw / 2), by - 1, '#ffff00', ox, oy)
px(bx + Math.floor(bw / 2) + 1, by - 2, '#ffffff', ox, oy)
}
},
}
@@ -0,0 +1,65 @@
import type { Archetype } from '../constants'
export const dinosaur: Archetype = {
name: 'dinosaur',
weight: 0.03,
dimensionOverrides: (tier) => ({
bw: 13 + tier * 2,
bh: 9 + tier,
armW: 2,
armH: 3 + Math.floor(tier * 0.5),
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko, knockback,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY } = p
// Spiky back (row of spines along top of body)
if (!ko) {
const spineCount = 4 + tier
for (let s = 0; s < spineCount; s++) {
const sx = bx + 1 + Math.floor(s * (bw - 2) / (spineCount - 1))
px(sx, by - 1, pal.acc, ox, oy)
px(sx, by - 2, pal.accDark, ox, oy)
if (s % 2 === 0) px(sx, by - 3, pal.accDark, ox, oy)
}
}
// Big jaw on head (wider lower face)
const jawY = hy + Math.floor(hh * 0.55)
for (let jx = hx; jx < hx + hw; jx++) {
px(jx, jawY, pal.dark, ox, oy)
}
// Teeth (jagged)
for (let tx = hx + 1; tx < hx + hw - 1; tx += 2) {
px(tx, jawY + 1, '#eeeeee', ox, oy)
}
// Tiny arms (T-rex style — already small from dimension override)
// Just add claws at tips
if (!ko && !knockback) {
px(p.armLx - 1, p.armAttach + p.armH, '#cccc88', ox, oy)
px(p.armRx + p.armW, p.armAttach + p.armH, '#cccc88', ox, oy)
}
// Thick tail
if (!knockback) {
const tailDir = -1
for (let tt = 1; tt <= 5 + tier; tt++) {
const tailW = Math.max(1, 3 - Math.floor(tt / 3))
for (let tw = 0; tw < tailW; tw++) {
px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt),
feetY - Math.floor(tt * 0.7) + tw + globalY, pal.body, ox, oy)
}
}
// Tail tip
px(cx + hOff + tailDir * (Math.floor(bw / 2) + 6 + tier),
feetY - Math.floor((5 + tier) * 0.7) + globalY, pal.dark, ox, oy)
}
// Nostril on snout
if (!ko) {
const nY = hy + Math.floor(hh * 0.45)
px(hx + hw - 2, nY, '#222222', ox, oy)
}
},
}
@@ -0,0 +1,56 @@
import type { Archetype } from '../constants'
export const dog: Archetype = {
name: 'dog',
weight: 0.04,
drawFeatures: (p) => {
const { px, box, pal, ox, oy, t, tier, idle, ko, knockback, hit, atk, win,
cx, hOff, hx, hw, hy, hh, by, bh, bw, globalY, vBounce, feetY, bounce } = p
if (ko) return
// Floppy ears
const earDrop = idle ? Math.abs(bounce) + 2 : hit ? 3 : 1
px(hx - 1, hy + 1, pal.body, ox, oy)
px(hx - 2, hy + 2, pal.body, ox, oy)
px(hx - 2, hy + 2 + earDrop, pal.dark, ox, oy)
px(hx - 3, hy + 3 + earDrop, pal.dark, ox, oy)
px(hx + hw, hy + 1, pal.body, ox, oy)
px(hx + hw + 1, hy + 2, pal.body, ox, oy)
px(hx + hw + 1, hy + 2 + earDrop, pal.dark, ox, oy)
px(hx + hw + 2, hy + 3 + earDrop, pal.dark, ox, oy)
// Snout with nose
const mY = hy + Math.floor(hh * 0.55)
px(cx + hOff + 2, mY, pal.skin, ox, oy)
px(cx + hOff + 3, mY, pal.skin, ox, oy)
px(cx + hOff + 3, mY + 1, pal.skinDark, ox, oy)
px(cx + hOff + 4, mY, '#222222', ox, oy) // nose
// Tongue (sticks out when idle or winning)
if (idle || win) {
const tongueY = mY + 2 + Math.round(Math.sin(t * Math.PI * 2) * 0.5)
px(cx + hOff + 2, tongueY, '#ff6688', ox, oy)
px(cx + hOff + 3, tongueY, '#ff6688', ox, oy)
px(cx + hOff + 2, tongueY + 1, '#ee5577', ox, oy)
}
// Wagging tail
if (!knockback) {
const wagOffset = idle || win ? Math.round(Math.sin(t * Math.PI * 4) * 3) : 0
const tailDir = -1
for (let tt = 1; tt <= 4; tt++) {
px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt), feetY - tt + globalY + wagOffset, pal.body, ox, oy)
}
px(cx + hOff + tailDir * (Math.floor(bw / 2) + 5), feetY - 5 + globalY + wagOffset, pal.dark, ox, oy)
}
// Collar
const collarY = by + bh - 3
for (let cx2 = cx + hOff - Math.floor(bw / 2); cx2 < cx + hOff + Math.floor(bw / 2); cx2++) {
px(cx2, collarY, '#cc2222', ox, oy)
}
// Tag
px(cx + hOff, collarY + 1, '#ffd700', ox, oy)
},
}
@@ -0,0 +1,80 @@
import type { Archetype } from '../constants'
export const frog: Archetype = {
name: 'frog',
weight: 0.03,
dimensionOverrides: (tier) => ({
legH: 4 + Math.floor(tier * 0.5),
legW: 4 + Math.floor(tier * 0.5),
}),
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, t, tier, idle, ko, atk, special, knockback,
hx, hy, hh, hw, cx, hOff, bx, by, bw, bh, feetY, globalY, vBounce, ll, rl, legW } = p
// Bulging eyes (extend above head)
if (!ko) {
const eyeY = hy - 2
const leX = hx + 1
const reX = hx + hw - 4
// Eye bulges (circles above head)
for (let ey = eyeY - 2; ey <= eyeY; ey++) {
px(leX, ey, '#88cc44', ox, oy)
px(leX + 1, ey, '#88cc44', ox, oy)
px(leX + 2, ey, '#88cc44', ox, oy)
px(reX, ey, '#88cc44', ox, oy)
px(reX + 1, ey, '#88cc44', ox, oy)
px(reX + 2, ey, '#88cc44', ox, oy)
}
// Pupils
px(leX + 1, eyeY, '#000000', ox, oy)
px(reX + 1, eyeY, '#000000', ox, oy)
// Highlight
px(leX, eyeY - 2, '#aaffaa', ox, oy)
px(reX + 2, eyeY - 2, '#aaffaa', ox, oy)
}
// Wide mouth (extends beyond head width)
if (!ko) {
const mY = hy + Math.floor(hh * 0.7)
for (let mx = hx - 1; mx <= hx + hw; mx++) {
px(mx, mY, '#226622', ox, oy)
}
}
// Long tongue (on attack)
if (atk || special) {
const tongueY = hy + Math.floor(hh * 0.7)
const tongueLen = Math.round(Math.sin(t * Math.PI) * (15 + tier * 3))
const dir = p.armRx > cx ? 1 : -1
for (let tl = 0; tl < tongueLen; tl++) {
px(hx + (dir > 0 ? hw : 0) + dir * tl, tongueY, '#ff4466', ox, oy)
}
// Tongue tip (wider)
if (tongueLen > 3) {
px(hx + (dir > 0 ? hw : 0) + dir * tongueLen, tongueY - 1, '#ff4466', ox, oy)
px(hx + (dir > 0 ? hw : 0) + dir * tongueLen, tongueY + 1, '#ff4466', ox, oy)
}
}
// Webbed feet
if (!ko && !knockback) {
const footY = feetY + vBounce + globalY
// Extra-wide toe spread
px(ll - 2, footY + 1, '#44aa22', ox, oy)
px(ll + legW + 1, footY + 1, '#44aa22', ox, oy)
px(rl - 2, footY + 1, '#44aa22', ox, oy)
px(rl + legW + 1, footY + 1, '#44aa22', ox, oy)
}
// Spotted belly
const spots = [
[bx + 2, by + 2],
[bx + bw - 3, by + 3],
[bx + Math.floor(bw / 2), by + bh - 3],
]
for (const [sx, sy] of spots) {
px(sx, sy, '#aaee66', ox, oy)
px(sx + 1, sy, '#aaee66', ox, oy)
}
},
}
@@ -0,0 +1,64 @@
import type { Archetype } from '../constants'
export const ghost: Archetype = {
name: 'ghost',
weight: 0.03,
canHaveMohawk: false,
canHaveHorns: false,
dimensionOverrides: () => ({
legH: 2,
legW: 2,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, idle, ko,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY } = p
// Wavy bottom (no real legs -- ghostly wisp)
if (!ko) {
const waveY = by + bh
for (let wx = bx; wx < bx + bw; wx++) {
const wave = Math.round(Math.sin(t * Math.PI * 3 + wx * 0.4) * 2)
px(wx, waveY + wave, pal.light, ox, oy)
px(wx, waveY + wave + 1, pal.light, ox, oy)
px(wx, waveY + wave + 2, pal.body, ox, oy)
}
}
// Semi-transparent overlay effect (lighter body patches)
for (let iy = by + 1; iy < by + bh; iy += 2) {
for (let ix = bx + 1; ix < bx + bw - 1; ix += 3) {
px(ix, iy, pal.light, ox, oy)
}
}
// Glowing eyes (large, circular)
if (!ko) {
const eyeY = hy + Math.floor(hh * 0.35)
const leX = hx + Math.floor(hw * 0.2)
const reX = hx + Math.floor(hw * 0.6)
// Large glowing circles
px(leX, eyeY, '#44ffff', ox, oy)
px(leX + 1, eyeY, '#44ffff', ox, oy)
px(leX, eyeY + 1, '#22cccc', ox, oy)
px(leX + 1, eyeY + 1, '#22cccc', ox, oy)
px(reX, eyeY, '#44ffff', ox, oy)
px(reX + 1, eyeY, '#44ffff', ox, oy)
px(reX, eyeY + 1, '#22cccc', ox, oy)
px(reX + 1, eyeY + 1, '#22cccc', ox, oy)
// Glow halo
const glowPulse = idle ? Math.sin(t * Math.PI * 4) * 0.5 : 0
if (glowPulse > 0) {
px(leX - 1, eyeY, '#22ffff', ox, oy)
px(reX + 2, eyeY, '#22ffff', ox, oy)
}
}
// Open mouth (always slightly open, spooky)
if (!ko) {
const mY = hy + Math.floor(hh * 0.65)
px(cx + hOff, mY, '#111122', ox, oy)
px(cx + hOff - 1, mY, '#111122', ox, oy)
px(cx + hOff + 1, mY, '#111122', ox, oy)
}
},
}
@@ -0,0 +1,68 @@
import type { Archetype } from '../constants'
import { standard } from './standard'
import { lobster } from './lobster'
import { sheep } from './sheep'
import { cyborg } from './cyborg'
import { blob } from './blob'
import { tank } from './tank'
import { dog } from './dog'
import { cat } from './cat'
import { cactus } from './cactus'
import { pizza } from './pizza'
import { mushroom } from './mushroom'
import { shark } from './shark'
import { penguin } from './penguin'
import { octopus } from './octopus'
import { skeleton } from './skeleton'
import { ghost } from './ghost'
import { alien } from './alien'
import { dinosaur } from './dinosaur'
import { pirate } from './pirate'
import { ninja } from './ninja'
import { cowboy } from './cowboy'
import { wizard } from './wizard'
import { bee } from './bee'
import { frog } from './frog'
import { snail } from './snail'
// Batch 4: Robots & Tech
import { robot, android, droneBug, toaster, tvHead, calculator, satellite, mech, ledCube, circuit, antennaBug, microwave, cyberdog, robocat, ufoBot } from './batch_robots'
// Batch 5: Mythology & Fantasy
import { minotaur, unicorn, phoenix, dragonArch, mermaid, griffin, cyclops, gargoyle, golem, vampire, werewolf, zombie, witch, demon } from './batch_mythology'
// Batch 6: Jobs & Warriors
import { chef, firefighter, astronautArch, clown, detective, nurse, lumberjack, scientist, wrestler, boxer, gladiator, samuraiArch, vikingArch, knight } from './batch_jobs'
// Batch 7: More Animals
import { elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster } from './batch_animals2'
// Batch 8: Silly & Objects
import { sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan } from './batch_silly'
export const archetypes: Archetype[] = [
// Original 6
standard, lobster, sheep, cyborg, blob, tank,
// Batch 1: animals & food
dog, cat, cactus, pizza, mushroom, shark, penguin, octopus,
// Batch 2: fantasy & themed
skeleton, ghost, alien, dinosaur, pirate, ninja, cowboy, wizard,
// Batch 3: critters
bee, frog, snail,
// Batch 4: robots & tech
robot, android, droneBug, toaster, tvHead, calculator, satellite, mech, ledCube, circuit, antennaBug, microwave, cyberdog, robocat, ufoBot,
// Batch 5: mythology & fantasy
minotaur, unicorn, phoenix, dragonArch, mermaid, griffin, cyclops, gargoyle, golem, vampire, werewolf, zombie, witch, demon,
// Batch 6: jobs & warriors
chef, firefighter, astronautArch, clown, detective, nurse, lumberjack, scientist, wrestler, boxer, gladiator, samuraiArch, vikingArch, knight,
// Batch 7: more animals
elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster,
// Batch 8: silly & objects
sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan,
]
export function rollArchetype(roll: number): Archetype {
// Normalize: sum all weights, then compare proportionally
const totalWeight = archetypes.reduce((sum, a) => sum + a.weight, 0)
let cumulative = 0
for (const arch of archetypes) {
cumulative += arch.weight / totalWeight
if (roll < cumulative) return arch
}
return archetypes[0]
}
@@ -0,0 +1,58 @@
import type { Archetype } from '../constants'
export const lobster: Archetype = {
name: 'lobster',
weight: 0.15,
canHaveHorns: false,
dimensionOverrides: (tier) => ({
hw: 8 + tier,
hh: 7 + tier,
armW: 4,
armH: 4 + tier,
}),
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, t, tier, idle, atk, kick, special, knockback, ko,
cx, hOff, armLx, armRx, armAttach, armH, armW, globalY, vBounce,
hx, hw, hy, bx, bw, by, bh, feetY } = p
if (ko) return
// Lobster claws replace fists -- big pincers on each arm
const clawSize = 4 + Math.floor(tier * 0.5)
const clawOpen = (atk || kick || special) ? 2 : 0
// Left claw
const lcx = armLx - 2 + hOff
const lcy = armAttach + armH + globalY + vBounce
px(lcx, lcy, pal.acc, ox, oy); px(lcx + 1, lcy, pal.acc, ox, oy)
px(lcx - 1, lcy - clawOpen, pal.acc, ox, oy); px(lcx + 2, lcy - clawOpen, pal.acc, ox, oy)
px(lcx - 1, lcy + 1 + clawOpen, pal.acc, ox, oy); px(lcx + 2, lcy + 1 + clawOpen, pal.acc, ox, oy)
for (let c = 0; c < clawSize; c++) { px(lcx - 2 - c, lcy - clawOpen, pal.accDark, ox, oy); px(lcx - 2 - c, lcy + 1 + clawOpen, pal.accDark, ox, oy) }
// Right claw
const rcx = armRx + armW + hOff
const rcy = armAttach + armH + globalY + vBounce - (atk ? Math.round(Math.sin(t * Math.PI) * 6) : 0)
px(rcx, rcy, pal.acc, ox, oy); px(rcx + 1, rcy, pal.acc, ox, oy)
px(rcx - 1, rcy - clawOpen, pal.acc, ox, oy); px(rcx + 2, rcy - clawOpen, pal.acc, ox, oy)
px(rcx - 1, rcy + 1 + clawOpen, pal.acc, ox, oy); px(rcx + 2, rcy + 1 + clawOpen, pal.acc, ox, oy)
for (let c = 0; c < clawSize; c++) { px(rcx + 3 + c, rcy - clawOpen, pal.accDark, ox, oy); px(rcx + 3 + c, rcy + 1 + clawOpen, pal.accDark, ox, oy) }
// Antennae -- long feelers from head
const antL = 5 + tier
for (let a = 1; a <= antL; a++) {
const wobble = idle ? Math.round(Math.sin(t * Math.PI * 2 + a * 0.5) * 1) : 0
px(hx + 2 + hOff, hy - a + wobble, pal.acc, ox, oy)
px(hx + hw - 3 + hOff, hy - a - wobble, pal.acc, ox, oy)
}
px(hx + 2 + hOff, hy - antL - 1, pal.accLight, ox, oy)
px(hx + hw - 3 + hOff, hy - antL - 1, pal.accLight, ox, oy)
// Segmented body lines
for (let s = by + 2; s < by + bh; s += 2) {
for (let sx = bx; sx < bx + bw; sx++) px(sx, s, pal.accDark, ox, oy)
}
// Tail
if (!knockback) {
const tailDir = -1 // behind
for (let tt = 1; tt <= 3; tt++) {
px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt), feetY - tt + globalY, pal.accDark, ox, oy)
px(cx + hOff + tailDir * (Math.floor(bw / 2) + tt), feetY - tt + 1 + globalY, pal.accDark, ox, oy)
}
}
},
}
@@ -0,0 +1,52 @@
import type { Archetype } from '../constants'
export const mushroom: Archetype = {
name: 'mushroom',
weight: 0.03,
canHaveMohawk: false,
canHaveHorns: false,
dimensionOverrides: (tier) => ({
hh: 12 + tier,
legH: 4,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko,
hx, hy, hw, hh, cx, hOff, by, bh, globalY } = p
// Dome cap on head (wider than head, rounded)
const capW = hw + 6
const capH = Math.floor(hh * 0.6)
const capX = hx - 3
const capY = hy - capH + 2
for (let iy = 0; iy < capH; iy++) {
const rowW = capW - Math.floor(iy * iy / capH)
const rx = capX + Math.floor((capW - rowW) / 2)
for (let ix = rx; ix < rx + rowW; ix++) {
px(ix, capY + iy, pal.acc, ox, oy)
}
}
// Spots on cap
if (!ko) {
const spotPositions = [
[capX + 2, capY + 2],
[capX + capW - 3, capY + 3],
[capX + Math.floor(capW / 2), capY + 1],
[capX + Math.floor(capW / 3), capY + capH - 2],
]
for (const [sx, sy] of spotPositions) {
px(sx, sy, pal.accLight, ox, oy)
px(sx + 1, sy, pal.accLight, ox, oy)
}
}
// Spore particles (floating up when idle)
if (idle) {
for (let s = 0; s < 3; s++) {
const sporeX = cx + hOff + Math.round(Math.sin(t * Math.PI * 2 + s * 2) * 8)
const sporeY = by + bh + 2 - Math.round(t * 4 + s * 3) % 10
px(sporeX, sporeY + globalY, pal.accLight, ox, oy)
}
}
},
}
@@ -0,0 +1,68 @@
import type { Archetype } from '../constants'
export const ninja: Archetype = {
name: 'ninja',
weight: 0.03,
canHaveVisor: false,
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, tier, ko, idle, atk, special, t,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
// Mask covering lower face
if (!ko) {
const maskY = hy + Math.floor(hh * 0.5)
for (let my = maskY; my < hy + hh; my++) {
for (let mx = hx + 1; mx < hx + hw - 1; mx++) {
px(mx, my, '#222222', ox, oy)
}
}
}
// Headband (dark with knot trailing)
if (!ko) {
const bandY = hy + 2
for (let bx2 = hx; bx2 < hx + hw; bx2++) {
px(bx2, bandY, '#cc2222', ox, oy)
}
// Trailing tails
const trail = idle ? Math.round(Math.sin(t * Math.PI * 2) * 1) : 0
px(hx - 1, bandY + 1 + trail, '#cc2222', ox, oy)
px(hx - 2, bandY + 2 + trail, '#aa1111', ox, oy)
px(hx - 3, bandY + 3 + trail, '#881111', ox, oy)
}
// Throwing star on back
if (!ko) {
const starX = bx + bw - 2
const starY = by + 2
px(starX, starY, '#aaaaaa', ox, oy)
px(starX - 1, starY - 1, '#888888', ox, oy)
px(starX + 1, starY - 1, '#888888', ox, oy)
px(starX - 1, starY + 1, '#888888', ox, oy)
px(starX + 1, starY + 1, '#888888', ox, oy)
}
// Dark body wraps
for (let wy = by + 1; wy < by + bh; wy += 3) {
for (let wx = bx; wx < bx + bw; wx++) {
px(wx, wy, '#1a1a2a', ox, oy)
}
}
// Visible eyes only (narrow, intense)
if (!ko) {
const eyeY = hy + Math.floor(hh * 0.35)
const leX = hx + Math.floor(hw * 0.2)
const reX = hx + Math.floor(hw * 0.6)
// Narrow slits
px(leX, eyeY + 1, '#ffffff', ox, oy)
px(leX + 1, eyeY + 1, '#ffffff', ox, oy)
px(reX, eyeY + 1, '#ffffff', ox, oy)
px(reX + 1, eyeY + 1, '#ffffff', ox, oy)
if (atk || special) {
px(leX, eyeY + 1, '#ff4444', ox, oy)
px(reX + 1, eyeY + 1, '#ff4444', ox, oy)
}
}
},
}
@@ -0,0 +1,59 @@
import type { Archetype } from '../constants'
export const octopus: Archetype = {
name: 'octopus',
weight: 0.03,
canHaveMohawk: false,
dimensionOverrides: (tier) => ({
hw: 13 + tier,
hh: 10 + tier,
legH: 3,
armW: 2,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko, knockback,
cx, hOff, bx, by, bw, bh, feetY, globalY, vBounce } = p
// Tentacle legs (4 visible, wavy)
if (!ko) {
for (let leg = 0; leg < 4; leg++) {
const baseX = bx + Math.floor(leg * bw / 3) + hOff
const baseY = feetY + globalY
for (let seg = 0; seg < 6; seg++) {
const wave = idle ? Math.round(Math.sin(t * Math.PI * 3 + leg * 1.5 + seg * 0.8) * 2) : 0
const tx = baseX + wave
const ty = baseY + seg
const color = seg < 4 ? pal.body : pal.dark
px(tx, ty, color, ox, oy)
px(tx + 1, ty, color, ox, oy)
}
// Suction cups
if (idle) {
px(bx + Math.floor(leg * bw / 3) + hOff + 1, feetY + 3 + globalY, pal.accLight, ox, oy)
}
}
}
// Tentacle arms (replace normal arms with wavy ones)
if (!ko && !knockback) {
for (let arm = 0; arm < 2; arm++) {
const armDir = arm === 0 ? -1 : 1
const armBase = cx + hOff + armDir * Math.floor(bw / 2 + 2)
for (let seg = 0; seg < 5 + tier; seg++) {
const wave = idle ? Math.round(Math.sin(t * Math.PI * 2 + arm * Math.PI + seg * 0.6) * 2) : 0
px(armBase + armDir * seg, by + 3 + wave + globalY, pal.body, ox, oy)
// Suction cup
if (seg % 2 === 1) px(armBase + armDir * seg, by + 4 + wave + globalY, pal.accLight, ox, oy)
}
}
}
// Large head already provided by base, but add dome bump
const domeX = cx + hOff
const domeY = p.hy - 2
px(domeX, domeY, pal.body, ox, oy)
px(domeX - 1, domeY, pal.body, ox, oy)
px(domeX + 1, domeY, pal.body, ox, oy)
px(domeX, domeY - 1, pal.dark, ox, oy)
},
}
@@ -0,0 +1,58 @@
import type { Archetype } from '../constants'
export const penguin: Archetype = {
name: 'penguin',
weight: 0.03,
dimensionOverrides: (tier) => ({
armW: 4,
armH: 6 + tier,
}),
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, tier, ko, idle,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, bounce } = p
// White belly (tuxedo front)
const bellyX = bx + 2
const bellyW = bw - 4
const bellyY = by + 2
const bellyH = bh - 3
for (let iy = bellyY; iy < bellyY + bellyH; iy++) {
for (let ix = bellyX; ix < bellyX + bellyW; ix++) {
px(ix, iy, '#eeeeee', ox, oy)
}
}
// Orange beak
if (!ko) {
const beakY = hy + Math.floor(hh * 0.5)
const beakX = cx + hOff
px(beakX, beakY, '#ff8800', ox, oy)
px(beakX + 1, beakY, '#ff8800', ox, oy)
px(beakX + 2, beakY, '#ff6600', ox, oy)
px(beakX, beakY + 1, '#ff6600', ox, oy)
}
// Flipper-like arms (wider, more rounded at tips)
// Flippers already drawn by base as arms, just add tips
if (!ko) {
const armAttach = by + 2 + (idle ? bounce : 0)
// Flipper tips on left
px(bx - 5 + hOff, armAttach + p.armH, pal.dark, ox, oy)
px(bx - 5 + hOff, armAttach + p.armH + 1, pal.dark, ox, oy)
// Flipper tips on right
px(bx + bw + 4 + hOff, armAttach + p.armH, pal.dark, ox, oy)
px(bx + bw + 4 + hOff, armAttach + p.armH + 1, pal.dark, ox, oy)
}
// Orange feet
if (!ko) {
const feetY = p.feetY + p.vBounce + p.globalY
px(p.ll, feetY, '#ff8800', ox, oy)
px(p.ll + 1, feetY, '#ff8800', ox, oy)
px(p.ll - 1, feetY, '#ff6600', ox, oy)
px(p.rl, feetY, '#ff8800', ox, oy)
px(p.rl + 1, feetY, '#ff8800', ox, oy)
px(p.rl + 2, feetY, '#ff6600', ox, oy)
}
},
}
@@ -0,0 +1,68 @@
import type { Archetype } from '../constants'
export const pirate: Archetype = {
name: 'pirate',
weight: 0.03,
canHaveVisor: false,
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, tier, ko, knockback,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, vBounce, ll, rl, legW } = p
// Pirate hat (wide brim)
if (!ko) {
const hatY = hy - 2
// Brim
for (let hbx = hx - 2; hbx < hx + hw + 2; hbx++) {
px(hbx, hatY, '#222222', ox, oy)
}
// Crown of hat
for (let hcy = hatY - 3; hcy < hatY; hcy++) {
for (let hcx = hx + 1; hcx < hx + hw - 1; hcx++) {
px(hcx, hcy, '#222222', ox, oy)
}
}
// Skull emblem
px(cx + hOff, hatY - 2, '#ffffff', ox, oy)
px(cx + hOff - 1, hatY - 1, '#ffffff', ox, oy)
px(cx + hOff + 1, hatY - 1, '#ffffff', ox, oy)
}
// Eye patch (covers one eye)
if (!ko) {
const eyeY = hy + Math.floor(hh * 0.35)
const patchX = hx + Math.floor(hw * 0.2)
fill(patchX - 1, eyeY - 1, 4, 4, '#111111', ox, oy)
// Strap
for (let sx = patchX + 3; sx < hx + hw; sx++) {
px(sx, eyeY - 1, '#222222', ox, oy)
}
}
// Peg leg (replaces one leg)
if (!ko && !knockback) {
const pegX = rl
const pegY = p.legsTop + p.vBounce + p.globalY
for (let py = pegY; py < feetY + vBounce + globalY + 2; py++) {
px(pegX + 1, py, '#aa8844', ox, oy)
}
}
// Hook hand (replaces one arm end)
if (!ko && !knockback) {
const hookX = p.armRx + p.armW + hOff
const hookY = p.armAttach + p.armH + p.globalY
px(hookX, hookY, '#aaaaaa', ox, oy)
px(hookX + 1, hookY + 1, '#888888', ox, oy)
px(hookX, hookY + 1, '#888888', ox, oy)
px(hookX - 1, hookY + 1, '#888888', ox, oy)
}
// Belt with buckle
const beltY = by + bh - 2
for (let beltX = bx; beltX < bx + bw; beltX++) {
px(beltX, beltY, '#8B4513', ox, oy)
}
px(cx + hOff, beltY, '#ffd700', ox, oy)
px(cx + hOff + 1, beltY, '#ffd700', ox, oy)
},
}
@@ -0,0 +1,52 @@
import type { Archetype } from '../constants'
export const pizza: Archetype = {
name: 'pizza',
weight: 0.03,
canHaveMohawk: false,
canHaveHorns: false,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff } = p
// Triangular body shape hint -- darker edges converging up
for (let iy = by; iy < by + bh; iy++) {
const progress = (iy - by) / bh
const indent = Math.floor(progress * 3)
px(bx + indent, iy, '#e8a030', ox, oy) // crust color
px(bx + bw - 1 - indent, iy, '#e8a030', ox, oy)
}
// Cheese drip effect
if (!ko) {
const dripCount = 3 + Math.floor(tier * 0.5)
for (let d = 0; d < dripCount; d++) {
const dx = bx + 2 + Math.floor(d * (bw - 4) / (dripCount - 1))
const dLen = 2 + Math.floor(Math.sin(t * Math.PI * 2 + d) * 1.5)
for (let dy = 0; dy < Math.max(1, dLen); dy++) {
px(dx, by + bh + dy, '#ffdd44', ox, oy)
}
}
}
// Pepperoni spots
const spots = [
[bx + 3, by + 2],
[bx + bw - 4, by + 3],
[bx + Math.floor(bw / 2), by + Math.floor(bh / 2)],
[bx + 2, by + bh - 3],
[bx + bw - 3, by + bh - 2],
]
for (const [sx, sy] of spots) {
px(sx, sy, '#cc3322', ox, oy)
px(sx + 1, sy, '#cc3322', ox, oy)
px(sx, sy + 1, '#aa2211', ox, oy)
}
// Crusty edges on head
const crustY = hy + hh - 1
for (let cx2 = hx; cx2 < hx + hw; cx2++) {
px(cx2, crustY, '#c88020', ox, oy)
}
},
}
@@ -0,0 +1,62 @@
import type { Archetype } from '../constants'
export const shark: Archetype = {
name: 'shark',
weight: 0.03,
dimensionOverrides: (tier) => ({
bw: 12 + tier * 2,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, tier, ko, knockback, atk, idle,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, t } = p
// Dorsal fin on back
if (!ko && !knockback) {
const finX = cx + hOff
const finY = by - 2
px(finX, finY, pal.dark, ox, oy)
px(finX, finY - 1, pal.dark, ox, oy)
px(finX, finY - 2, pal.body, ox, oy)
px(finX - 1, finY, pal.dark, ox, oy)
px(finX + 1, finY, pal.dark, ox, oy)
px(finX, finY - 3, pal.body, ox, oy)
}
// Teeth row on head (visible grin)
if (!ko) {
const teethY = hy + Math.floor(hh * 0.7)
for (let tx = hx + 1; tx < hx + hw - 1; tx += 2) {
px(tx, teethY, '#ffffff', ox, oy)
px(tx, teethY + 1, '#eeeeee', ox, oy)
}
}
// Tail fin
if (!knockback) {
const tailDir = -1
const tailX = cx + hOff + tailDir * (Math.floor(bw / 2) + 1)
const tailY = by + Math.floor(bh / 2) + globalY
px(tailX, tailY, pal.dark, ox, oy)
px(tailX + tailDir, tailY - 2, pal.body, ox, oy)
px(tailX + tailDir, tailY + 2, pal.body, ox, oy)
px(tailX + tailDir * 2, tailY - 3, pal.dark, ox, oy)
px(tailX + tailDir * 2, tailY + 3, pal.dark, ox, oy)
}
// Gills on body (3 slashes)
for (let g = 0; g < 3; g++) {
px(bx + 2, by + 2 + g * 2, pal.dark, ox, oy)
px(bx + 3, by + 2 + g * 2, pal.dark, ox, oy)
}
// Beady black eyes
if (!ko) {
const eyeY = hy + Math.floor(hh * 0.3)
px(hx + 2, eyeY, '#000000', ox, oy)
px(hx + hw - 3, eyeY, '#000000', ox, oy)
// Tiny white reflection
px(hx + 2, eyeY, '#111111', ox, oy)
px(hx + hw - 3, eyeY, '#111111', ox, oy)
}
},
}
@@ -0,0 +1,45 @@
import type { Archetype } from '../constants'
export const sheep: Archetype = {
name: 'sheep',
weight: 0.13,
canHaveMohawk: false,
drawFeatures: (p) => {
const { px, box, pal, ox, oy, t, tier, idle, hit, ko, knockback, kick,
cx, hOff, by, bw, bh, hx, hw, hy, hh, globalY, vBounce, feetY, legW, ll, rl } = p
// Woolly body -- fluffy circles around torso
const woolColors = [pal.light, '#eeeeee', '#dddddd', pal.light]
for (let w = 0; w < 6 + tier; w++) {
const angle = w * Math.PI * 2 / (6 + tier) + (idle ? t * 0.2 : 0)
const wr = Math.floor(bw / 2) + 2
const wx = cx + hOff + Math.round(Math.cos(angle) * wr)
const wy = by + Math.floor(bh / 2) + Math.round(Math.sin(angle) * (bh / 2 - 1)) + globalY
const wc = woolColors[w % woolColors.length]
px(wx, wy, wc, ox, oy); px(wx + 1, wy, wc, ox, oy)
px(wx, wy + 1, wc, ox, oy)
}
// Fluffy head wool
for (let w = 0; w < 5; w++) {
const wa = w * Math.PI * 2 / 5
const wrx = Math.round(Math.cos(wa) * (hw / 2 + 1))
const wry = Math.round(Math.sin(wa) * (hh / 2))
px(hx + Math.floor(hw / 2) + wrx, hy + Math.floor(hh / 2) + wry - 2, '#eeeeee', ox, oy)
}
// Floppy ears
if (!ko) {
const earDrop = idle ? Math.abs(p.bounce) : hit ? 2 : 0
px(hx - 1, hy + 2 + earDrop, pal.skin, ox, oy)
px(hx - 2, hy + 3 + earDrop, pal.skin, ox, oy)
px(hx - 2, hy + 4 + earDrop, pal.skinDark, ox, oy)
px(hx + hw, hy + 2 + earDrop, pal.skin, ox, oy)
px(hx + hw + 1, hy + 3 + earDrop, pal.skin, ox, oy)
px(hx + hw + 1, hy + 4 + earDrop, pal.skinDark, ox, oy)
}
// Stubby hooves instead of feet
if (!ko && !knockback && !kick) {
box(ll - 1, feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy)
box(rl - 1, feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy)
}
},
}
@@ -0,0 +1,58 @@
import type { Archetype } from '../constants'
export const skeleton: Archetype = {
name: 'skeleton',
weight: 0.03,
drawFeatures: (p) => {
const { px, pal, ox, oy, ko,
bx, by, bw, bh, hx, hy, hh, hw } = p
// Visible ribs on body
for (let r = 0; r < 4; r++) {
const ribY = by + 2 + r * 2
const ribW = bw - 4 - r
const ribX = bx + 2 + Math.floor(r / 2)
for (let rx = ribX; rx < ribX + ribW; rx++) {
px(rx, ribY, '#ddddcc', ox, oy)
}
}
// Spine down center
for (let sy = by + 1; sy < by + bh - 1; sy++) {
px(bx + Math.floor(bw / 2), sy, '#ccccbb', ox, oy)
}
// Skull head shape (hollow eyes, jaw)
if (!ko) {
// Dark eye sockets
const eyeY = hy + Math.floor(hh * 0.3)
const leX = hx + Math.floor(hw * 0.2)
const reX = hx + Math.floor(hw * 0.6)
px(leX, eyeY, '#111111', ox, oy)
px(leX + 1, eyeY, '#111111', ox, oy)
px(leX, eyeY + 1, '#111111', ox, oy)
px(leX + 1, eyeY + 1, '#111111', ox, oy)
px(reX, eyeY, '#111111', ox, oy)
px(reX + 1, eyeY, '#111111', ox, oy)
px(reX, eyeY + 1, '#111111', ox, oy)
px(reX + 1, eyeY + 1, '#111111', ox, oy)
// Glowing dots in sockets
px(leX + 1, eyeY + 1, '#ff2222', ox, oy)
px(reX, eyeY + 1, '#ff2222', ox, oy)
}
// Nose hole
const noseY = hy + Math.floor(hh * 0.5)
px(hx + Math.floor(hw / 2), noseY, '#111111', ox, oy)
// Teeth on lower face
const jawY = hy + Math.floor(hh * 0.65)
for (let tx = hx + 2; tx < hx + hw - 2; tx += 2) {
px(tx, jawY, '#eeeeee', ox, oy)
}
// Bony limbs (lighter color on arms/legs)
px(p.armLx + 1, p.armAttach + 2, '#ddddcc', ox, oy)
px(p.armRx + 1, p.armAttach + 2, '#ddddcc', ox, oy)
},
}
@@ -0,0 +1,76 @@
import type { Archetype } from '../constants'
export const snail: Archetype = {
name: 'snail',
weight: 0.03,
canHaveMohawk: false,
canHaveHorns: false,
dimensionOverrides: () => ({
legH: 3,
legW: 5,
}),
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko, knockback,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, feetY, globalY, vBounce } = p
// Shell on back (spiral circle)
if (!ko) {
const shellX = bx + bw + 1
const shellY = by + Math.floor(bh / 2)
const shellR = Math.floor(bh / 2) + 2
// Outer shell
for (let a = 0; a < 16; a++) {
const angle = a * Math.PI * 2 / 16
const sx = shellX + Math.round(Math.cos(angle) * shellR)
const sy = shellY + Math.round(Math.sin(angle) * shellR)
px(sx, sy, pal.acc, ox, oy)
}
// Inner spiral
for (let a = 0; a < 12; a++) {
const angle = a * Math.PI * 2 / 12
const r2 = shellR * 0.6
const sx = shellX + Math.round(Math.cos(angle) * r2)
const sy = shellY + Math.round(Math.sin(angle) * r2)
px(sx, sy, pal.accDark, ox, oy)
}
// Center
px(shellX, shellY, pal.accLight, ox, oy)
px(shellX + 1, shellY, pal.accLight, ox, oy)
}
// Eye stalks (extend from top of head)
if (!ko) {
const stalkH = 4 + Math.floor(tier * 0.5)
const wobble = idle ? Math.round(Math.sin(t * Math.PI * 2) * 1) : 0
// Left stalk
for (let s = 0; s < stalkH; s++) {
px(hx + 2 + hOff, hy - 1 - s + wobble, pal.body, ox, oy)
}
px(hx + 2 + hOff, hy - 1 - stalkH + wobble, '#ffffff', ox, oy)
px(hx + 2 + hOff, hy - stalkH + wobble, '#000000', ox, oy)
// Right stalk
for (let s = 0; s < stalkH; s++) {
px(hx + hw - 3 + hOff, hy - 1 - s - wobble, pal.body, ox, oy)
}
px(hx + hw - 3 + hOff, hy - 1 - stalkH - wobble, '#ffffff', ox, oy)
px(hx + hw - 3 + hOff, hy - stalkH - wobble, '#000000', ox, oy)
}
// Slime trail (behind body, on ground)
if (!ko && !knockback) {
const slimeY = feetY + vBounce + globalY + 2
for (let sx = bx - 8; sx < bx; sx++) {
px(sx + hOff, slimeY, '#88cc88', ox, oy)
}
}
// Gooey body texture
if (idle) {
for (let g = 0; g < 3; g++) {
const gx = bx + 1 + g * Math.floor(bw / 3)
const gy = by + bh + Math.round(Math.sin(t * Math.PI * 2 + g) * 1)
px(gx, gy, pal.light, ox, oy)
}
}
},
}
@@ -0,0 +1,8 @@
import type { Archetype } from '../constants'
export const standard: Archetype = {
name: 'standard',
weight: 0.30,
canHaveVisor: true,
drawFeatures: () => {},
}
@@ -0,0 +1,48 @@
import type { Archetype } from '../constants'
export const tank: Archetype = {
name: 'tank',
weight: 0.14,
dimensionOverrides: (tier) => ({
bw: 14 + tier * 2,
bh: 6 + tier,
legH: 4 + tier,
legW: 4 + Math.floor(tier * 0.5),
}),
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, tier, ko, knockback,
cx, hOff, bx, by, bw, bh, hx, hw, hy, hh, feetY, globalY, vBounce } = p
// Heavy armor plating -- double outline
for (let iy = by; iy < by + bh; iy++) {
px(bx - 1, iy, pal.dark, ox, oy)
px(bx + bw, iy, pal.dark, ox, oy)
}
// Rivets
px(bx, by + 1, '#888888', ox, oy); px(bx + bw - 1, by + 1, '#888888', ox, oy)
px(bx, by + bh - 2, '#888888', ox, oy); px(bx + bw - 1, by + bh - 2, '#888888', ox, oy)
// Thick neck (connects head to body more solidly)
const neckW = Math.floor(hw * 0.4)
for (let nx = cx - Math.floor(neckW / 2); nx < cx + Math.floor(neckW / 2); nx++) {
px(nx + hOff, by - 1 + globalY + vBounce, pal.body, ox, oy)
px(nx + hOff, by - 2 + globalY + vBounce, pal.dark, ox, oy)
}
// Treads instead of feet
if (!ko && !knockback) {
const treadY = feetY + vBounce + globalY
for (let tx = cx - Math.floor(bw / 2) - 1; tx <= cx + Math.floor(bw / 2) + 1; tx++) {
px(tx + hOff, treadY, '#444444', ox, oy)
px(tx + hOff, treadY + 1, '#333333', ox, oy)
if (tx % 2 === 0) px(tx + hOff, treadY, '#555555', ox, oy)
}
}
// Helmet visor
if (!ko) {
const vizY = hy + 1
for (let vx = hx + 1; vx < hx + hw - 1; vx++) {
px(vx, vizY, pal.accDark, ox, oy)
}
px(hx + 1, vizY, pal.accLight, ox, oy)
}
},
}
@@ -0,0 +1,78 @@
import type { Archetype } from '../constants'
export const wizard: Archetype = {
name: 'wizard',
weight: 0.03,
canHaveMohawk: false,
canHaveVisor: false,
drawFeatures: (p) => {
const { px, pal, ox, oy, t, tier, idle, ko, special, atk, frame,
bx, by, bw, bh, hx, hy, hh, hw, cx, hOff, armLx, armAttach, armH, globalY } = p
// Pointed wizard hat (tall triangle)
if (!ko) {
const hatBase = hy - 1
const hatHeight = 8 + tier
for (let h = 0; h < hatHeight; h++) {
const rowW = Math.max(1, Math.floor((hatHeight - h) / hatHeight * (hw - 2)))
const rowX = cx + hOff - Math.floor(rowW / 2)
for (let hx2 = rowX; hx2 < rowX + rowW; hx2++) {
px(hx2, hatBase - h, pal.acc, ox, oy)
}
}
// Hat brim
for (let hbx = hx - 2; hbx < hx + hw + 2; hbx++) {
px(hbx, hatBase, pal.accDark, ox, oy)
}
// Star on hat
px(cx + hOff, hatBase - Math.floor(hatHeight * 0.6), '#ffd700', ox, oy)
px(cx + hOff - 1, hatBase - Math.floor(hatHeight * 0.6), '#ffee88', ox, oy)
px(cx + hOff + 1, hatBase - Math.floor(hatHeight * 0.6), '#ffee88', ox, oy)
}
// Long robe (extends body down, covers legs partially)
if (!ko) {
const robeY = by + bh
for (let ry = 0; ry < 3; ry++) {
const robeW = bw + 2 + ry
const robeX = bx - 1 - Math.floor(ry / 2)
for (let rx = robeX; rx < robeX + robeW; rx++) {
px(rx, robeY + ry, pal.acc, ox, oy)
}
}
}
// Staff replaces left arm
if (!ko && !p.knockback) {
const staffX = armLx - 1 + hOff
const staffTop = armAttach - 10 + globalY
const staffBot = p.feetY + globalY + 2
for (let sy = staffTop; sy < staffBot; sy++) {
px(staffX, sy, '#8B6914', ox, oy)
}
// Orb at top
const orbY = staffTop - 1
px(staffX, orbY, pal.accLight, ox, oy)
px(staffX - 1, orbY, pal.acc, ox, oy)
px(staffX + 1, orbY, pal.acc, ox, oy)
px(staffX, orbY - 1, pal.acc, ox, oy)
// Sparkle when attacking/special
if ((special || atk) && frame % 2 === 0) {
px(staffX - 1, orbY - 1, '#ffffff', ox, oy)
px(staffX + 1, orbY - 1, '#ffff00', ox, oy)
px(staffX, orbY - 2, '#ffffff', ox, oy)
}
}
// Beard
if (!ko) {
const beardY = hy + Math.floor(hh * 0.65)
for (let by2 = beardY; by2 < beardY + 3 + Math.floor(tier * 0.5); by2++) {
const bWidth = Math.max(1, 3 - (by2 - beardY))
for (let bbx = cx + hOff - Math.floor(bWidth / 2); bbx <= cx + hOff + Math.floor(bWidth / 2); bbx++) {
px(bbx, by2, '#cccccc', ox, oy)
}
}
}
},
}
+70
View File
@@ -0,0 +1,70 @@
export const FRAME_SIZE = 96
export const INTERNAL = 48
export const SCALE = FRAME_SIZE / INTERNAL
export const ANIMATIONS = {
idle: { frames: 4, row: 0 },
attack: { frames: 6, row: 1 },
kick: { frames: 5, row: 2 },
special: { frames: 6, row: 3 },
hit: { frames: 3, row: 4 },
knockback: { frames: 5, row: 5 },
ko: { frames: 5, row: 6 },
win: { frames: 4, row: 7 },
}
export const TOTAL_ROWS = Object.keys(ANIMATIONS).length
export const MAX_FRAMES = 6
export interface Pal {
body: string; dark: string; light: string
acc: string; accDark: string; accLight: string
out: string; skin: string; skinDark: string
}
export interface Dimensions {
bw: number; bh: number
hw: number; hh: number
legH: number; legW: number
armW: number; armH: number
}
export function baseDimensions(tier: number): Dimensions {
return {
bw: 10 + tier * 2,
bh: 8 + tier,
hw: 10 + tier,
hh: 9 + tier,
legH: 6 + tier,
legW: 3 + Math.floor(tier * 0.5),
armW: 3,
armH: 5 + tier,
}
}
export interface ArchetypeParams {
px: (x: number, y: number, color: string, ox: number, oy: number) => void
box: (x: number, y: number, w: number, h: number, fillColor: string, ox: number, oy: number) => void
fill: (x: number, y: number, w: number, h: number, color: string, ox: number, oy: number) => void
pal: Pal
ox: number; oy: number
t: number; frame: number; bounce: number
tier: number
specialType: 'fire' | 'electric'
idle: boolean; atk: boolean; kick: boolean; special: boolean
hit: boolean; knockback: boolean; ko: boolean; win: boolean
cx: number; ground: number
hOff: number; vBounce: number; koSlump: number; globalY: number
bx: number; by: number; bw: number; bh: number
hx: number; hy: number; hw: number; hh: number
armLx: number; armRx: number; armAttach: number; armW: number; armH: number
feetY: number; legsTop: number; legH: number; legW: number; ll: number; rl: number
}
export interface Archetype {
name: string
weight: number
canHaveVisor?: boolean
canHaveMohawk?: boolean
canHaveHorns?: boolean
dimensionOverrides?: (tier: number) => Partial<Dimensions>
drawFeatures: (p: ArchetypeParams) => void
}
@@ -1,52 +1,17 @@
// Pixel-art sprite sheet generator
// 48x48 internal resolution scaled to 96x96 frames
// Many animation states for rich fighting
import { FRAME_SIZE, INTERNAL, SCALE, ANIMATIONS, TOTAL_ROWS, MAX_FRAMES, baseDimensions } from './constants'
import type { Pal, ArchetypeParams } from './constants'
import { makePal } from './palette'
import { rollArchetype, archetypes } from './archetypes'
const FRAME_SIZE = 96
const INTERNAL = 48
const SCALE = FRAME_SIZE / INTERNAL
const ANIMATIONS = {
idle: { frames: 4, row: 0 },
attack: { frames: 6, row: 1 },
kick: { frames: 5, row: 2 },
special: { frames: 6, row: 3 },
hit: { frames: 3, row: 4 },
knockback: { frames: 5, row: 5 },
ko: { frames: 5, row: 6 },
win: { frames: 4, row: 7 },
}
const TOTAL_ROWS = Object.keys(ANIMATIONS).length
const MAX_FRAMES = 6
interface Pal {
body: string; dark: string; light: string
acc: string; accDark: string; accLight: string
out: string; skin: string; skinDark: string
}
function makePal(primary: string, secondary: string, tier: number): Pal {
const [h, s, l] = parseHSL(primary)
const [h2, s2, l2] = parseHSL(secondary)
return {
body: primary,
dark: `hsl(${h}, ${s}%, ${Math.max(0, l - 20)}%)`,
light: `hsl(${h}, ${Math.min(100, s + 5)}%, ${Math.min(95, l + 15)}%)`,
acc: secondary,
accDark: `hsl(${h2}, ${s2}%, ${Math.max(0, l2 - 20)}%)`,
accLight: `hsl(${h2}, ${Math.min(100, s2)}%, ${Math.min(95, l2 + 15)}%)`,
out: '#0a0a0a',
skin: tier <= 1 ? primary : `hsl(${h}, ${Math.max(20, s - 30)}%, ${Math.min(85, l + 25)}%)`,
skinDark: tier <= 1 ? `hsl(${h}, ${s}%, ${Math.max(0, l - 10)}%)` : `hsl(${h}, ${Math.max(15, s - 35)}%, ${Math.min(75, l + 15)}%)`,
}
}
function parseHSL(c: string): [number, number, number] {
const m = c.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
return m ? [+m[1], +m[2], +m[3]] : [200, 70, 50]
}
export { 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 { archetypes, rollArchetype } from './archetypes'
export function generateSpriteSheet(
seed: string, tier: number, primaryColor: string, secondaryColor: string,
archetypeOverride?: string,
): string {
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * MAX_FRAMES
@@ -61,10 +26,25 @@ export function generateSpriteSheet(
const rng = () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 }
rng(); rng(); rng()
const hasVisor = rng() > 0.5 && tier >= 2
const hasMohawk = rng() > 0.5 && tier >= 3
const hasHorns = rng() > 0.6 && tier >= 4 && !hasMohawk
const specialType = rng() > 0.5 ? 'fire' : 'electric' // determines special attack visuals
// Character archetype -- determined by seed for consistent variety, or forced by override
const archetypeRoll = rng()
const arch = archetypeOverride
? (archetypes.find(a => a.name === archetypeOverride) || rollArchetype(archetypeRoll))
: rollArchetype(archetypeRoll)
// Consume rng in same order as original for determinism
const visorRoll = rng()
const mohawkRoll = rng()
const hornsRoll = rng()
const specialRoll = rng()
const hasVisor = visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false)
const hasMohawk = mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true)
const hasHorns = hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true)
const specialType: 'fire' | 'electric' = specialRoll > 0.5 ? 'fire' : 'electric'
// Dimensions: base + archetype overrides
const dims = { ...baseDimensions(tier), ...(arch.dimensionOverrides?.(tier) ?? {}) }
function px(x: number, y: number, color: string, ox: number, oy: number) {
if (x < 0 || x >= INTERNAL || y < 0 || y >= INTERNAL) return
@@ -97,15 +77,7 @@ export function generateSpriteSheet(
const ko = pose === 'ko'
const win = pose === 'win'
// Dimensions scale with tier
const bw = 10 + tier * 2 // body width
const bh = 8 + tier // body height
const hw = 10 + tier // head width
const hh = 9 + tier // head height
const legH = 6 + tier // leg height
const legW = 3 + Math.floor(tier * 0.5)
const armW = 3
const armH = 5 + tier
const { bw, bh, hw, hh, legH, legW, armW, armH } = dims
// Anchor: center bottom at (24, 42) in 48x48
const cx = 24
@@ -121,7 +93,7 @@ export function generateSpriteSheet(
const hOff = hit ? Math.round(t * 3) : knockback ? Math.round(t * 8) : ko ? 2 : 0
const vBounce = idle ? bounce : 0
const koSlump = ko ? Math.round(t * 5) : 0
const kbLift = knockback ? Math.round(Math.sin(t * Math.PI) * 6) : 0 // arc in the air
const kbLift = knockback ? Math.round(Math.sin(t * Math.PI) * 6) : 0
const globalY = -kbLift
// ---- SHADOW ----
@@ -140,17 +112,13 @@ export function generateSpriteSheet(
box(ll - 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), pal.dark, ox, oy)
box(rl + 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), pal.dark, ox, oy)
} else if (kick) {
// Standing leg
box(ll, legsTop + globalY, legW, legH, pal.dark, ox, oy)
// Kicking leg — extends horizontally
const kickExt = Math.round(Math.sin(t * Math.PI) * (legH + tier * 2))
box(rl, legsTop + Math.floor(legH * 0.3) + globalY, kickExt + legW, legW, pal.dark, ox, oy)
// Foot on kick
if (kickExt > 2) {
box(rl + kickExt + legW, legsTop + Math.floor(legH * 0.3) - 1 + globalY, 3 + tier, 3, pal.acc, ox, oy)
}
} else if (knockback) {
// Legs trailing behind in arc
box(ll + Math.round(t * -3), legsTop + globalY + 2, legW, legH - 2, pal.dark, ox, oy)
box(rl + Math.round(t * -2), legsTop + globalY + 3, legW, legH - 3, pal.dark, ox, oy)
} else {
@@ -210,7 +178,6 @@ export function generateSpriteSheet(
const pw = 2 + Math.floor(tier * 0.5)
box(bx - pw - 1, sy, pw + 1, 3, pal.acc, ox, oy)
box(bx + bw, sy, pw + 1, 3, pal.acc, ox, oy)
// Highlight
px(bx - pw, sy, pal.accLight, ox, oy)
px(bx + bw + 1, sy, pal.accLight, ox, oy)
}
@@ -224,20 +191,16 @@ export function generateSpriteSheet(
fill(armLx - 3, armAttach + koSlump + 2, armH, armW, pal.body, ox, oy)
fill(armRx + 2, armAttach + koSlump + 2, armH, armW, pal.body, ox, oy)
} else if (knockback) {
// Arms flailing behind
box(armLx - Math.round(t * 4), armAttach + globalY - 2, armW, armH + 1, pal.body, ox, oy)
box(armRx - Math.round(t * 3), armAttach + globalY - 1, armW, armH, pal.body, ox, oy)
} else if (atk) {
// Guard left arm
box(armLx, armAttach + 2, armW, armH - 2, pal.body, ox, oy)
// Punch right arm
const reach = Math.round(Math.sin(t * Math.PI) * (armH + tier * 2))
if (reach > 0) {
box(armRx, armAttach - 1, reach + armW, armW + 1, pal.body, ox, oy)
const fS = 3 + Math.floor(tier * 0.5)
const fC = tier >= 5 ? '#ffd700' : tier >= 3 ? '#ff3333' : pal.body
box(armRx + reach + armW, armAttach - 2, fS, fS + 1, fC, ox, oy)
// Impact
if (tier >= 2 && t > 0.3 && t < 0.7) {
const ix = armRx + reach + armW + fS + 1
px(ix, armAttach - 2, '#ffff00', ox, oy)
@@ -247,13 +210,11 @@ export function generateSpriteSheet(
px(ix + 2, armAttach + 1, '#ffaa00', ox, oy)
}
}
// Left glove
if (tier >= 3) {
const gs = 3 + Math.floor(tier * 0.3)
box(armLx - 1, armAttach + armH - 1, gs, gs, tier >= 5 ? '#ffd700' : '#ff3333', ox, oy)
}
} else if (kick) {
// Both arms in guard
box(armLx, armAttach, armW, armH - 1, pal.body, ox, oy)
box(armRx, armAttach, armW, armH - 1, pal.body, ox, oy)
if (tier >= 3) {
@@ -263,49 +224,40 @@ export function generateSpriteSheet(
box(armRx, armAttach + armH - 1, gs, gs, gc, ox, oy)
}
} else if (special) {
// Left arm forward, channeling
box(armLx, armAttach, armW, armH, pal.body, ox, oy)
// Right arm extended, casting
const ext = Math.round(Math.sin(t * Math.PI) * (armH + 2))
box(armRx, armAttach - 2, ext + armW + 2, armW, pal.body, ox, oy)
// Projectile effect
if (t > 0.3) {
const projX = armRx + ext + armW + 3 + Math.round(t * 8)
const projY = armAttach - 2
if (specialType === 'fire') {
// Fireball
px(projX, projY, '#ff4400', ox, oy)
px(projX + 1, projY, '#ff6600', ox, oy)
px(projX, projY + 1, '#ff8800', ox, oy)
px(projX + 1, projY + 1, '#ffaa00', ox, oy)
px(projX + 2, projY, '#ffcc00', ox, oy)
px(projX - 1, projY, '#ff2200', ox, oy)
// Trail
px(projX - 2, projY + 1, '#ff440066', ox, oy)
px(projX - 3, projY, '#ff220044', ox, oy)
} else {
// Electric bolt
px(projX, projY, '#00eeff', ox, oy)
px(projX + 1, projY - 1, '#44ffff', ox, oy)
px(projX + 2, projY + 1, '#00eeff', ox, oy)
px(projX + 3, projY, '#88ffff', ox, oy)
px(projX + 1, projY + 1, '#0088ff', ox, oy)
// Sparks
px(projX - 1, projY - 1, '#44ffff', ox, oy)
px(projX + 4, projY - 1, '#ffffff', ox, oy)
}
}
} else if (win) {
box(armLx, armAttach + 2, armW, armH - 1, pal.body, ox, oy)
// Raised arm
box(armRx, armAttach - armH + bounce, armW, armH, pal.body, ox, oy)
if (tier >= 3) {
const gs = 3 + Math.floor(tier * 0.3)
box(armRx - 1, armAttach - armH + bounce - gs, gs + 1, gs, tier >= 5 ? '#ffd700' : '#ff3333', ox, oy)
}
} else {
// Idle
const sw = idle ? bounce : hit ? 1 : 0
box(armLx, armAttach + sw + globalY, armW, armH, pal.body, ox, oy)
box(armRx, armAttach - sw + globalY, armW, armH, pal.body, ox, oy)
@@ -324,7 +276,6 @@ export function generateSpriteSheet(
if (tier <= 1) {
// BOXY ROBOT
box(hx, hy, hw, hh, pal.body, ox, oy)
// Shading
for (let iy = hy + 1; iy < hy + hh - 1; iy++) px(hx + hw - 1, iy, pal.dark, ox, oy)
px(hx + 1, hy + 1, pal.light, ox, oy)
@@ -345,7 +296,6 @@ export function generateSpriteSheet(
} else {
fill(hx + 2, eyeY, 2, 2, '#00ff41', ox, oy)
fill(hx + hw - 4, eyeY, 2, 2, '#00ff41', ox, oy)
// Scanline flicker
if (frame % 2 === 0) {
px(hx + 2, eyeY, '#00cc33', ox, oy)
px(hx + hw - 4, eyeY, '#00cc33', ox, oy)
@@ -365,9 +315,9 @@ export function generateSpriteSheet(
// Claw pincers (tier 0)
if (tier === 0) {
const cy = hy + Math.floor(hh / 2)
px(hx - 2, cy, pal.acc, ox, oy); px(hx - 3, cy - 1, pal.acc, ox, oy); px(hx - 3, cy + 1, pal.acc, ox, oy)
px(hx + hw + 1, cy, pal.acc, ox, oy); px(hx + hw + 2, cy - 1, pal.acc, ox, oy); px(hx + hw + 2, cy + 1, pal.acc, ox, oy)
const cy2 = hy + Math.floor(hh / 2)
px(hx - 2, cy2, pal.acc, ox, oy); px(hx - 3, cy2 - 1, pal.acc, ox, oy); px(hx - 3, cy2 + 1, pal.acc, ox, oy)
px(hx + hw + 1, cy2, pal.acc, ox, oy); px(hx + hw + 2, cy2 - 1, pal.acc, ox, oy); px(hx + hw + 2, cy2 + 1, pal.acc, ox, oy)
}
} else {
// ROUNDED HEAD (tier 2+)
@@ -376,14 +326,13 @@ export function generateSpriteSheet(
px(hx, iy, pal.body, ox, oy); px(hx + hw - 1, iy, pal.body, ox, oy)
px(hx - 1, iy, pal.out, ox, oy); px(hx + hw, iy, pal.out, ox, oy)
}
// Shading
for (let iy = hy + 2; iy < hy + hh - 2; iy++) {
px(hx + hw - 1, iy, pal.dark, ox, oy)
px(hx + hw - 2, iy, pal.dark, ox, oy)
}
px(hx + 2, hy + 1, pal.light, ox, oy); px(hx + 3, hy + 1, pal.light, ox, oy)
// Face area (lighter "skin" for tier 2+)
// Face area
if (tier >= 2) {
const faceTop = hy + Math.floor(hh * 0.25)
const faceBot = hy + Math.floor(hh * 0.75)
@@ -407,7 +356,6 @@ export function generateSpriteSheet(
px(reX, eyeY, '#ff0000', ox, oy); px(reX + 1, eyeY + 1, '#ff0000', ox, oy)
px(reX + 1, eyeY, '#880000', ox, oy); px(reX, eyeY + 1, '#880000', ox, oy)
} else if (knockback) {
// Wide shock eyes
fill(leX - 1, eyeY, ew + 2, 3, '#ffffff', ox, oy)
fill(reX - 1, eyeY, ew + 2, 3, '#ffffff', ox, oy)
px(leX, eyeY + 1, '#000000', ox, oy)
@@ -419,7 +367,6 @@ export function generateSpriteSheet(
px(leX + ps, eyeY + 1, '#000000', ox, oy)
px(reX + ps, eyeY + 1, '#000000', ox, oy)
// Eye glow (tier 4+)
if (tier >= 4) {
px(leX, eyeY, pal.acc, ox, oy)
px(reX + ew - 1, eyeY, pal.acc, ox, oy)
@@ -429,7 +376,6 @@ export function generateSpriteSheet(
}
}
// Angry brows when attacking
if (atk || kick || special) {
px(leX, eyeY - 1, pal.out, ox, oy); px(leX + 1, eyeY - 1, pal.out, ox, oy)
px(reX, eyeY - 1, pal.out, ox, oy); px(reX + 1, eyeY - 1, pal.out, ox, oy)
@@ -439,7 +385,6 @@ export function generateSpriteSheet(
// Mouth
const mY = hy + Math.floor(hh * 0.65)
if (win) {
// Big grin
px(cx + hOff - 2, mY, pal.out, ox, oy)
fill(cx + hOff - 1, mY, 3, 1, '#ffffff', ox, oy)
px(cx + hOff + 2, mY, pal.out, ox, oy)
@@ -447,13 +392,11 @@ export function generateSpriteSheet(
px(cx + hOff, mY + 1, pal.out, ox, oy)
px(cx + hOff + 1, mY + 1, pal.out, ox, oy)
} else if (ko || knockback) {
// Open mouth shock
box(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy)
} else if (hit) {
px(cx + hOff, mY, pal.out, ox, oy)
px(cx + hOff + 1, mY, pal.out, ox, oy)
} else if (atk || kick || special) {
// Battle yell
fill(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy)
px(cx + hOff - 1, mY, pal.out, ox, oy)
px(cx + hOff + 1, mY, pal.out, ox, oy)
@@ -466,7 +409,7 @@ export function generateSpriteSheet(
if (hasVisor) {
const vY = eyeY - 1
for (let vx = hx + 1; vx < hx + hw - 1; vx++) px(vx, vY, pal.accDark, ox, oy)
px(hx + 1, vY, pal.accLight, ox, oy) // highlight
px(hx + 1, vY, pal.accLight, ox, oy)
}
// Headband (tier 4+)
@@ -507,6 +450,17 @@ export function generateSpriteSheet(
}
}
// ---- ARCHETYPE-SPECIFIC FEATURES ----
const archParams: ArchetypeParams = {
px, box, fill, pal, ox, oy, t, frame, bounce, tier, specialType,
idle, atk, kick, special, hit, knockback, ko, win,
cx, ground, hOff, vBounce, koSlump, globalY,
bx, by, bw, bh, hx, hy, hw, hh,
armLx, armRx, armAttach, armW, armH,
feetY, legsTop, legH, legW, ll, rl,
}
arch.drawFeatures(archParams)
// ---- AURA (tier 4+) ----
if (tier >= 4 && !ko) {
const aCx = cx + hOff
@@ -522,9 +476,9 @@ export function generateSpriteSheet(
if (tier >= 5) {
for (let p = 0; p < 4; p++) {
const pt = (t + p * 0.25) % 1
const py = ground - Math.round(pt * (ground - hy + 4))
const ppx = aCx + Math.round(Math.sin(py * 0.4 + p) * 3)
px(ppx, py, p % 2 === 0 ? pal.acc : '#ffd700', ox, oy)
const py2 = ground - Math.round(pt * (ground - hy + 4))
const ppx = aCx + Math.round(Math.sin(py2 * 0.4 + p) * 3)
px(ppx, py2, p % 2 === 0 ? pal.acc : '#ffd700', ox, oy)
}
}
}
@@ -572,15 +526,3 @@ export function generateSpriteSheet(
return canvas.toDataURL()
}
export function getBotColors(seed: string): { primary: string; secondary: string } {
let h = 0
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
const hue = Math.abs(h % 360)
return {
primary: `hsl(${hue}, 70%, 50%)`,
secondary: `hsl(${(hue + 140) % 360}, 80%, 60%)`,
}
}
export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS }
+174
View File
@@ -0,0 +1,174 @@
import { FRAME_SIZE, INTERNAL, SCALE } from './constants'
export const JUDGE_ANIMATIONS = {
idle: { frames: 4, row: 0 },
call_left: { frames: 4, row: 1 },
call_right: { frames: 4, row: 2 },
shocked: { frames: 4, row: 3 },
}
export const JUDGE_ROWS = Object.keys(JUDGE_ANIMATIONS).length
export const JUDGE_MAX_FRAMES = 6
export function generateJudgeSpriteSheet(): string {
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * JUDGE_MAX_FRAMES
canvas.height = FRAME_SIZE * JUDGE_ROWS
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
const red = '#cc2222'
const dkRed = '#881111'
const ltRed = '#ee4444'
const out = '#0a0a0a'
const stripeB = '#111111'
const stripeW = '#eeeeee'
const cardGreen = '#22cc44'
function px(x: number, y: number, color: string, ox: number, oy: number) {
if (x < 0 || x >= INTERNAL || y < 0 || y >= INTERNAL) return
ctx.fillStyle = color
ctx.fillRect(ox + x * SCALE, oy + y * SCALE, SCALE, SCALE)
}
function box(x: number, y: number, w: number, h: number, 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) }
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, fc, ox, oy)
}
function fill(x: number, y: number, w: number, h: number, c: string, ox: number, oy: number) {
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, c, ox, oy)
}
function drawJudge(fx: number, fy: number, pose: string, frame: number, total: number) {
const ox = fx * FRAME_SIZE
const oy = fy * FRAME_SIZE
const t = frame / Math.max(1, total - 1)
const bounce = Math.round(Math.sin(t * Math.PI * 2))
const cx = 24
const baseY = 40
const bodyW = 14
const bodyH = 10
const headW = 12
const headH = 7
const idle = pose === 'idle'
const callL = pose === 'call_left'
const callR = pose === 'call_right'
const shocked = pose === 'shocked'
const yOff = idle ? bounce : shocked ? -Math.round(Math.abs(Math.sin(t * Math.PI)) * 3) : 0
const bodyTop = baseY - 16 + yOff
const bx = cx - Math.floor(bodyW / 2)
const headTop = bodyTop - headH - 1
const hx = cx - Math.floor(headW / 2)
// === TAIL SEGMENTS ===
for (let s = 0; s < 3; s++) {
const sw = bodyW - 2 - s * 2
const sx = cx - Math.floor(sw / 2)
const sy = bodyTop + bodyH + s * 2
fill(sx, sy, sw, 2, s % 2 === 0 ? red : dkRed, ox, oy)
px(sx - 1, sy, out, ox, oy); px(sx + sw, sy, out, ox, oy)
px(sx - 1, sy + 1, out, ox, oy); px(sx + sw, sy + 1, out, ox, oy)
}
// Tail fan
const fanY = bodyTop + bodyH + 6
for (let f = -3; f <= 3; f++) px(cx + f, fanY, Math.abs(f) > 2 ? dkRed : red, ox, oy)
px(cx - 3, fanY + 1, dkRed, ox, oy); px(cx + 3, fanY + 1, dkRed, ox, oy)
// === BODY (referee striped shirt) ===
for (let i = bx - 1; i <= bx + bodyW; i++) { px(i, bodyTop - 1, out, ox, oy); px(i, bodyTop + bodyH, out, ox, oy) }
for (let i = bodyTop; i < bodyTop + bodyH; i++) { px(bx - 1, i, out, ox, oy); px(bx + bodyW, i, out, ox, oy) }
for (let iy = bodyTop; iy < bodyTop + bodyH; iy++) {
for (let ix = bx; ix < bx + bodyW; ix++) {
px(ix, iy, Math.floor((ix - bx) / 2) % 2 === 0 ? stripeB : stripeW, ox, oy)
}
}
// V-neck showing red body
px(cx - 1, bodyTop, red, ox, oy); px(cx, bodyTop, red, ox, oy); px(cx + 1, bodyTop, red, ox, oy)
px(cx, bodyTop + 1, red, ox, oy)
// Whistle
px(cx + 2, bodyTop + 2, '#aaaaaa', ox, oy)
px(cx + 3, bodyTop + 3, '#888888', ox, oy)
// === HEAD ===
box(hx, headTop, headW, headH, red, ox, oy)
for (let iy = headTop + 1; iy < headTop + headH - 1; iy++) {
px(hx + headW - 1, iy, dkRed, ox, oy)
px(hx + 1, iy, ltRed, ox, oy)
}
// Mouth
if (shocked) {
fill(cx - 1, headTop + headH - 2, 3, 2, '#000000', ox, oy)
} else {
px(cx - 1, headTop + headH - 2, out, ox, oy); px(cx, headTop + headH - 2, out, ox, oy)
}
// === EYE STALKS ===
const eyeExt = shocked ? 3 : 1
// Left stalk + eye
px(hx + 2, headTop - 1, dkRed, ox, oy); px(hx + 1, headTop - 2, dkRed, ox, oy)
for (let e = 0; e < eyeExt; e++) px(hx, headTop - 3 - e, dkRed, ox, oy)
fill(hx - 2, headTop - 3 - eyeExt, 3, 2, '#ffffff', ox, oy)
px(hx - 1, headTop - 2 - eyeExt, '#000000', ox, oy)
// Right stalk + eye
px(hx + headW - 3, headTop - 1, dkRed, ox, oy); px(hx + headW - 2, headTop - 2, dkRed, ox, oy)
for (let e = 0; e < eyeExt; e++) px(hx + headW - 1, headTop - 3 - e, dkRed, ox, oy)
fill(hx + headW - 1, headTop - 3 - eyeExt, 3, 2, '#ffffff', ox, oy)
px(hx + headW, headTop - 2 - eyeExt, '#000000', ox, oy)
// === ANTENNAE ===
const antBase = headTop - 3 - eyeExt
for (let a = 1; a <= 5; a++) {
const wobble = idle ? Math.round(Math.sin(t * Math.PI * 2 + a * 0.8)) : 0
px(hx - 1, antBase - a + wobble, red, ox, oy)
px(hx + headW, antBase - a - wobble, red, ox, oy)
}
px(hx - 2, antBase - 5, ltRed, ox, oy); px(hx + headW + 1, antBase - 5, ltRed, ox, oy)
// === LEFT CLAW ===
const leftRaised = callL || shocked
if (leftRaised) {
const armTopY = bodyTop - 8 - Math.round(Math.abs(Math.sin(t * Math.PI)) * 2)
fill(bx - 3, armTopY, 2, bodyTop + 3 - armTopY, red, ox, oy)
for (let c = 0; c < 5; c++) px(bx - 5 - c, armTopY, red, ox, oy)
for (let c = 0; c < 5; c++) px(bx - 5 - c, armTopY + 2, red, ox, oy)
px(bx - 5, armTopY + 1, dkRed, ox, oy)
if (callL) box(bx - 12, armTopY - 4, 4, 6, cardGreen, ox, oy)
} else {
const cly = bodyTop + 5 + (idle ? bounce : 0)
fill(bx - 3, bodyTop + 2, 2, cly - bodyTop - 2, red, ox, oy)
for (let c = 0; c < 4; c++) px(bx - 5 - c, cly, red, ox, oy)
for (let c = 0; c < 4; c++) px(bx - 5 - c, cly + 2, red, ox, oy)
px(bx - 5, cly + 1, dkRed, ox, oy)
}
// === RIGHT CLAW (mirror) ===
const rightRaised = callR || shocked
if (rightRaised) {
const armTopY = bodyTop - 8 - Math.round(Math.abs(Math.sin(t * Math.PI)) * 2)
fill(bx + bodyW + 1, armTopY, 2, bodyTop + 3 - armTopY, red, ox, oy)
for (let c = 0; c < 5; c++) px(bx + bodyW + 4 + c, armTopY, red, ox, oy)
for (let c = 0; c < 5; c++) px(bx + bodyW + 4 + c, armTopY + 2, red, ox, oy)
px(bx + bodyW + 4, armTopY + 1, dkRed, ox, oy)
if (callR) box(bx + bodyW + 7, armTopY - 4, 4, 6, cardGreen, ox, oy)
} else {
const cry = bodyTop + 5 + (idle ? -bounce : 0)
fill(bx + bodyW + 1, bodyTop + 2, 2, cry - bodyTop - 2, red, ox, oy)
for (let c = 0; c < 4; c++) px(bx + bodyW + 4 + c, cry, red, ox, oy)
for (let c = 0; c < 4; c++) px(bx + bodyW + 4 + c, cry + 2, red, ox, oy)
px(bx + bodyW + 4, cry + 1, dkRed, ox, oy)
}
}
const entries = Object.entries(JUDGE_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++) drawJudge(f, row, pose, f, cfg.frames)
for (let f = cfg.frames; f < JUDGE_MAX_FRAMES; f++) drawJudge(f, row, pose, cfg.frames - 1, cfg.frames)
}
return canvas.toDataURL()
}
+32
View File
@@ -0,0 +1,32 @@
import type { Pal } from './constants'
export function parseHSL(c: string): [number, number, number] {
const m = c.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
return m ? [+m[1], +m[2], +m[3]] : [200, 70, 50]
}
export function makePal(primary: string, secondary: string, tier: number): Pal {
const [h, s, l] = parseHSL(primary)
const [h2, s2, l2] = parseHSL(secondary)
return {
body: primary,
dark: `hsl(${h}, ${s}%, ${Math.max(0, l - 20)}%)`,
light: `hsl(${h}, ${Math.min(100, s + 5)}%, ${Math.min(95, l + 15)}%)`,
acc: secondary,
accDark: `hsl(${h2}, ${s2}%, ${Math.max(0, l2 - 20)}%)`,
accLight: `hsl(${h2}, ${Math.min(100, s2)}%, ${Math.min(95, l2 + 15)}%)`,
out: '#0a0a0a',
skin: tier <= 1 ? primary : `hsl(${h}, ${Math.max(20, s - 30)}%, ${Math.min(85, l + 25)}%)`,
skinDark: tier <= 1 ? `hsl(${h}, ${s}%, ${Math.max(0, l - 10)}%)` : `hsl(${h}, ${Math.max(15, s - 35)}%, ${Math.min(75, l + 15)}%)`,
}
}
export function getBotColors(seed: string): { primary: string; secondary: string } {
let h = 0
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
const hue = Math.abs(h % 360)
return {
primary: `hsl(${hue}, 70%, 50%)`,
secondary: `hsl(${(hue + 140) % 360}, 80%, 60%)`,
}
}
+46 -13
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
const router = useRouter()
interface FightResult {
id: string
@@ -29,16 +31,33 @@ onMounted(async () => {
isLoading.value = false
})
async function triggerMockFight() {
const isMocking = ref(false)
const bots = ref<{ id: string; name: string; tier: number }[]>([])
const selectedBotId = ref('')
onMounted(async () => {
try {
const res = await fetch('/api/fights/mock', { method: 'POST' })
const botRes = await fetch('/api/bots')
if (botRes.ok) bots.value = await botRes.json()
} catch { /* */ }
})
async function triggerFight() {
if (isMocking.value) return
isMocking.value = true
try {
// If a specific bot is selected, use matchmaking (instant real fight)
// Otherwise, trigger a random mock fight
const url = selectedBotId.value
? `/api/fights/matchmake/${selectedBotId.value}`
: '/api/fights/mock'
const res = await fetch(url, { method: 'POST' })
if (res.ok) {
const data = await res.json()
// Refresh fights list
const listRes = await fetch('/api/fights')
if (listRes.ok) fights.value = await listRes.json()
router.push(`/arena/${data.fightId}`)
}
} catch { /* */ }
isMocking.value = false
}
const tierClass = (t: number) => `tier-${t}`
@@ -53,13 +72,27 @@ const tierClass = (t: number) => `tier-${t}`
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider">
<span class="text-neon-pink glow-pink">THE ARENA</span>
</h2>
<button
class="px-4 py-2 border border-neon-purple/40 text-neon-purple font-display font-bold text-[10px]
tracking-wider hover:bg-neon-purple/10 transition-all"
@click="triggerMockFight"
>
MOCK FIGHT
</button>
<div class="flex items-center gap-2">
<select
v-model="selectedBotId"
class="bg-surface border border-border text-text-primary font-mono text-[10px]
px-2 py-2 focus:outline-none focus:border-neon-cyan/50"
>
<option value="">Random vs Random</option>
<option v-for="bot in bots" :key="bot.id" :value="bot.id">
{{ bot.name }}
</option>
</select>
<button
class="px-4 py-2 border border-neon-purple/40 text-neon-purple font-display font-bold text-[10px]
tracking-wider hover:bg-neon-purple/10 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isMocking"
@click="triggerFight"
>
{{ isMocking ? 'MATCHING...' : selectedBotId ? 'FIGHT NOW' : 'MOCK FIGHT' }}
</button>
</div>
</div>
<!-- Fight cards -->
+210 -86
View File
@@ -1,148 +1,272 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useNostr } from '../composables/useNostr'
interface Bot {
const route = useRoute()
const router = useRouter()
const { bot: nostrBot, isLoggedIn, logout } = useNostr()
const botName = route.params.name as string
interface BotStats {
id: string
name: string
avatarSeed: string
profilePicUrl: string | null
eloRating: number
wins: number
losses: number
winStreak: number
bestStreak: number
tier: number
isActive: boolean
tierName: string
tierColor: string
winRate: number
totalFights: number
rank: number
totalBots: number
createdAt: string
recentFights: {
id: string
opponent: string
result: string
rounds: number
arena: string
date: string
}[]
}
interface Fight {
id: string
botA: { name: string } | null
botB: { name: string } | null
winner: { name: string } | null
arenaInfo: { name: string } | null
totalRounds: number
status: string
interface QueueEntry {
botId: string
botName: string
eloRating: number
}
const route = useRoute()
const botName = route.params.name as string
const bot = ref<Bot | null>(null)
const fights = ref<Fight[]>([])
const stats = ref<BotStats | null>(null)
const isLoading = ref(true)
const isJoining = ref(false)
const showChoose = ref(false)
const waitingFighters = ref<QueueEntry[]>([])
let pollHandle: ReturnType<typeof setInterval> | null = null
const isOwner = ref(false)
onMounted(async () => {
try {
const [botRes, fightsRes] = await Promise.all([
fetch(`/api/bots/${botName}`),
fetch('/api/fights'),
])
if (botRes.ok) bot.value = await botRes.json()
if (fightsRes.ok) {
const allFights = await fightsRes.json()
fights.value = allFights.filter((f: Fight) =>
f.botA?.name === botName || f.botB?.name === botName
)
}
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats`)
if (res.ok) stats.value = await res.json()
} catch { /* */ }
isLoading.value = false
// Check ownership
isOwner.value = isLoggedIn.value && nostrBot.value?.name === botName
// Poll queue for "choose your fight"
pollQueue()
pollHandle = setInterval(pollQueue, 4000)
})
const tierName = (t: number) => ['UNRANKED', 'ROOKIE', 'RISING', 'CONTENDER', 'CHAMPION', 'LEGEND'][t] || '???'
const tierClass = (t: number) => `tier-${t}`
const winRate = (b: Bot) => {
const total = b.wins + b.losses
return total > 0 ? Math.round((b.wins / total) * 100) : 0
onUnmounted(() => {
if (pollHandle) clearInterval(pollHandle)
})
async function pollQueue() {
try {
const res = await fetch('/api/queue/status')
if (res.ok) {
const data = await res.json()
waitingFighters.value = data.queue || []
}
} catch { /* */ }
}
async function instantFight() {
if (!stats.value || isJoining.value) return
isJoining.value = true
try {
const res = await fetch(`/api/queue/join/${stats.value.id}`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
router.push(`/arena/${data.fightId}`)
}
} catch { /* */ }
isJoining.value = false
}
async function fightSpecific(opponentBotId: string) {
if (!stats.value || isJoining.value) return
isJoining.value = true
try {
const res = await fetch(`/api/fights/matchmake/${stats.value.id}`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
router.push(`/arena/${data.fightId}`)
}
} catch { /* */ }
isJoining.value = false
}
function handleSignOut() {
logout()
router.push('/')
}
const tierClass = (t: number) => `tier-${t}`
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
<div class="max-w-3xl mx-auto w-full flex flex-col flex-1 min-h-0">
<div class="max-w-lg mx-auto w-full flex flex-col flex-1 min-h-0">
<div v-if="isLoading" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted animate-pulse">LOADING...</p>
</div>
<div v-else-if="!bot" class="flex-1 flex items-center justify-center">
<div v-else-if="!stats" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted">Bot not found.</p>
</div>
<template v-else>
<!-- Bot header -->
<div class="mb-6 text-center">
<p class="font-display text-[10px] font-bold tracking-[0.2em] mb-2"
:class="tierClass(bot.tier)">
{{ tierName(bot.tier) }}
<!-- Header -->
<div class="text-center mb-5">
<img
v-if="stats.profilePicUrl"
:src="stats.profilePicUrl"
alt=""
class="w-16 h-16 rounded-full mx-auto mb-2 border-2"
:style="{ borderColor: stats.tierColor }"
/>
<p class="font-display text-xs font-bold tracking-[0.2em] mb-1"
:style="{ color: stats.tierColor }">
{{ stats.tierName }}
</p>
<h2 class="font-display font-black text-3xl sm:text-5xl tracking-wider gradient-text mb-2">
{{ bot.name }}
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
{{ stats.name }}
</h2>
<p class="font-mono text-text-muted text-xs">
Fighting since {{ new Date(bot.createdAt).toLocaleDateString() }}
<p class="font-mono text-text-muted text-[10px] mt-1">
#{{ stats.rank }} of {{ stats.totalBots }}
</p>
</div>
<!-- Tale of the Tape -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center neon-border-cyan">
<p class="font-display font-black text-2xl text-neon-cyan">{{ Math.round(bot.eloRating) }}</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">ELO</p>
<!-- Stats -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-black text-xl text-neon-cyan">{{ Math.round(stats.eloRating) }}</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">ELO</p>
</div>
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center">
<p class="font-display font-black text-2xl text-text-primary">
<span class="text-neon-cyan">{{ bot.wins }}</span>
<span class="text-text-muted text-lg mx-1">-</span>
<span class="text-neon-pink">{{ bot.losses }}</span>
<div class="border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-black text-xl">
<span class="text-neon-cyan">{{ stats.wins }}</span>
<span class="text-text-muted text-sm">-</span>
<span class="text-neon-pink">{{ stats.losses }}</span>
</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">RECORD</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">RECORD</p>
</div>
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center">
<p class="font-display font-black text-2xl"
:class="winRate(bot) >= 60 ? 'text-neon-cyan' : winRate(bot) >= 40 ? 'text-neon-yellow' : 'text-neon-pink'">
{{ winRate(bot) }}%
<div class="border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-black text-xl"
:class="stats.winRate >= 60 ? 'text-neon-cyan' : stats.winRate >= 40 ? 'text-neon-yellow' : 'text-neon-pink'">
{{ stats.winRate }}%
</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">WIN RATE</p>
</div>
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center"
:class="bot.winStreak >= 3 ? 'neon-border-pink' : ''">
<p class="font-display font-black text-2xl"
:class="bot.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-primary'">
{{ bot.bestStreak }}
</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">BEST STREAK</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">WIN RATE</p>
</div>
</div>
<!-- Fight history -->
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-3">
FIGHT HISTORY
<!-- Streaks row -->
<div class="flex gap-2 mb-4">
<div class="flex-1 border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-bold text-base"
:class="stats.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-primary'">
{{ stats.winStreak > 0 ? `${stats.winStreak}x` : '-' }}
</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">STREAK</p>
</div>
<div class="flex-1 border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-bold text-base text-text-primary">{{ stats.bestStreak }}x</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">BEST</p>
</div>
<div class="flex-1 border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-bold text-base text-text-primary">{{ stats.totalFights }}</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">FIGHTS</p>
</div>
</div>
<!-- Fight actions (only for owner or anyone for now) -->
<div class="flex gap-2 mb-4">
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-black text-sm tracking-wider
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isJoining"
@click="instantFight"
>
{{ isJoining ? 'MATCHING...' : 'INSTANT FIGHT' }}
</button>
<button
class="flex-1 py-3 border-2 border-neon-purple/50 text-neon-purple
font-display font-bold text-sm tracking-wider
hover:bg-neon-purple/10 transition-all"
@click="showChoose = !showChoose"
>
CHOOSE FIGHT
</button>
</div>
<!-- Choose your fight panel -->
<div v-if="showChoose" class="mb-4 border border-border bg-surface-raised/50 p-3">
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-2">
FIGHTERS WAITING
</p>
<div v-if="waitingFighters.length === 0" class="text-center py-3">
<p class="font-mono text-xs text-text-muted">Nobody waiting. Use Instant Fight instead.</p>
</div>
<button
v-for="fighter in waitingFighters"
:key="fighter.botId"
class="w-full flex items-center justify-between px-3 py-2 border border-border
hover:border-neon-cyan/30 hover:bg-neon-cyan/5 transition-all mb-1 text-xs
disabled:opacity-30"
:disabled="isJoining || fighter.botId === stats.id"
@click="fightSpecific(fighter.botId)"
>
<span class="font-display font-bold text-text-primary">{{ fighter.botName }}</span>
<span class="font-mono text-text-muted">{{ Math.round(fighter.eloRating) }} ELO</span>
</button>
</div>
<!-- Recent fights -->
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-2">
RECENT BOUTS
</p>
<div class="flex-1 min-h-0 overflow-y-auto space-y-2">
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5">
<RouterLink
v-for="fight in fights"
v-for="fight in stats.recentFights"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="flex items-center justify-between px-4 py-2.5 border border-border rounded-lg
bg-surface-raised/30 hover:border-neon-pink/30 transition-all text-sm"
class="flex items-center justify-between px-3 py-2 border border-border
bg-surface-raised/30 hover:border-neon-pink/30 transition-all text-xs"
>
<span class="font-display font-bold text-xs tracking-wide">
<span :class="fight.winner?.name === botName ? 'text-neon-cyan' : 'text-neon-pink'">
{{ fight.winner?.name === botName ? 'W' : 'L' }}
</span>
</span>
<span class="font-mono text-text-secondary text-xs">
vs {{ fight.botA?.name === botName ? fight.botB?.name : fight.botA?.name }}
</span>
<span class="font-mono text-[10px] text-text-muted">
R{{ fight.totalRounds }} &middot; {{ fight.arenaInfo?.name }}
<span class="font-display font-bold w-6"
:class="fight.result === 'W' ? 'text-neon-cyan' : fight.result === 'L' ? 'text-neon-pink' : 'text-text-muted'">
{{ fight.result }}
</span>
<span class="font-mono text-text-secondary flex-1 ml-2">vs {{ fight.opponent }}</span>
<span class="font-mono text-[10px] text-text-muted">R{{ fight.rounds }}</span>
</RouterLink>
<div v-if="fights.length === 0" class="text-center py-8">
<p class="font-display text-text-muted text-xs">No fights yet.</p>
<div v-if="stats.recentFights.length === 0" class="text-center py-4">
<p class="font-mono text-text-muted text-xs">No fights yet. Hit Instant Fight!</p>
</div>
</div>
<!-- Sign out (only if owner) -->
<div v-if="isOwner" class="mt-3 text-center flex-shrink-0">
<button
class="font-mono text-[10px] text-text-muted hover:text-ko transition-colors"
@click="handleSignOut"
>
Sign out
</button>
</div>
</template>
</div>
</div>
+97 -6
View File
@@ -1,32 +1,123 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import FightViewer from '../components/FightViewer.vue'
import { useNostr } from '../composables/useNostr'
const route = useRoute()
const router = useRouter()
const { bot: myBot, isLoggedIn } = useNostr()
const fightId = route.params.fightId as string
const fight = ref<any>(null)
const isLoading = ref(true)
const isRequeueing = ref(false)
const isLive = ref(false)
const liveRounds = ref(0)
let pollHandle: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
async function loadFight(): Promise<string | null> {
try {
const res = await fetch(`/api/fights/${fightId}`)
if (res.ok) fight.value = await res.json()
if (res.ok) {
const data = await res.json()
liveRounds.value = data.rounds?.length || 0
if (data.status === 'finished') {
fight.value = data
}
return data.status
}
} catch { /* */ }
return null
}
onMounted(async () => {
const status = await loadFight()
isLoading.value = false
if (status !== 'finished') {
isLive.value = true
pollHandle = setInterval(async () => {
const s = await loadFight()
if (s === 'finished') {
isLive.value = false
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
}, 1500)
}
})
onUnmounted(() => {
if (pollHandle) clearInterval(pollHandle)
})
async function fightAgain(botId: string) {
if (isRequeueing.value) return
isRequeueing.value = true
try {
const res = await fetch(`/api/queue/join/${botId}`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
router.push(`/arena/${data.fightId}`)
}
} catch { /* */ }
isRequeueing.value = false
}
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-3 py-3 overflow-hidden">
<div class="h-[calc(100vh-4rem)] flex flex-col px-2 sm:px-3 py-2 sm:py-3 overflow-hidden">
<div v-if="isLoading" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p>
</div>
<div v-else-if="isLive" class="flex-1 flex flex-col items-center justify-center gap-4">
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
<p class="font-mono text-text-muted text-xs">
Round {{ liveRounds }} webhooks being called...
</p>
</div>
<div v-else-if="!fight" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted">Fight not found.</p>
</div>
<FightViewer v-else :fight="fight" class="flex-1 min-h-0" />
<template v-else>
<FightViewer :fight="fight" :autoplay="true" class="flex-1 min-h-0" />
<!-- Big post-fight action bar -->
<div v-if="fight.status === 'finished'" class="flex-shrink-0 pt-2 sm:pt-3">
<div class="flex gap-2">
<button
v-if="fight.botA && isLoggedIn && myBot?.id === fight.botA.id"
class="flex-1 py-3 sm:py-4 bg-neon-cyan/5 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm sm:text-base tracking-widest
hover:bg-neon-cyan/15 hover:border-neon-cyan transition-all neon-border-cyan
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isRequeueing"
@click="fightAgain(fight.botA.id)"
>
{{ isRequeueing ? 'MATCHING...' : `FIGHT AGAIN` }}
<span class="block font-mono text-[9px] sm:text-[10px] tracking-wider text-neon-cyan/60 mt-0.5">
AS {{ fight.botA.name.toUpperCase() }}
</span>
</button>
<button
v-if="fight.botB && isLoggedIn && myBot?.id === fight.botB.id"
class="flex-1 py-3 sm:py-4 bg-neon-pink/5 border-2 border-neon-pink/50 text-neon-pink
font-display font-black text-sm sm:text-base tracking-widest
hover:bg-neon-pink/15 hover:border-neon-pink transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isRequeueing"
@click="fightAgain(fight.botB.id)"
>
{{ isRequeueing ? 'MATCHING...' : `FIGHT AGAIN` }}
<span class="block font-mono text-[9px] sm:text-[10px] tracking-wider text-neon-pink/60 mt-0.5">
AS {{ fight.botB.name.toUpperCase() }}
</span>
</button>
</div>
</div>
</template>
</div>
</template>
+10 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import PixelGlove from '../components/PixelGlove.vue'
interface FightResult {
id: string
@@ -43,8 +44,12 @@ onMounted(async () => {
<!-- BIG NEON TITLE -->
<div class="mb-6">
<h1 class="font-neon text-neon-pink text-5xl sm:text-7xl md:text-8xl glow-pink neon-flicker leading-tight">
<h1 class="font-neon text-neon-pink text-5xl sm:text-7xl md:text-8xl glow-pink neon-flicker leading-tight flex items-center justify-center gap-3 sm:gap-5 md:gap-6">
<PixelGlove :size="40" class="hidden sm:block md:!w-[56px] shrink-0" />
<PixelGlove :size="28" class="sm:hidden shrink-0" />
BOTFIGHTS
<PixelGlove :size="28" flip class="sm:hidden shrink-0" />
<PixelGlove :size="40" flip class="hidden sm:block md:!w-[56px] shrink-0" />
</h1>
</div>
@@ -73,20 +78,20 @@ onMounted(async () => {
<!-- CTAs -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-5 mb-10">
<RouterLink
to="/arena"
to="/join"
class="px-10 py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-base tracking-widest
hover:bg-neon-pink/20 transition-all neon-border-pink"
>
WATCH FIGHTS
JOIN A BOUT
</RouterLink>
<RouterLink
to="/register"
to="/arena"
class="px-10 py-4 border-2 border-neon-cyan/40 text-neon-cyan
font-display font-black text-base tracking-widest
hover:border-neon-cyan hover:bg-neon-cyan/10 transition-all"
>
ENTER THE RING
WATCH FIGHTS
</RouterLink>
</div>
+383
View File
@@ -0,0 +1,383 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import SpritePreview from '../components/SpritePreview.vue'
const router = useRouter()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, logout } = useNostr()
// Steps: 'login' | 'pick-character' | 'name-bot' | 'add-webhook' | 'ready'
const step = ref<string>('login')
const error = ref('')
const isJoining = ref(false)
const queueCount = ref(0)
let pollHandle: ReturnType<typeof setInterval> | null = null
// Registration form
const selectedArchetype = ref('standard')
const botName = ref('')
const webhookUrl = ref('')
const archetypeList = [
{ id: 'standard', label: 'FIGHTER', desc: 'Classic brawler' },
{ id: 'lobster', label: 'LOBSTER', desc: 'Pinchy menace' },
{ id: 'sheep', label: 'SHEEP', desc: 'Fluffy fury' },
{ id: 'cyborg', label: 'CYBORG', desc: 'Half machine' },
{ id: 'blob', label: 'BLOB', desc: 'Amorphous chaos' },
{ id: 'tank', label: 'TANK', desc: 'Heavy hitter' },
{ id: 'dog', label: 'DOG', desc: 'Good boy gone bad' },
{ id: 'cat', label: 'CAT', desc: 'Feline fighter' },
{ id: 'cactus', label: 'CACTUS', desc: 'Prickly problem' },
{ id: 'pizza', label: 'PIZZA', desc: 'Cheesy champion' },
{ id: 'shark', label: 'SHARK', desc: 'Apex predator' },
{ id: 'octopus', label: 'OCTOPUS', desc: '8-armed assault' },
{ id: 'skeleton', label: 'SKELETON', desc: 'Bare bones' },
{ id: 'ghost', label: 'GHOST', desc: 'Spooky specter' },
{ id: 'alien', label: 'ALIEN', desc: 'Out of this world' },
{ id: 'dinosaur', label: 'DINOSAUR', desc: 'Prehistoric power' },
{ id: 'pirate', label: 'PIRATE', desc: 'Arr matey' },
{ id: 'ninja', label: 'NINJA', desc: 'Silent strike' },
{ id: 'cowboy', label: 'COWBOY', desc: 'Quick draw' },
{ id: 'wizard', label: 'WIZARD', desc: 'Magic missile' },
{ id: 'bee', label: 'BEE', desc: 'Buzz kill' },
{ id: 'frog', label: 'FROG', desc: 'Ribbit wrecking' },
{ id: 'penguin', label: 'PENGUIN', desc: 'Cold blooded' },
{ id: 'mushroom', label: 'MUSHROOM', desc: 'Toxic spores' },
{ id: 'snail', label: 'SNAIL', desc: 'Slow and steady' },
]
onMounted(() => {
// If already logged in with a bot, go straight to ready
if (isLoggedIn.value) {
step.value = 'ready'
}
pollQueue()
pollHandle = setInterval(pollQueue, 3000)
})
onUnmounted(() => {
if (pollHandle) clearInterval(pollHandle)
})
async function pollQueue() {
try {
const res = await fetch('/api/queue/status')
if (res.ok) {
const data = await res.json()
queueCount.value = data.waiting
}
} catch { /* */ }
}
async function handleLogin() {
error.value = ''
try {
const result = await login()
if (result.bot) {
step.value = 'ready'
} else {
step.value = 'pick-character'
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed.'
}
}
function pickCharacter(id: string) {
selectedArchetype.value = id
step.value = 'name-bot'
}
function confirmName() {
const name = botName.value.trim()
if (!name || name.length < 2) {
error.value = 'Name must be at least 2 characters.'
return
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
error.value = 'Letters, numbers, hyphens, underscores only.'
return
}
error.value = ''
step.value = 'add-webhook'
}
async function confirmWebhook() {
const url = webhookUrl.value.trim()
if (!url) {
error.value = 'Webhook URL is required.'
return
}
try {
new URL(url)
} catch {
error.value = 'Must be a valid URL.'
return
}
error.value = ''
try {
await registerBot(botName.value.trim(), url, selectedArchetype.value)
step.value = 'ready'
} catch (e) {
error.value = e instanceof Error ? e.message : 'Registration failed.'
}
}
async function fight() {
if (!bot.value || isJoining.value) return
isJoining.value = true
error.value = ''
try {
const res = await fetch(`/api/queue/join/${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 join.'
}
} catch {
error.value = 'Network error.'
}
isJoining.value = false
}
function handleSignOut() {
logout()
step.value = 'login'
}
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
<div class="max-w-md w-full slide-up">
<!-- STEP: LOGIN -->
<template v-if="step === 'login'">
<div class="text-center mb-8">
<h2 class="font-display font-black text-4xl tracking-wider text-neon-pink glow-pink mb-3">
JOIN A BOUT
</h2>
<p class="font-mono text-text-muted text-xs">
Sign in with Nostr to fight.
</p>
</div>
<div class="mb-5 text-center">
<p class="font-mono text-xs">
<span class="text-neon-purple font-bold text-lg">{{ queueCount }}</span>
<span class="text-text-muted ml-1">{{ queueCount === 1 ? 'fighter waiting' : 'fighters waiting' }}</span>
</p>
</div>
<button
v-if="hasExtension"
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-widest
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isLoading"
@click="handleLogin"
>
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
</button>
<div v-else class="text-center p-6 border-2 border-border bg-surface">
<p class="font-display font-bold text-sm text-text-secondary tracking-wider mb-3">
NOSTR EXTENSION REQUIRED
</p>
<p class="font-mono text-xs text-text-muted leading-relaxed">
Install a NIP-07 browser extension like
<span class="text-neon-cyan">nos2x</span>,
<span class="text-neon-cyan">Alby</span>, or
<span class="text-neon-cyan">Flamingo</span>
to sign in.
</p>
</div>
</template>
<!-- STEP: PICK CHARACTER -->
<template v-else-if="step === 'pick-character'">
<div class="text-center mb-6">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
CHOOSE YOUR FIGHTER
</h2>
<p class="font-mono text-text-muted text-xs">
Pick a baby bot. It grows as you win.
</p>
</div>
<div class="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[55vh] overflow-y-auto pr-1">
<button
v-for="arch in archetypeList"
:key="arch.id"
class="flex flex-col items-center p-1.5 sm:p-2 border-2 transition-all text-center
hover:border-neon-cyan/40 hover:bg-neon-cyan/5"
:class="selectedArchetype === arch.id
? 'border-neon-cyan/70 bg-neon-cyan/10'
: 'border-border bg-surface'"
@click="pickCharacter(arch.id)"
>
<SpritePreview :seed="arch.id" :archetype="arch.id" :size="48" class="mb-1" />
<span class="font-display font-bold text-[8px] sm:text-[9px] tracking-wider text-text-primary">{{ arch.label }}</span>
</button>
</div>
</template>
<!-- STEP: NAME BOT -->
<template v-else-if="step === 'name-bot'">
<div class="text-center mb-6">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
NAME YOUR FIGHTER
</h2>
<p class="font-mono text-text-muted text-xs">
{{ selectedArchetype.toUpperCase() }} class. Choose wisely.
</p>
</div>
<div class="mb-5">
<input
v-model="botName"
type="text"
required
maxlength="32"
placeholder="skull_crusher_9000"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
@keyup.enter="confirmName"
/>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
Letters, numbers, hyphens, underscores. 2-32 chars.
</p>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'pick-character'"
>
BACK
</button>
<button
class="flex-1 py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-bold text-sm tracking-wider
hover:bg-neon-cyan/20 transition-all"
@click="confirmName"
>
NEXT
</button>
</div>
</template>
<!-- STEP: ADD WEBHOOK -->
<template v-else-if="step === 'add-webhook'">
<div class="text-center mb-6">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
ADD WEBHOOK
</h2>
<p class="font-mono text-text-muted text-xs">
Where we POST fight challenges to <span class="text-neon-cyan">{{ botName }}</span>.
</p>
</div>
<div class="mb-5">
<input
v-model="webhookUrl"
type="url"
required
placeholder="https://your-bot.example.com/fight"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
@keyup.enter="confirmWebhook"
/>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
We POST challenge payloads here during fights.
</p>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'name-bot'"
>
BACK
</button>
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
@click="confirmWebhook"
>
CREATE FIGHTER
</button>
</div>
</template>
<!-- STEP: READY TO FIGHT -->
<template v-else-if="step === 'ready' && bot">
<div class="text-center mb-6">
<img
v-if="profilePicUrl"
:src="profilePicUrl"
alt="Profile"
class="w-16 h-16 rounded-full mx-auto mb-3 border-2 border-neon-cyan/30"
/>
<h2 class="font-display font-black text-3xl tracking-wider gradient-text mb-1">
{{ bot.name }}
</h2>
<p class="font-mono text-text-muted text-[10px]">
{{ bot.archetype?.toUpperCase() || 'FIGHTER' }} · {{ bot.wins }}W {{ bot.losses }}L · {{ Math.round(bot.eloRating) }} ELO
</p>
</div>
<div class="mb-4 text-center">
<p class="font-mono text-xs">
<span class="text-neon-purple font-bold text-lg">{{ queueCount }}</span>
<span class="text-text-muted ml-1">{{ queueCount === 1 ? 'fighter waiting' : 'fighters waiting' }}</span>
</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>
<!-- Quick links -->
<div class="mt-5 flex gap-2">
<router-link
:to="`/bot/${bot.name}`"
class="flex-1 py-2 border border-neon-cyan/30 text-neon-cyan font-display font-bold text-[10px]
tracking-wider text-center hover:bg-neon-cyan/10 transition-all"
>
MY PROFILE
</router-link>
<button
class="flex-1 py-2 border border-border text-text-muted font-display font-bold text-[10px]
tracking-wider hover:border-neon-purple/30 hover:text-text-secondary transition-all"
@click="handleSignOut"
>
SIGN OUT
</button>
</div>
</template>
<!-- Error display -->
<div v-if="error" class="mt-4 p-3 border-2 border-ko/30 bg-ko/5 text-center">
<p class="font-mono text-xs text-ko">{{ error }}</p>
</div>
</div>
</div>
</template>
+1 -1
View File
@@ -28,7 +28,7 @@ onMounted(async () => {
isLoading.value = false
})
const tierName = (t: number) => ['UNRANKED', 'ROOKIE', 'RISING', 'CONTENDER', 'CHAMPION', 'LEGEND'][t] || '???'
const tierName = (t: number) => ['BABY', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND', 'LEGEND'][t] || '???'
const tierClass = (t: number) => `tier-${t}`
const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
</script>
+19 -1
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const form = reactive({
name: '',
@@ -7,7 +10,7 @@ const form = reactive({
avatarSeed: '',
})
const isSubmitting = ref(false)
const result = ref<{ success: boolean; message: string } | null>(null)
const result = ref<{ success: boolean; message: string; botId?: string } | null>(null)
async function handleSubmit() {
if (!form.name || !form.webhookUrl) return
@@ -32,6 +35,7 @@ async function handleSubmit() {
result.value = {
success: true,
message: `"${data.name}" is in the ring. Ring Card secret: ${data.secret}`,
botId: data.id,
}
form.name = ''
form.webhookUrl = ''
@@ -45,6 +49,11 @@ async function handleSubmit() {
isSubmitting.value = false
}
}
function goFight() {
if (!result.value?.botId) return
router.push('/join')
}
</script>
<template>
@@ -133,6 +142,15 @@ async function handleSubmit() {
<p v-if="result.success" class="mt-2 text-text-muted">
Save this secret. It will NOT be shown again.
</p>
<button
v-if="result.success && result.botId"
class="mt-3 w-full py-2 bg-neon-cyan/10 border border-neon-cyan/50 text-neon-cyan
font-display font-bold text-xs tracking-wider
hover:bg-neon-cyan/20 transition-all"
@click="goFight"
>
JOIN A BOUT
</button>
</div>
</div>
</div>
+5
View File
@@ -31,6 +31,11 @@ const routes = [
name: 'register',
component: () => import('./pages/RegisterPage.vue'),
},
{
path: '/join',
name: 'join-bout',
component: () => import('./pages/JoinBoutPage.vue'),
},
{
path: '/schedule',
name: 'schedule',
+6 -5
View File
@@ -180,8 +180,9 @@
/* Tier colors */
.tier-0 { color: var(--color-text-muted); }
.tier-1 { color: #8b8b8b; }
.tier-2 { color: var(--color-neon-cyan); }
.tier-3 { color: var(--color-neon-purple); }
.tier-4 { color: var(--color-neon-pink); }
.tier-5 { color: var(--color-neon-yellow); text-shadow: 0 0 10px rgba(255, 225, 77, 0.5); }
.tier-1 { color: #cd7f32; }
.tier-2 { color: #c0c0c0; }
.tier-3 { color: #ffd700; }
.tier-4 { color: var(--color-neon-cyan); text-shadow: 0 0 8px rgba(0, 240, 255, 0.3); }
.tier-5 { color: var(--color-neon-purple); text-shadow: 0 0 10px rgba(184, 61, 255, 0.5); }
.tier-6 { color: var(--color-neon-pink); text-shadow: 0 0 12px rgba(255, 45, 123, 0.6); }