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:
co-authored by
Claude Opus 4.6
parent
335c148866
commit
47d20fbe66
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+4267
-140
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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%)`,
|
||||
}
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 }} · {{ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user