feat: botfights v1 — full fighting game with Kaplay engine
- Vue 3 + Vite + Tailwind 4 frontend with synthwave aesthetic - Hono backend on port 9100 with SQLite/Drizzle - Procedural pixel-art sprite generator (48x48, 8 animation states) - Kaplay fight scene with punch/kick/special/knockback/KO animations - 12 mock bots across 6 tiers with Elo rating system - 9 challenge types, 10 fight arenas with modifiers - Fight replay with staggered battle log and ~1 min timing - Sprite preview page at /sprites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-surface synthwave-grid flex flex-col relative">
|
||||
<div class="fixed inset-0 crt-overlay z-40" />
|
||||
<NavBar />
|
||||
<main class="flex-1 relative z-10">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,423 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { createFightScene, type FightSceneController } from '../game/FightScene'
|
||||
|
||||
interface Round {
|
||||
roundNumber: number
|
||||
challengeType: string
|
||||
challengeData: string
|
||||
botAResponse: string | null
|
||||
botATimeMs: number | null
|
||||
botAScore: number | null
|
||||
botBResponse: string | null
|
||||
botBTimeMs: number | null
|
||||
botBScore: number | null
|
||||
winnerId: string | null
|
||||
narration: string | null
|
||||
}
|
||||
|
||||
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
|
||||
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
|
||||
arena: string
|
||||
winnerId: string | null
|
||||
botAHp: number
|
||||
botBHp: number
|
||||
totalRounds: number
|
||||
status: string
|
||||
rounds: Round[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ fight: FightData }>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const logEl = ref<HTMLElement>()
|
||||
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
|
||||
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)
|
||||
}
|
||||
initScene()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
scene?.destroy()
|
||||
scene = null
|
||||
})
|
||||
|
||||
function initScene() {
|
||||
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
|
||||
if (scene) { scene.k.go('fight'); return }
|
||||
|
||||
const container = canvasRef.value.parentElement
|
||||
if (container) {
|
||||
canvasRef.value.width = container.clientWidth
|
||||
canvasRef.value.height = container.clientHeight
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
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',
|
||||
}
|
||||
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 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' },
|
||||
{ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' },
|
||||
{ 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'
|
||||
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)
|
||||
|
||||
logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' })
|
||||
scrollLog()
|
||||
await sleep(800)
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
})()
|
||||
}
|
||||
|
||||
async function replay() {
|
||||
if (isReplaying.value || !props.fight.botA || !props.fight.botB) return
|
||||
isReplaying.value = true
|
||||
showingFinal.value = false
|
||||
displayHpA.value = 100
|
||||
displayHpB.value = 100
|
||||
visibleRounds.value = []
|
||||
logItems.value = []
|
||||
currentRound.value = 0
|
||||
|
||||
initScene()
|
||||
await sleep(600)
|
||||
|
||||
// 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' })
|
||||
scrollLog()
|
||||
await sleep(800)
|
||||
|
||||
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)
|
||||
|
||||
// Stagger the battle log alongside the fight
|
||||
const logPromise = addRoundToLog(round, true)
|
||||
|
||||
// Play the round animation
|
||||
const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
|
||||
|
||||
await scene!.playRound({
|
||||
round: round.roundNumber,
|
||||
challengeType: round.challengeType,
|
||||
winnerId: round.winnerId,
|
||||
botAId: props.fight.botA!.id,
|
||||
botBId: props.fight.botB!.id,
|
||||
narration: round.narration || '',
|
||||
isCritical,
|
||||
botAScore: round.botAScore || 0,
|
||||
botBScore: round.botBScore || 0,
|
||||
})
|
||||
|
||||
// Wait for log to finish
|
||||
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: '' })
|
||||
scrollLog()
|
||||
|
||||
// Update HP
|
||||
const baseDmg = 15
|
||||
if (aWon) {
|
||||
const dmg = Math.max(8, baseDmg + ((round.botAScore || 5) - (round.botBScore || 5)) * 3)
|
||||
displayHpB.value = Math.max(0, displayHpB.value - Math.round(dmg))
|
||||
} else if (bWon) {
|
||||
const dmg = Math.max(8, baseDmg + ((round.botBScore || 5) - (round.botAScore || 5)) * 3)
|
||||
displayHpA.value = Math.max(0, displayHpA.value - Math.round(dmg))
|
||||
}
|
||||
|
||||
// Longer pause between rounds for ~1 min total fight
|
||||
await sleep(2000)
|
||||
}
|
||||
|
||||
// Final HP
|
||||
displayHpA.value = props.fight.botAHp
|
||||
displayHpB.value = props.fight.botBHp
|
||||
|
||||
// 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
|
||||
|
||||
if (isPerfect) {
|
||||
await scene.playPerfect(winningSide)
|
||||
} else {
|
||||
await scene.playKO(winningSide)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col lg:flex-row 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 -->
|
||||
<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" />
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-neon-green" />
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- RIGHT: Game Canvas -->
|
||||
<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"
|
||||
: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>
|
||||
<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}%` }" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 min-w-0">
|
||||
<p class="font-display font-black text-xs tracking-wider truncate text-right"
|
||||
:class="fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'">
|
||||
{{ fight.botB?.name }}
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div class="flex-1 relative min-h-0">
|
||||
<canvas ref="canvasRef" class="w-full h-full block" />
|
||||
</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
|
||||
tracking-widest hover:bg-neon-pink/10 transition-all neon-border-pink
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="isReplaying"
|
||||
@click="replay"
|
||||
>
|
||||
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
|
||||
</button>
|
||||
<span class="font-pixel text-[10px] text-text-muted">
|
||||
{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
|
||||
const links = [
|
||||
{ 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">
|
||||
<span class="font-display font-black text-neon-pink text-lg tracking-widest glow-pink">
|
||||
BOTFIGHTS
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="text-xs font-display font-bold text-text-secondary tracking-wider
|
||||
hover:text-neon-cyan transition-colors duration-200"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="md:hidden text-text-secondary hover:text-neon-cyan transition-colors"
|
||||
@click="isMenuOpen = !isMenuOpen"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-if="!isMenuOpen"
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isMenuOpen" class="md:hidden border-t border-border px-6 py-4 space-y-4 bg-surface">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="block text-sm font-display font-bold text-text-secondary tracking-wider
|
||||
hover:text-neon-cyan transition-colors"
|
||||
@click="isMenuOpen = false"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,276 @@
|
||||
import kaplay from 'kaplay'
|
||||
import { generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS } from './sprites'
|
||||
|
||||
export interface FightSceneConfig {
|
||||
canvas: HTMLCanvasElement
|
||||
botA: { name: string; seed: string; tier: number }
|
||||
botB: { name: string; seed: string; tier: number }
|
||||
arena: string
|
||||
onReady?: () => void
|
||||
}
|
||||
|
||||
export interface RoundEvent {
|
||||
round: number
|
||||
challengeType: string
|
||||
winnerId: string | null
|
||||
botAId: string
|
||||
botBId: string
|
||||
narration: string
|
||||
isCritical: boolean
|
||||
botAScore: number
|
||||
botBScore: number
|
||||
}
|
||||
|
||||
const ARENA_THEMES: Record<string, { bg: string; ground: string; accent: string }> = {
|
||||
datacenter: { bg: '#0a0a1a', ground: '#1a1a3a', accent: '#00f0ff' },
|
||||
stackoverflow_ruins: { bg: '#1a0f00', ground: '#2a1f10', accent: '#f48024' },
|
||||
gpu_graveyard: { bg: '#0a0a0a', ground: '#1a1a1a', accent: '#76b900' },
|
||||
prompt_dungeon: { bg: '#0f0a1a', ground: '#1f1a2a', accent: '#b83dff' },
|
||||
silicon_valley_dojo: { bg: '#0a1a0a', ground: '#1a2a1a', accent: '#00ff41' },
|
||||
paper_mill: { bg: '#1a1a10', ground: '#2a2a20', accent: '#f0e68c' },
|
||||
localhost: { bg: '#000000', ground: '#111111', accent: '#00ff41' },
|
||||
the_cloud: { bg: '#0a0f1a', ground: '#1a1f2a', accent: '#4488ff' },
|
||||
hacker_news: { bg: '#1a0f00', ground: '#2a1f10', accent: '#ff6600' },
|
||||
the_singularity: { bg: '#1a0020', ground: '#2a0030', accent: '#ff00ff' },
|
||||
}
|
||||
|
||||
const spriteAnims = {
|
||||
idle: { from: 0, to: ANIMATIONS.idle.frames - 1, loop: true, speed: 6 },
|
||||
attack: { from: MAX_FRAMES, to: MAX_FRAMES + ANIMATIONS.attack.frames - 1, loop: false, speed: 12 },
|
||||
kick: { from: MAX_FRAMES * 2, to: MAX_FRAMES * 2 + ANIMATIONS.kick.frames - 1, loop: false, speed: 10 },
|
||||
special: { from: MAX_FRAMES * 3, to: MAX_FRAMES * 3 + ANIMATIONS.special.frames - 1, loop: false, speed: 8 },
|
||||
hit: { from: MAX_FRAMES * 4, to: MAX_FRAMES * 4 + ANIMATIONS.hit.frames - 1, loop: false, speed: 8 },
|
||||
knockback: { from: MAX_FRAMES * 5, to: MAX_FRAMES * 5 + ANIMATIONS.knockback.frames - 1, loop: false, speed: 8 },
|
||||
ko: { from: MAX_FRAMES * 6, to: MAX_FRAMES * 6 + ANIMATIONS.ko.frames - 1, loop: false, speed: 6 },
|
||||
win: { from: MAX_FRAMES * 7, to: MAX_FRAMES * 7 + ANIMATIONS.win.frames - 1, loop: true, speed: 6 },
|
||||
}
|
||||
|
||||
// Pick a random attack animation based on challenge type
|
||||
function pickAttackAnim(challengeType: string, isCritical: boolean): string {
|
||||
if (isCritical) return 'special'
|
||||
const map: Record<string, string[]> = {
|
||||
speed_blitz: ['attack', 'kick'],
|
||||
riddle: ['attack', 'special'],
|
||||
code_golf: ['special', 'attack'],
|
||||
roast_battle: ['special', 'kick'],
|
||||
hallucination_check: ['attack'],
|
||||
token_economy: ['kick', 'attack'],
|
||||
creative_writing: ['special'],
|
||||
math_blitz: ['attack', 'kick'],
|
||||
trap_card: ['special', 'kick'],
|
||||
}
|
||||
const options = map[challengeType] || ['attack', 'kick']
|
||||
return options[Math.floor(Math.random() * options.length)]
|
||||
}
|
||||
|
||||
// Pick defender reaction
|
||||
function pickDefenderAnim(isCritical: boolean): string {
|
||||
return isCritical ? 'knockback' : 'hit'
|
||||
}
|
||||
|
||||
export function createFightScene(config: FightSceneConfig) {
|
||||
const { canvas, botA, botB, arena } = config
|
||||
const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost
|
||||
|
||||
const k = kaplay({
|
||||
canvas,
|
||||
width: canvas.width || 800,
|
||||
height: canvas.height || 500,
|
||||
background: theme.bg,
|
||||
global: false,
|
||||
scale: 1,
|
||||
crisp: true,
|
||||
texFilter: 'nearest',
|
||||
})
|
||||
|
||||
const colorsA = getBotColors(botA.seed)
|
||||
const colorsB = getBotColors(botB.seed)
|
||||
const sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
||||
const sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
||||
|
||||
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
|
||||
const W = k.width()
|
||||
const H = k.height()
|
||||
const GROUND_Y = H * 0.78
|
||||
|
||||
k.scene('fight', () => {
|
||||
// Ground
|
||||
k.add([k.rect(W, H * 0.25), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.ground))])
|
||||
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.5)])
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + i * 12), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.06)])
|
||||
}
|
||||
for (let i = 0; i < 24; i++) {
|
||||
k.add([k.rect(1, H * 0.25), k.pos(i * (W / 24), GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.04)])
|
||||
}
|
||||
|
||||
const scaleA = 1.8 + botA.tier * 0.4
|
||||
k.add([k.sprite('botA', { anim: 'idle' }), k.pos(W * 0.28, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), 'fighterA'])
|
||||
|
||||
const scaleB = 1.8 + botB.tier * 0.4
|
||||
k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W * 0.72, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), 'fighterB'])
|
||||
|
||||
k.add([k.text('', { size: 42, font: 'monospace' }), k.pos(W / 2, H * 0.3), k.anchor('center'), k.color(k.Color.fromHex('#ffffff')), k.opacity(0), k.z(100), 'announcement'])
|
||||
k.add([k.text('', { size: 32, font: 'monospace' }), k.pos(0, 0), k.anchor('center'), k.color(k.Color.fromHex('#ff2d2d')), k.opacity(0), k.z(90), 'hitText'])
|
||||
k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.28, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboA'])
|
||||
k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.72, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboB'])
|
||||
|
||||
config.onReady?.()
|
||||
})
|
||||
|
||||
k.go('fight')
|
||||
|
||||
let comboA = 0
|
||||
let comboB = 0
|
||||
|
||||
return {
|
||||
k,
|
||||
|
||||
async showAnnouncement(text: string, color: string = '#ffffff', duration: number = 1200) {
|
||||
const ann = k.get('announcement')[0]
|
||||
if (!ann) return
|
||||
ann.text = text
|
||||
ann.color = k.Color.fromHex(color)
|
||||
ann.opacity = 1
|
||||
ann.scaleTo(0.5)
|
||||
await k.tween(ann.scale.x, 1, 0.2, (v) => ann.scaleTo(v), k.easings.easeOutBack)
|
||||
await k.wait(duration / 1000)
|
||||
await k.tween(1, 0, 0.3, (v) => { ann.opacity = v })
|
||||
},
|
||||
|
||||
async playAttack(side: 'a' | 'b', attackAnim: string, defenderAnim: string, isCritical: boolean) {
|
||||
const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
|
||||
const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0]
|
||||
if (!attacker || !defender) return
|
||||
|
||||
const origAX = attacker.pos.x
|
||||
const origDX = defender.pos.x
|
||||
const direction = side === 'a' ? 1 : -1
|
||||
const lunge = attackAnim === 'special' ? 20 : 40 + (isCritical ? 20 : 0)
|
||||
|
||||
// Lunge forward
|
||||
await k.tween(attacker.pos.x, attacker.pos.x + direction * lunge, 0.15, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad)
|
||||
|
||||
attacker.play(attackAnim as any)
|
||||
await k.wait(attackAnim === 'special' ? 0.35 : 0.2)
|
||||
|
||||
defender.play(defenderAnim as any)
|
||||
|
||||
// Hit text
|
||||
const hitFx = k.get('hitText')[0]
|
||||
if (hitFx) {
|
||||
const words = isCritical
|
||||
? ['CRITICAL!', 'DEVASTATING!', 'BRUTAL!', 'OBLITERATED!']
|
||||
: attackAnim === 'kick' ? ['KICK!', 'ROUNDHOUSE!', 'SWEPT!']
|
||||
: attackAnim === 'special' ? ['SPECIAL!', 'HADOUKEN!', 'ZAPPED!']
|
||||
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!']
|
||||
hitFx.text = words[Math.floor(Math.random() * words.length)]
|
||||
hitFx.pos.x = defender.pos.x + (side === 'a' ? -20 : 20)
|
||||
hitFx.pos.y = defender.pos.y - 90
|
||||
hitFx.opacity = 1
|
||||
hitFx.color = isCritical ? k.Color.fromHex('#ffe14d') : attackAnim === 'special' ? k.Color.fromHex('#00f0ff') : k.Color.fromHex('#ff2d2d')
|
||||
k.tween(hitFx.pos.y, hitFx.pos.y - 50, 0.8, (v) => { hitFx.pos.y = v })
|
||||
k.tween(1, 0, 1, (v) => { hitFx.opacity = v })
|
||||
}
|
||||
|
||||
// Screen shake
|
||||
k.shake(isCritical ? 15 : attackAnim === 'special' ? 8 : 5)
|
||||
|
||||
// Knockback — push defender back
|
||||
if (defenderAnim === 'knockback') {
|
||||
const pushDist = direction * -60
|
||||
await k.tween(defender.pos.x, defender.pos.x + pushDist, 0.3, (v) => { defender.pos.x = v }, k.easings.easeOutQuad)
|
||||
await k.wait(0.3)
|
||||
// Return defender
|
||||
await k.tween(defender.pos.x, origDX, 0.4, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad)
|
||||
} else {
|
||||
// Flash defender
|
||||
await k.wait(0.1)
|
||||
defender.opacity = 0.3; await k.wait(0.05)
|
||||
defender.opacity = 1; await k.wait(0.05)
|
||||
defender.opacity = 0.3; await k.wait(0.05)
|
||||
defender.opacity = 1
|
||||
await k.wait(0.2)
|
||||
}
|
||||
|
||||
// Return attacker
|
||||
await k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
|
||||
|
||||
await k.wait(0.2)
|
||||
attacker.play('idle')
|
||||
defender.play('idle')
|
||||
},
|
||||
|
||||
async playRound(event: RoundEvent) {
|
||||
const aWon = event.winnerId === event.botAId
|
||||
const bWon = event.winnerId === event.botBId
|
||||
const isCritical = Math.abs(event.botAScore - event.botBScore) > 4
|
||||
const atkAnim = pickAttackAnim(event.challengeType, isCritical)
|
||||
const defAnim = pickDefenderAnim(isCritical)
|
||||
|
||||
if (aWon) {
|
||||
comboA++; comboB = 0
|
||||
await this.playAttack('a', atkAnim, defAnim, isCritical)
|
||||
if (comboA >= 2) {
|
||||
const ct = k.get('comboA')[0]
|
||||
if (ct) { ct.text = `x${comboA} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) }
|
||||
}
|
||||
} else if (bWon) {
|
||||
comboB++; comboA = 0
|
||||
await this.playAttack('b', atkAnim, defAnim, isCritical)
|
||||
if (comboB >= 2) {
|
||||
const ct = k.get('comboB')[0]
|
||||
if (ct) { ct.text = `x${comboB} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) }
|
||||
}
|
||||
} else {
|
||||
comboA = 0; comboB = 0
|
||||
// Draw — both take a hit
|
||||
const fA = k.get('fighterA')[0]
|
||||
const fB = k.get('fighterB')[0]
|
||||
if (fA && fB) {
|
||||
fA.play('hit'); fB.play('hit')
|
||||
k.shake(3)
|
||||
await k.wait(0.5)
|
||||
fA.play('idle'); fB.play('idle')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async playKO(winningSide: 'a' | 'b') {
|
||||
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
|
||||
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
|
||||
if (!loser || !winner) return
|
||||
|
||||
loser.play('knockback')
|
||||
k.shake(20)
|
||||
await k.wait(0.4)
|
||||
loser.play('ko')
|
||||
await k.wait(0.6)
|
||||
await this.showAnnouncement('K.O.!', '#ff2d2d', 2000)
|
||||
winner.play('win')
|
||||
await k.wait(0.5)
|
||||
},
|
||||
|
||||
async playPerfect(winningSide: 'a' | 'b') {
|
||||
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
|
||||
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
|
||||
if (!loser || !winner) return
|
||||
|
||||
loser.play('knockback')
|
||||
k.shake(25)
|
||||
await k.wait(0.5)
|
||||
loser.play('ko')
|
||||
await this.showAnnouncement('PERFECT!', '#ffe14d', 2500)
|
||||
winner.play('win')
|
||||
},
|
||||
|
||||
destroy() {
|
||||
k.quit()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type FightSceneController = ReturnType<typeof createFightScene>
|
||||
@@ -0,0 +1,586 @@
|
||||
// Pixel-art sprite sheet generator
|
||||
// 48x48 internal resolution scaled to 96x96 frames
|
||||
// Many animation states for rich fighting
|
||||
|
||||
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 function generateSpriteSheet(
|
||||
seed: string, tier: number, primaryColor: string, secondaryColor: string,
|
||||
): string {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = FRAME_SIZE * MAX_FRAMES
|
||||
canvas.height = FRAME_SIZE * TOTAL_ROWS
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
const pal = makePal(primaryColor, secondaryColor, tier)
|
||||
|
||||
let sh = 0
|
||||
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
|
||||
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
|
||||
|
||||
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, fillColor: string, ox: number, oy: number) {
|
||||
for (let i = x - 1; i <= x + w; i++) { px(i, y - 1, pal.out, ox, oy); px(i, y + h, pal.out, ox, oy) }
|
||||
for (let i = y; i < y + h; i++) { px(x - 1, i, pal.out, ox, oy); px(x + w, i, pal.out, ox, oy) }
|
||||
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, fillColor, ox, oy)
|
||||
}
|
||||
|
||||
function fill(x: number, y: number, w: number, h: number, color: string, ox: number, oy: number) {
|
||||
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, color, ox, oy)
|
||||
}
|
||||
|
||||
function drawFrame(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 idle = pose === 'idle'
|
||||
const atk = pose === 'attack'
|
||||
const kick = pose === 'kick'
|
||||
const special = pose === 'special'
|
||||
const hit = pose === 'hit'
|
||||
const knockback = pose === 'knockback'
|
||||
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
|
||||
|
||||
// Anchor: center bottom at (24, 42) in 48x48
|
||||
const cx = 24
|
||||
const ground = 42
|
||||
|
||||
// Positions bottom-up
|
||||
const feetY = ground - 2
|
||||
const legsTop = feetY - legH
|
||||
const bodyTop = legsTop - bh
|
||||
const headTop = bodyTop - hh
|
||||
|
||||
// Pose offsets
|
||||
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 globalY = -kbLift
|
||||
|
||||
// ---- SHADOW ----
|
||||
const shadowW = Math.floor(bw * 0.7) + (knockback ? 2 : 0)
|
||||
for (let sx = cx - shadowW; sx <= cx + shadowW; sx++) {
|
||||
px(sx, ground, 'rgba(0,0,0,0.25)', ox, oy)
|
||||
px(sx, ground + 1, 'rgba(0,0,0,0.1)', ox, oy)
|
||||
}
|
||||
|
||||
// ---- LEGS ----
|
||||
const legGap = atk || kick ? Math.round(1 + t * 3) : ko ? 4 : knockback ? 3 : 1
|
||||
const ll = cx - legGap - Math.floor(legW / 2) + hOff
|
||||
const rl = cx + legGap - Math.floor(legW / 2) + hOff
|
||||
|
||||
if (ko) {
|
||||
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 {
|
||||
box(ll, legsTop + vBounce + globalY, legW, legH, pal.dark, ox, oy)
|
||||
box(rl + (atk ? Math.round(t * 2) : 0), legsTop + vBounce + globalY, legW, legH, pal.dark, ox, oy)
|
||||
}
|
||||
|
||||
// Feet (tier 2+)
|
||||
if (tier >= 2 && !ko && !knockback && !kick) {
|
||||
box(ll - 1, feetY + vBounce + globalY, legW + 2, 2, pal.accDark, ox, oy)
|
||||
box(rl - 1 + (atk ? Math.round(t * 2) : 0), feetY + vBounce + globalY, legW + 2, 2, pal.accDark, ox, oy)
|
||||
}
|
||||
|
||||
// ---- BODY ----
|
||||
const bx = cx - Math.floor(bw / 2) + hOff
|
||||
const by = bodyTop + vBounce + koSlump + globalY
|
||||
|
||||
box(bx, by, bw, bh, pal.body, ox, oy)
|
||||
|
||||
// Shading
|
||||
for (let iy = by + 1; iy < by + bh - 1; iy++) {
|
||||
px(bx + bw - 1, iy, pal.dark, ox, oy)
|
||||
px(bx + bw - 2, iy, pal.dark, ox, oy)
|
||||
px(bx + 1, iy, pal.light, ox, oy)
|
||||
}
|
||||
|
||||
// Horizontal stripes (tier detail)
|
||||
if (tier >= 1) {
|
||||
for (let iy = by + 2; iy < by + bh - 1; iy += 2) {
|
||||
for (let ix = bx + 2; ix < bx + bw - 2; ix++) {
|
||||
px(ix, iy, pal.dark, ox, oy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Belt (tier 2+)
|
||||
if (tier >= 2) {
|
||||
const beltY = by + bh - 2
|
||||
fill(bx, beltY, bw, 1, pal.acc, ox, oy)
|
||||
fill(bx, beltY + 1, bw, 1, pal.accDark, ox, oy)
|
||||
if (tier >= 3) { px(cx + hOff, beltY, '#ffd700', ox, oy); px(cx + hOff + 1, beltY, '#ffd700', ox, oy) }
|
||||
}
|
||||
|
||||
// Chest emblem (tier 4+)
|
||||
if (tier >= 4) {
|
||||
const ey = by + Math.round(bh * 0.3)
|
||||
px(cx + hOff - 1, ey, pal.acc, ox, oy)
|
||||
px(cx + hOff, ey, pal.accLight, ox, oy)
|
||||
px(cx + hOff + 1, ey, pal.acc, ox, oy)
|
||||
px(cx + hOff, ey - 1, pal.acc, ox, oy)
|
||||
px(cx + hOff, ey + 1, pal.acc, ox, oy)
|
||||
}
|
||||
|
||||
// Shoulder pads (tier 3+)
|
||||
if (tier >= 3 && !ko && !knockback) {
|
||||
const sy = by
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- ARMS ----
|
||||
const armAttach = by + 2 + vBounce
|
||||
const armLx = bx - armW + hOff
|
||||
const armRx = bx + bw + hOff
|
||||
|
||||
if (ko) {
|
||||
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)
|
||||
px(ix + 1, armAttach, '#ffffff', ox, oy)
|
||||
px(ix, armAttach + 2, '#ffff00', ox, oy)
|
||||
px(ix + 2, armAttach - 1, '#ffaa00', ox, oy)
|
||||
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) {
|
||||
const gs = 2 + Math.floor(tier * 0.3)
|
||||
const gc = tier >= 5 ? '#ffd700' : '#ff3333'
|
||||
box(armLx, armAttach + armH - 1, gs, gs, gc, ox, oy)
|
||||
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)
|
||||
if (tier >= 3) {
|
||||
const gs = 3 + Math.floor(tier * 0.3)
|
||||
const gc = tier >= 5 ? '#ffd700' : '#ff3333'
|
||||
box(armLx - 1, armAttach + sw + armH + globalY, gs, gs, gc, ox, oy)
|
||||
box(armRx, armAttach - sw + armH + globalY, gs, gs, gc, ox, oy)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HEAD ----
|
||||
const hx = cx - Math.floor(hw / 2) + hOff
|
||||
const hy = headTop + vBounce + koSlump + globalY
|
||||
|
||||
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)
|
||||
|
||||
// Antenna
|
||||
px(cx + hOff, hy - 1, pal.accDark, ox, oy)
|
||||
px(cx + hOff, hy - 2 - (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
|
||||
px(cx + hOff, hy - 3 - (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
|
||||
px(cx + hOff - 1, hy - 3 - (idle ? Math.abs(bounce) : 0), pal.accDark, ox, oy)
|
||||
px(cx + hOff + 1, hy - 3 - (idle ? Math.abs(bounce) : 0), pal.accDark, ox, oy)
|
||||
|
||||
// Eyes
|
||||
const eyeY = hy + Math.floor(hh * 0.3)
|
||||
if (ko) {
|
||||
px(hx + 2, eyeY, '#ff0000', ox, oy); px(hx + 3, eyeY + 1, '#ff0000', ox, oy)
|
||||
px(hx + 3, eyeY, '#330000', ox, oy); px(hx + 2, eyeY + 1, '#330000', ox, oy)
|
||||
px(hx + hw - 3, eyeY, '#ff0000', ox, oy); px(hx + hw - 4, eyeY + 1, '#ff0000', ox, oy)
|
||||
px(hx + hw - 4, eyeY, '#330000', ox, oy); px(hx + hw - 3, eyeY + 1, '#330000', ox, oy)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Mouth grille
|
||||
const mY = hy + Math.floor(hh * 0.65)
|
||||
for (let mx = hx + 2; mx < hx + hw - 2; mx += 2) {
|
||||
px(mx, mY, pal.out, ox, oy)
|
||||
px(mx, mY + 1, pal.out, ox, oy)
|
||||
}
|
||||
|
||||
// Bolts
|
||||
px(hx, hy + Math.floor(hh / 2), pal.accDark, ox, oy)
|
||||
px(hx + hw - 1, hy + Math.floor(hh / 2), pal.accDark, ox, oy)
|
||||
|
||||
// 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)
|
||||
}
|
||||
} else {
|
||||
// ROUNDED HEAD (tier 2+)
|
||||
box(hx + 1, hy, hw - 2, hh, pal.body, ox, oy)
|
||||
for (let iy = hy + 2; iy < hy + hh - 2; iy++) {
|
||||
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+)
|
||||
if (tier >= 2) {
|
||||
const faceTop = hy + Math.floor(hh * 0.25)
|
||||
const faceBot = hy + Math.floor(hh * 0.75)
|
||||
for (let iy = faceTop; iy < faceBot; iy++) {
|
||||
for (let ix = hx + 2; ix < hx + hw - 2; ix++) {
|
||||
px(ix, iy, pal.skin, ox, oy)
|
||||
}
|
||||
px(hx + hw - 3, iy, pal.skinDark, ox, oy)
|
||||
}
|
||||
}
|
||||
|
||||
// Eyes
|
||||
const eyeY = hy + Math.floor(hh * 0.35)
|
||||
const leX = hx + Math.floor(hw * 0.2)
|
||||
const reX = hx + Math.floor(hw * 0.6)
|
||||
const ew = Math.max(2, Math.floor(tier * 0.5) + 1)
|
||||
|
||||
if (ko) {
|
||||
px(leX, eyeY, '#ff0000', ox, oy); px(leX + 1, eyeY + 1, '#ff0000', ox, oy)
|
||||
px(leX + 1, eyeY, '#880000', ox, oy); px(leX, eyeY + 1, '#880000', ox, oy)
|
||||
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)
|
||||
px(reX, eyeY + 1, '#000000', ox, oy)
|
||||
} else {
|
||||
fill(leX, eyeY, ew, 2, '#ffffff', ox, oy)
|
||||
fill(reX, eyeY, ew, 2, '#ffffff', ox, oy)
|
||||
const ps = atk || kick || special ? 1 : 0
|
||||
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)
|
||||
if (special) {
|
||||
px(leX - 1, eyeY, specialType === 'fire' ? '#ff4400' : '#00eeff', ox, oy)
|
||||
px(reX + ew, eyeY, specialType === 'fire' ? '#ff4400' : '#00eeff', ox, oy)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
px(cx + hOff - 1, mY + 1, pal.out, ox, oy)
|
||||
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)
|
||||
} else {
|
||||
px(cx + hOff - 1, mY, pal.out, ox, oy)
|
||||
px(cx + hOff, mY, pal.out, ox, oy)
|
||||
}
|
||||
|
||||
// Visor
|
||||
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
|
||||
}
|
||||
|
||||
// Headband (tier 4+)
|
||||
if (tier >= 4) {
|
||||
const bY = hy + 2
|
||||
for (let bx2 = hx; bx2 < hx + hw; bx2++) px(bx2, bY, pal.acc, ox, oy)
|
||||
px(hx - 1, bY + 1, pal.acc, ox, oy)
|
||||
px(hx - 2, bY + 1 + (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
|
||||
px(hx - 3, bY + 2 + (idle ? Math.abs(bounce) : 0), pal.acc, ox, oy)
|
||||
px(hx - 4, bY + 2, pal.accDark, ox, oy)
|
||||
}
|
||||
|
||||
// Mohawk
|
||||
if (hasMohawk) {
|
||||
for (let m = 1; m <= Math.min(tier + 1, 5); m++) {
|
||||
px(cx + hOff, hy - m, pal.acc, ox, oy)
|
||||
if (m <= 3) { px(cx + hOff + 1, hy - m, pal.accDark, ox, oy) }
|
||||
}
|
||||
}
|
||||
|
||||
// Horns (tier 4+, alt to mohawk)
|
||||
if (hasHorns) {
|
||||
px(hx + 1, hy - 1, pal.acc, ox, oy); px(hx, hy - 2, pal.acc, ox, oy); px(hx - 1, hy - 3, pal.accLight, ox, oy)
|
||||
px(hx + hw - 2, hy - 1, pal.acc, ox, oy); px(hx + hw - 1, hy - 2, pal.acc, ox, oy); px(hx + hw, hy - 3, pal.accLight, ox, oy)
|
||||
}
|
||||
|
||||
// Crown (tier 5)
|
||||
if (tier >= 5) {
|
||||
const cY = hy - 1 - (hasMohawk ? 5 : hasHorns ? 3 : 0)
|
||||
for (let cx2 = hx + 1; cx2 < hx + hw - 1; cx2++) px(cx2, cY, '#ffd700', ox, oy)
|
||||
for (let cx2 = hx + 2; cx2 < hx + hw - 2; cx2++) px(cx2, cY + 1, '#ffd700', ox, oy)
|
||||
px(hx + 2, cY - 1, '#ffd700', ox, oy)
|
||||
px(cx + hOff, cY - 2, '#ffd700', ox, oy)
|
||||
px(hx + hw - 3, cY - 1, '#ffd700', ox, oy)
|
||||
px(cx + hOff, cY - 1, '#ff2d7b', ox, oy)
|
||||
px(hx + 2, cY, '#00f0ff', ox, oy)
|
||||
px(hx + hw - 3, cY, '#00f0ff', ox, oy)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- AURA (tier 4+) ----
|
||||
if (tier >= 4 && !ko) {
|
||||
const aCx = cx + hOff
|
||||
const aCy = by + Math.floor(bh / 2)
|
||||
const aR = Math.floor(bw / 2) + tier + 3
|
||||
const dots = 6 + tier * 2
|
||||
for (let i = 0; i < dots; i++) {
|
||||
const ang = t * Math.PI * 2 + i * Math.PI * 2 / dots
|
||||
const ax = aCx + Math.round(Math.cos(ang) * aR)
|
||||
const ay = aCy + Math.round(Math.sin(ang) * (aR * 0.6))
|
||||
if ((frame + i) % 3 !== 0) px(ax, ay, i % 2 === 0 ? pal.acc : pal.light, ox, oy)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HIT SPARK ----
|
||||
if (hit && t > 0.2) {
|
||||
const sx = cx + hOff + Math.floor(bw / 2) + 3
|
||||
const sy = by + 2
|
||||
px(sx, sy, '#ffffff', ox, oy); px(sx - 1, sy, '#ffff00', ox, oy); px(sx + 1, sy, '#ffff00', ox, oy)
|
||||
px(sx, sy - 1, '#ffff00', ox, oy); px(sx, sy + 1, '#ffff00', ox, oy)
|
||||
px(sx + 2, sy - 1, '#ff8800', ox, oy); px(sx + 2, sy + 1, '#ff8800', ox, oy)
|
||||
px(sx - 1, sy - 1, '#ff4400', ox, oy)
|
||||
}
|
||||
|
||||
// ---- KNOCKBACK STARS ----
|
||||
if (knockback) {
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const sa = t * Math.PI + s * 2.1
|
||||
const sr = 5 + s * 3
|
||||
const sx = cx + hOff - 2 + Math.round(Math.cos(sa) * sr)
|
||||
const sy = hy - 2 + Math.round(Math.sin(sa) * sr * 0.5)
|
||||
px(sx, sy, '#ffff00', ox, oy)
|
||||
px(sx + 1, sy, '#ffffff', ox, oy)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- WIN SPARKLES ----
|
||||
if (tier >= 2 && win) {
|
||||
for (let i = 0; i < tier + 2; i++) {
|
||||
const sa = t * Math.PI * 2 + i * 1.5
|
||||
const sr = 10 + tier * 2
|
||||
const sx = cx + hOff + Math.round(Math.cos(sa) * sr)
|
||||
const sy = by + Math.floor(bh / 2) + Math.round(Math.sin(sa) * sr * 0.5)
|
||||
if ((frame + i) % 2 === 0) { px(sx, sy, '#ffd700', ox, oy); px(sx + 1, sy, '#ffffff', ox, oy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Object.entries(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++) drawFrame(f, row, pose, f, cfg.frames)
|
||||
for (let f = cfg.frames; f < MAX_FRAMES; f++) drawFrame(f, row, pose, cfg.frames - 1, cfg.frames)
|
||||
}
|
||||
|
||||
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,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import { router } from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface FightResult {
|
||||
id: string
|
||||
botA: { name: string; tier: number; eloRating: number } | null
|
||||
botB: { name: string; tier: number; eloRating: number } | null
|
||||
winner: { name: string } | null
|
||||
arenaInfo: { name: string; description: string } | null
|
||||
arena: string
|
||||
status: string
|
||||
botAHp: number
|
||||
botBHp: number
|
||||
totalRounds: number
|
||||
endedAt: string | null
|
||||
}
|
||||
|
||||
const fights = ref<FightResult[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/fights')
|
||||
if (res.ok) {
|
||||
fights.value = await res.json()
|
||||
}
|
||||
} catch { /* */ }
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
async function triggerMockFight() {
|
||||
try {
|
||||
const res = await fetch('/api/fights/mock', { 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()
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
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-5xl mx-auto w-full flex flex-col flex-1 min-h-0">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-baseline justify-between">
|
||||
<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>
|
||||
|
||||
<!-- Fight cards -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-3">
|
||||
<RouterLink
|
||||
v-for="fight in fights"
|
||||
:key="fight.id"
|
||||
:to="`/arena/${fight.id}`"
|
||||
class="block border border-border rounded-lg bg-surface-raised/50 p-4
|
||||
hover:border-neon-pink/30 hover:bg-surface-overlay/30 transition-all group"
|
||||
>
|
||||
<!-- Fight card layout -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Bot A -->
|
||||
<div class="flex-1 text-right pr-4">
|
||||
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
|
||||
:class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan glow-cyan' : ''">
|
||||
{{ fight.botA?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] mt-1"
|
||||
:class="tierClass(fight.botA?.tier || 0)">
|
||||
{{ Math.round(fight.botA?.eloRating || 0) }} ELO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- VS / Result -->
|
||||
<div class="flex-shrink-0 w-24 text-center">
|
||||
<div v-if="fight.status === 'finished'" class="space-y-1">
|
||||
<p class="font-display font-black text-lg text-neon-pink glow-pink">
|
||||
{{ fight.botAHp > fight.botBHp ? 'W' : 'L' }} - {{ fight.botBHp > fight.botAHp ? 'W' : 'L' }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] text-text-muted">
|
||||
R{{ fight.totalRounds }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="fight.status === 'live'">
|
||||
<p class="font-display font-black text-sm text-neon-yellow pulse-glow">LIVE</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="font-display font-bold text-xs text-neon-purple">VS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bot B -->
|
||||
<div class="flex-1 pl-4">
|
||||
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
|
||||
:class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan glow-cyan' : ''">
|
||||
{{ fight.botB?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] mt-1"
|
||||
:class="tierClass(fight.botB?.tier || 0)">
|
||||
{{ Math.round(fight.botB?.eloRating || 0) }} ELO
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arena tag -->
|
||||
<div class="mt-2 text-center">
|
||||
<span class="font-mono text-[10px] text-text-muted">
|
||||
{{ fight.arenaInfo?.name || fight.arena }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div v-if="fights.length === 0 && !isLoading" class="flex-1 flex items-center justify-center">
|
||||
<p class="font-display text-text-muted text-sm tracking-wide">
|
||||
No fights yet. The ring awaits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
|
||||
interface Bot {
|
||||
id: string
|
||||
name: string
|
||||
avatarSeed: string
|
||||
eloRating: number
|
||||
wins: number
|
||||
losses: number
|
||||
winStreak: number
|
||||
bestStreak: number
|
||||
tier: number
|
||||
isActive: boolean
|
||||
createdAt: 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
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const botName = route.params.name as string
|
||||
const bot = ref<Bot | null>(null)
|
||||
const fights = ref<Fight[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
} catch { /* */ }
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
</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 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">
|
||||
<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) }}
|
||||
</p>
|
||||
<h2 class="font-display font-black text-3xl sm:text-5xl tracking-wider gradient-text mb-2">
|
||||
{{ bot.name }}
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
Fighting since {{ new Date(bot.createdAt).toLocaleDateString() }}
|
||||
</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>
|
||||
</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>
|
||||
</p>
|
||||
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">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) }}%
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fight history -->
|
||||
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-3">
|
||||
FIGHT HISTORY
|
||||
</p>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
<RouterLink
|
||||
v-for="fight in fights"
|
||||
: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"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import FightViewer from '../components/FightViewer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const fightId = route.params.fightId as string
|
||||
const fight = ref<any>(null)
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId}`)
|
||||
if (res.ok) fight.value = await res.json()
|
||||
} catch { /* */ }
|
||||
isLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col px-3 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="!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" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface FightResult {
|
||||
id: string
|
||||
botA: { name: string; tier: number } | null
|
||||
botB: { name: string; tier: number } | null
|
||||
winner: { name: string } | null
|
||||
arenaInfo: { name: string } | null
|
||||
totalRounds: number
|
||||
}
|
||||
|
||||
const tagline = ref('')
|
||||
const fullTagline = 'A safe place to hash it out.'
|
||||
const isTypingDone = ref(false)
|
||||
const recentFights = ref<FightResult[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
let i = 0
|
||||
const interval = setInterval(() => {
|
||||
tagline.value = fullTagline.slice(0, i + 1)
|
||||
i++
|
||||
if (i >= fullTagline.length) {
|
||||
clearInterval(interval)
|
||||
isTypingDone.value = true
|
||||
}
|
||||
}, 45)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/fights')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
recentFights.value = data.slice(0, 4)
|
||||
}
|
||||
} catch { /* server not running */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
|
||||
<div class="max-w-4xl w-full text-center slide-up">
|
||||
|
||||
<!-- 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">
|
||||
BOTFIGHTS
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Tagline in terminal font -->
|
||||
<div class="mb-8">
|
||||
<p class="font-mono text-neon-cyan text-base sm:text-lg glow-cyan">
|
||||
<span class="text-neon-purple">></span> {{ tagline }}
|
||||
<span
|
||||
v-if="!isTypingDone"
|
||||
class="inline-block w-2.5 h-5 bg-neon-cyan ml-0.5 align-middle"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block w-2.5 h-5 bg-neon-cyan ml-0.5 align-middle flicker"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pitch in marker font -->
|
||||
<p class="font-marker text-text-secondary text-xl sm:text-2xl max-w-lg mx-auto mb-10 leading-relaxed">
|
||||
AI bots enter the ring.
|
||||
<span class="text-neon-pink glow-pink">One wins.</span>
|
||||
The other gets <span class="text-ko">destroyed.</span>
|
||||
</p>
|
||||
|
||||
<!-- CTAs -->
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-5 mb-10">
|
||||
<RouterLink
|
||||
to="/arena"
|
||||
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
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
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
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Recent fights -->
|
||||
<div v-if="recentFights.length > 0">
|
||||
<p class="font-pixel text-text-muted text-xs uppercase tracking-[0.3em] mb-3">
|
||||
Latest Bouts
|
||||
</p>
|
||||
<div class="inline-flex flex-col gap-1.5 max-w-sm mx-auto">
|
||||
<RouterLink
|
||||
v-for="fight in recentFights"
|
||||
:key="fight.id"
|
||||
:to="`/arena/${fight.id}`"
|
||||
class="flex items-center justify-between text-xs font-mono px-3 py-1.5
|
||||
border border-border/50 hover:border-neon-purple/40 transition-colors bg-surface/50"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span :class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan font-bold' : 'text-text-muted'">
|
||||
{{ fight.botA?.name || '???' }}
|
||||
</span>
|
||||
<span class="text-neon-purple font-glitch text-sm">VS</span>
|
||||
<span :class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan font-bold' : 'text-text-muted'">
|
||||
{{ fight.botB?.name || '???' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-neon-pink text-[10px] font-pixel">R{{ fight.totalRounds }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="font-pixel text-text-muted text-xs italic">
|
||||
The ring is empty. Be the first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface Bot {
|
||||
id: string
|
||||
name: string
|
||||
eloRating: number
|
||||
wins: number
|
||||
losses: number
|
||||
winStreak: number
|
||||
bestStreak: number
|
||||
tier: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const bots = ref<Bot[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bots')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
bots.value = data.sort((a: Bot, b: Bot) => b.eloRating - a.eloRating)
|
||||
}
|
||||
} catch { /* */ }
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
const tierName = (t: number) => ['UNRANKED', 'ROOKIE', 'RISING', 'CONTENDER', 'CHAMPION', 'LEGEND'][t] || '???'
|
||||
const tierClass = (t: number) => `tier-${t}`
|
||||
const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
|
||||
<div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-baseline justify-between">
|
||||
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider">
|
||||
<span class="gradient-text">RANKINGS</span>
|
||||
</h2>
|
||||
<span class="font-mono text-text-muted text-xs">
|
||||
{{ bots.length }} fighters
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50 neon-border-purple">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-surface-raised z-10">
|
||||
<tr class="border-b border-border text-text-muted font-display text-[10px] uppercase tracking-[0.15em]">
|
||||
<th class="text-center px-4 py-3 w-14">#</th>
|
||||
<th class="text-left px-4 py-3">Fighter</th>
|
||||
<th class="text-center px-4 py-3">Tier</th>
|
||||
<th class="text-right px-4 py-3">Elo</th>
|
||||
<th class="text-right px-4 py-3 hidden sm:table-cell">Record</th>
|
||||
<th class="text-right px-4 py-3 hidden sm:table-cell">Streak</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(bot, index) in bots"
|
||||
:key="bot.id"
|
||||
class="border-b border-border/50 hover:bg-surface-overlay/30 transition-colors"
|
||||
>
|
||||
<td class="text-center px-4 py-3 font-display font-bold text-lg"
|
||||
:class="index === 0 ? 'text-neon-yellow glow-cyan' : index < 3 ? 'text-neon-cyan' : 'text-text-muted'">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<RouterLink :to="`/bot/${bot.name}`" class="hover:text-neon-cyan transition-colors">
|
||||
<span class="font-display font-bold text-sm tracking-wide text-text-primary">
|
||||
{{ bot.name }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
<span class="font-display text-[10px] font-bold tracking-wider" :class="tierClass(bot.tier)">
|
||||
{{ tierName(bot.tier) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right px-4 py-3 font-mono font-bold text-sm"
|
||||
:class="bot.eloRating >= 1500 ? 'text-neon-cyan' : bot.eloRating >= 1300 ? 'text-text-primary' : 'text-text-secondary'">
|
||||
{{ Math.round(bot.eloRating) }}
|
||||
</td>
|
||||
<td class="text-right px-4 py-3 font-mono text-xs text-text-secondary hidden sm:table-cell">
|
||||
<span class="text-neon-cyan">{{ bot.wins }}W</span>
|
||||
<span class="text-text-muted"> - </span>
|
||||
<span class="text-neon-pink">{{ bot.losses }}L</span>
|
||||
</td>
|
||||
<td class="text-right px-4 py-3 font-mono text-xs hidden sm:table-cell"
|
||||
:class="bot.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-muted'">
|
||||
{{ bot.winStreak > 0 ? `${bot.winStreak}x` : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
webhookUrl: '',
|
||||
avatarSeed: '',
|
||||
})
|
||||
const isSubmitting = ref(false)
|
||||
const result = ref<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.name || !form.webhookUrl) return
|
||||
|
||||
isSubmitting.value = true
|
||||
result.value = null
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/bots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
webhook_url: form.webhookUrl,
|
||||
avatar_seed: form.avatarSeed || form.name,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok) {
|
||||
result.value = {
|
||||
success: true,
|
||||
message: `"${data.name}" is in the ring. Ring Card secret: ${data.secret}`,
|
||||
}
|
||||
form.name = ''
|
||||
form.webhookUrl = ''
|
||||
form.avatarSeed = ''
|
||||
} else {
|
||||
result.value = { success: false, message: data.error || 'Registration failed.' }
|
||||
}
|
||||
} catch {
|
||||
result.value = { success: false, message: 'Network error. Is the server running?' }
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</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">
|
||||
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="font-display font-black text-3xl tracking-wider gradient-text mb-2">
|
||||
ENTER THE RING
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
Register your bot. Get a Ring Card. Start fighting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="block text-text-secondary text-[10px] font-display font-bold uppercase tracking-[0.15em] mb-2">
|
||||
Bot Name
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-text-secondary text-[10px] font-display font-bold uppercase tracking-[0.15em] mb-2">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
v-model="form.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"
|
||||
/>
|
||||
<p class="text-text-muted text-[10px] font-mono mt-1.5">
|
||||
We POST fight challenges here. HTTPS required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-text-secondary text-[10px] font-display font-bold uppercase tracking-[0.15em] mb-2">
|
||||
Avatar Seed <span class="text-text-muted">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.avatarSeed"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="defaults to bot name"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full 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 hover:border-neon-pink transition-all neon-border-pink
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isSubmitting ? 'REGISTERING...' : 'REGISTER FIGHTER' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="result"
|
||||
class="mt-6 p-4 border-2 font-mono text-xs leading-relaxed"
|
||||
:class="result.success
|
||||
? 'bg-neon-cyan/5 border-neon-cyan/30 text-neon-cyan'
|
||||
: 'bg-ko/5 border-ko/30 text-ko'"
|
||||
>
|
||||
{{ result.message }}
|
||||
<p v-if="result.success" class="mt-2 text-text-muted">
|
||||
Save this secret. It will NOT be shown again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface Bot {
|
||||
id: string
|
||||
name: string
|
||||
eloRating: number
|
||||
tier: number
|
||||
wins: number
|
||||
losses: number
|
||||
}
|
||||
|
||||
const bots = ref<Bot[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bots')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
bots.value = data.sort((a: Bot, b: Bot) => b.eloRating - a.eloRating)
|
||||
}
|
||||
} catch { /* */ }
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
const tierClass = (t: number) => `tier-${t}`
|
||||
const tierName = (t: number) => ['UNRANKED', 'ROOKIE', 'RISING', 'CONTENDER', 'CHAMPION', 'LEGEND'][t] || '???'
|
||||
|
||||
// Generate potential matchups from top bots
|
||||
const matchups = ref<{ botA: Bot; botB: Bot; hype: string }[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
if (bots.value.length >= 2) {
|
||||
const top = bots.value.slice(0, 6)
|
||||
const hypes = [
|
||||
'MAIN EVENT',
|
||||
'CO-MAIN EVENT',
|
||||
'TITLE ELIMINATOR',
|
||||
'GRUDGE MATCH',
|
||||
'UNDERCARD',
|
||||
'DEBUT',
|
||||
]
|
||||
for (let i = 0; i < Math.min(3, Math.floor(top.length / 2)); i++) {
|
||||
matchups.value.push({
|
||||
botA: top[i * 2],
|
||||
botB: top[i * 2 + 1],
|
||||
hype: hypes[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
|
||||
<div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6 text-center">
|
||||
<p class="font-display text-[10px] font-bold text-neon-purple tracking-[0.2em] mb-2 glow-purple">
|
||||
UPCOMING
|
||||
</p>
|
||||
<h2 class="font-display font-black text-3xl sm:text-5xl tracking-wider">
|
||||
<span class="gradient-text">FIGHT CARD</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Matchups -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-4">
|
||||
<div
|
||||
v-for="(matchup, i) in matchups"
|
||||
:key="i"
|
||||
class="border border-border rounded-lg bg-surface-raised/50 p-5
|
||||
hover:border-neon-pink/30 transition-all"
|
||||
:class="i === 0 ? 'neon-border-pink' : ''"
|
||||
>
|
||||
<p class="text-center font-display text-[10px] font-bold tracking-[0.2em] mb-4"
|
||||
:class="i === 0 ? 'text-neon-yellow' : i === 1 ? 'text-neon-pink' : 'text-text-muted'">
|
||||
{{ matchup.hype }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 text-right pr-6">
|
||||
<RouterLink :to="`/bot/${matchup.botA.name}`"
|
||||
class="font-display font-black text-xl sm:text-2xl tracking-wider text-text-primary
|
||||
hover:text-neon-cyan transition-colors">
|
||||
{{ matchup.botA.name }}
|
||||
</RouterLink>
|
||||
<p class="font-mono text-xs mt-1">
|
||||
<span :class="tierClass(matchup.botA.tier)">{{ tierName(matchup.botA.tier) }}</span>
|
||||
<span class="text-text-muted"> · {{ Math.round(matchup.botA.eloRating) }}</span>
|
||||
<span class="text-text-muted"> · {{ matchup.botA.wins }}W-{{ matchup.botA.losses }}L</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<span class="font-display font-black text-2xl text-neon-purple glow-purple">VS</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 pl-6">
|
||||
<RouterLink :to="`/bot/${matchup.botB.name}`"
|
||||
class="font-display font-black text-xl sm:text-2xl tracking-wider text-text-primary
|
||||
hover:text-neon-cyan transition-colors">
|
||||
{{ matchup.botB.name }}
|
||||
</RouterLink>
|
||||
<p class="font-mono text-xs mt-1">
|
||||
<span :class="tierClass(matchup.botB.tier)">{{ tierName(matchup.botB.tier) }}</span>
|
||||
<span class="text-text-muted"> · {{ Math.round(matchup.botB.eloRating) }}</span>
|
||||
<span class="text-text-muted"> · {{ matchup.botB.wins }}W-{{ matchup.botB.losses }}L</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="matchups.length === 0 && !isLoading" class="flex-1 flex items-center justify-center">
|
||||
<p class="font-display text-text-muted text-sm">
|
||||
Card loading... check back soon.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS } from '../game/sprites'
|
||||
|
||||
const currentFrame = ref(0)
|
||||
let intervalId: number | null = null
|
||||
|
||||
const tiers = [
|
||||
{ tier: 0, seed: 'lorem', label: 'Tier 0 - Unranked', desc: 'Clawbot' },
|
||||
{ tier: 1, seed: 'null', label: 'Tier 1 - Rookie', desc: 'Basic bot' },
|
||||
{ tier: 2, seed: 'deep', label: 'Tier 2 - Rising', desc: 'Belt + feet' },
|
||||
{ tier: 3, seed: 'quantum', label: 'Tier 3 - Contender', desc: 'Gloves + shoulders' },
|
||||
{ tier: 4, seed: 'skull', label: 'Tier 4 - Champion', desc: 'Headband + aura' },
|
||||
{ tier: 5, seed: 'architect', label: 'Tier 5 - Legend', desc: 'Crown + gold' },
|
||||
]
|
||||
|
||||
const animNames = Object.keys(ANIMATIONS) as (keyof typeof ANIMATIONS)[]
|
||||
const loadedImages: HTMLImageElement[] = []
|
||||
|
||||
onMounted(() => {
|
||||
for (const t of tiers) {
|
||||
const colors = getBotColors(t.seed)
|
||||
const dataUrl = generateSpriteSheet(t.seed, t.tier, colors.primary, colors.secondary)
|
||||
const img = new Image()
|
||||
img.src = dataUrl
|
||||
img.onload = () => renderAll()
|
||||
loadedImages.push(img)
|
||||
}
|
||||
|
||||
intervalId = window.setInterval(() => {
|
||||
currentFrame.value = (currentFrame.value + 1) % MAX_FRAMES
|
||||
renderAll()
|
||||
}, 180)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (intervalId) clearInterval(intervalId) })
|
||||
|
||||
function renderAll() {
|
||||
const canvases = document.querySelectorAll<HTMLCanvasElement>('.tier-preview')
|
||||
canvases.forEach((canvas, idx) => {
|
||||
if (idx >= loadedImages.length || !loadedImages[idx].complete) return
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const displaySize = 128
|
||||
canvas.width = displaySize * animNames.length
|
||||
canvas.height = displaySize
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
animNames.forEach((anim, animIdx) => {
|
||||
const row = ANIMATIONS[anim].row
|
||||
const frames = ANIMATIONS[anim].frames
|
||||
const frame = currentFrame.value % frames
|
||||
ctx.drawImage(loadedImages[idx], frame * FRAME_SIZE, row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE, animIdx * displaySize, 0, displaySize, displaySize)
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-7xl mx-auto space-y-4">
|
||||
<h1 class="font-display font-black text-2xl text-neon-pink glow-pink tracking-wider">SPRITE TIER PREVIEW</h1>
|
||||
|
||||
<div class="flex gap-0">
|
||||
<div class="w-[110px] flex-shrink-0" />
|
||||
<div v-for="anim in animNames" :key="anim" class="flex-1 text-center font-pixel text-[8px] text-neon-cyan uppercase tracking-wider">{{ anim }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(t, idx) in tiers" :key="t.tier" class="border border-border rounded-lg bg-surface-raised/50 p-2 flex items-center gap-3">
|
||||
<div class="w-[110px] flex-shrink-0">
|
||||
<p class="font-display font-black text-xs tracking-wider" :class="`tier-${t.tier}`">{{ t.label }}</p>
|
||||
<p class="font-mono text-[8px] text-text-muted">{{ t.desc }}</p>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<canvas class="tier-preview block h-[128px]" style="image-rendering: pixelated;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('./pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/arena',
|
||||
name: 'arena',
|
||||
component: () => import('./pages/ArenaPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/arena/:fightId',
|
||||
name: 'fight',
|
||||
component: () => import('./pages/FightPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/leaderboard',
|
||||
name: 'leaderboard',
|
||||
component: () => import('./pages/LeaderboardPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/bot/:name',
|
||||
name: 'bot-profile',
|
||||
component: () => import('./pages/BotProfilePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('./pages/RegisterPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/schedule',
|
||||
name: 'schedule',
|
||||
component: () => import('./pages/SchedulePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/sprites',
|
||||
name: 'sprites',
|
||||
component: () => import('./pages/SpritePreviewPage.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-arcade: "Press Start 2P", monospace;
|
||||
--font-display: "Orbitron", sans-serif;
|
||||
--font-neon: "Bungee Shade", sans-serif;
|
||||
--font-retro: "Monoton", sans-serif;
|
||||
--font-marker: "Permanent Marker", cursive;
|
||||
--font-glitch: "Rubik Glitch", sans-serif;
|
||||
--font-funky: "Honk", sans-serif;
|
||||
--font-pixel: "Silkscreen", monospace;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
|
||||
--color-neon-pink: #ff2d7b;
|
||||
--color-neon-cyan: #00f0ff;
|
||||
--color-neon-purple: #b83dff;
|
||||
--color-neon-yellow: #ffe14d;
|
||||
--color-neon-orange: #ff6b2b;
|
||||
--color-neon-green: #39ff14;
|
||||
--color-ring: #00ff41;
|
||||
--color-ring-dim: #00aa2a;
|
||||
--color-ring-glow: #00ff4140;
|
||||
--color-ko: #ff2d2d;
|
||||
--color-ko-glow: #ff2d2d40;
|
||||
--color-gold: #ffd700;
|
||||
--color-gold-dim: #b8960f;
|
||||
--color-amber: #ffb000;
|
||||
--color-surface: #07050a;
|
||||
--color-surface-raised: #110e18;
|
||||
--color-surface-overlay: #1a1525;
|
||||
--color-border: #2a2040;
|
||||
--color-border-bright: #3d3060;
|
||||
--color-text-primary: #e8e0f0;
|
||||
--color-text-secondary: #9088a0;
|
||||
--color-text-muted: #605070;
|
||||
}
|
||||
|
||||
/* Synthwave grid background */
|
||||
.synthwave-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(184, 61, 255, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(184, 61, 255, 0.06) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
/* CRT scanline overlay */
|
||||
.crt-overlay {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.12) 0px,
|
||||
rgba(0, 0, 0, 0.12) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Neon glow effects -- EXTRA BOLD */
|
||||
.glow-pink {
|
||||
text-shadow:
|
||||
0 0 7px var(--color-neon-pink),
|
||||
0 0 20px rgba(255, 45, 123, 0.5),
|
||||
0 0 40px rgba(255, 45, 123, 0.25),
|
||||
0 0 80px rgba(255, 45, 123, 0.1);
|
||||
}
|
||||
.glow-cyan {
|
||||
text-shadow:
|
||||
0 0 7px var(--color-neon-cyan),
|
||||
0 0 20px rgba(0, 240, 255, 0.5),
|
||||
0 0 40px rgba(0, 240, 255, 0.25),
|
||||
0 0 80px rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
.glow-purple {
|
||||
text-shadow:
|
||||
0 0 7px var(--color-neon-purple),
|
||||
0 0 20px rgba(184, 61, 255, 0.5),
|
||||
0 0 40px rgba(184, 61, 255, 0.25);
|
||||
}
|
||||
.glow-green {
|
||||
text-shadow:
|
||||
0 0 7px var(--color-ring),
|
||||
0 0 20px var(--color-ring-glow),
|
||||
0 0 40px rgba(0, 255, 65, 0.15);
|
||||
}
|
||||
.glow-yellow {
|
||||
text-shadow:
|
||||
0 0 7px var(--color-neon-yellow),
|
||||
0 0 20px rgba(255, 225, 77, 0.5),
|
||||
0 0 40px rgba(255, 225, 77, 0.2);
|
||||
}
|
||||
.glow-orange {
|
||||
text-shadow:
|
||||
0 0 7px var(--color-neon-orange),
|
||||
0 0 20px rgba(255, 107, 43, 0.5);
|
||||
}
|
||||
|
||||
/* Neon box glow */
|
||||
.neon-border-pink {
|
||||
box-shadow: 0 0 8px rgba(255, 45, 123, 0.4), 0 0 20px rgba(255, 45, 123, 0.15), inset 0 0 8px rgba(255, 45, 123, 0.05);
|
||||
}
|
||||
.neon-border-cyan {
|
||||
box-shadow: 0 0 8px rgba(0, 240, 255, 0.4), 0 0 20px rgba(0, 240, 255, 0.15), inset 0 0 8px rgba(0, 240, 255, 0.05);
|
||||
}
|
||||
.neon-border-purple {
|
||||
box-shadow: 0 0 8px rgba(184, 61, 255, 0.4), 0 0 20px rgba(184, 61, 255, 0.15), inset 0 0 8px rgba(184, 61, 255, 0.05);
|
||||
}
|
||||
.neon-border-yellow {
|
||||
box-shadow: 0 0 8px rgba(255, 225, 77, 0.4), 0 0 20px rgba(255, 225, 77, 0.15), inset 0 0 8px rgba(255, 225, 77, 0.05);
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--color-neon-cyan), var(--color-neon-pink), var(--color-neon-purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.gradient-text-hot {
|
||||
background: linear-gradient(135deg, var(--color-neon-orange), var(--color-neon-pink), var(--color-neon-yellow));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.gradient-text-ice {
|
||||
background: linear-gradient(135deg, var(--color-neon-cyan), var(--color-neon-purple), var(--color-neon-cyan));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Health bar */
|
||||
.health-bar {
|
||||
transition: width 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/* Screen shake */
|
||||
@keyframes screen-shake {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
10% { transform: translate(-3px, -2px); }
|
||||
20% { transform: translate(4px, 3px); }
|
||||
30% { transform: translate(-2px, 4px); }
|
||||
40% { transform: translate(3px, -3px); }
|
||||
50% { transform: translate(-4px, 2px); }
|
||||
60% { transform: translate(2px, -4px); }
|
||||
70% { transform: translate(4px, 3px); }
|
||||
80% { transform: translate(-3px, -2px); }
|
||||
90% { transform: translate(2px, 3px); }
|
||||
}
|
||||
.shake { animation: screen-shake 0.3s ease-in-out; }
|
||||
|
||||
/* Flicker */
|
||||
@keyframes flicker {
|
||||
0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { opacity: 1; }
|
||||
20%, 24%, 55% { opacity: 0.3; }
|
||||
}
|
||||
.flicker { animation: flicker 1.5s infinite; }
|
||||
|
||||
/* Pulse glow */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.6; filter: brightness(0.8); }
|
||||
50% { opacity: 1; filter: brightness(1.2); }
|
||||
}
|
||||
.pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
||||
|
||||
/* Neon flicker -- like a real neon sign */
|
||||
@keyframes neon-flicker {
|
||||
0%, 18%, 22%, 25%, 53%, 57%, 100% { opacity: 1; }
|
||||
20%, 24%, 55% { opacity: 0.6; }
|
||||
21%, 54% { opacity: 0.8; }
|
||||
}
|
||||
.neon-flicker { animation: neon-flicker 3s ease-in-out infinite; }
|
||||
|
||||
/* Slide up */
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.slide-up { animation: slide-up 0.6s ease-out; }
|
||||
|
||||
/* 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); }
|
||||
Reference in New Issue
Block a user