feat: profile page character display, persistent auth, cleanup
- Large animated sprite on BotProfilePage with tier-aware rendering - Dynamic status titles (UNSTOPPABLE, ON FIRE, FRESH MEAT, etc.) - Nostr auth persisted to localStorage — survives navigation and HMR - Remove grotesque close-up overlays (eyeballs, tongues, teeth, drool) - Remove crowd cheering signs (too small to look good) - Add archetype to bot stats API response - SpritePreview now accepts tier prop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e49735002c
commit
4a35e1e0b5
@@ -5,6 +5,7 @@ import { generateSpriteSheet, getBotColors, FRAME_SIZE, ANIMATIONS } from '../ga
|
||||
const props = defineProps<{
|
||||
seed: string
|
||||
archetype?: string
|
||||
tier?: number
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
@@ -36,7 +37,7 @@ function render() {
|
||||
|
||||
function loadSprite() {
|
||||
const colors = getBotColors(props.seed)
|
||||
const dataUrl = generateSpriteSheet(props.seed, 0, colors.primary, colors.secondary, props.archetype)
|
||||
const dataUrl = generateSpriteSheet(props.seed, props.tier || 0, colors.primary, colors.secondary, props.archetype)
|
||||
img = new Image()
|
||||
img.onload = () => render()
|
||||
img.src = dataUrl
|
||||
|
||||
@@ -26,15 +26,41 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const pubkey = ref<string | null>(null)
|
||||
const bot = ref<BotData | null>(null)
|
||||
const profilePicUrl = ref<string | null>(null)
|
||||
// Persist auth state across page navigations and HMR reloads
|
||||
function loadStored<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch { return null }
|
||||
}
|
||||
function store(key: string, value: unknown) {
|
||||
if (value == null) localStorage.removeItem(key)
|
||||
else localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
const pubkey = ref<string | null>(loadStored('bf_pubkey'))
|
||||
const bot = ref<BotData | null>(loadStored('bf_bot'))
|
||||
const profilePicUrl = ref<string | null>(loadStored('bf_pic'))
|
||||
const isLoading = ref(false)
|
||||
|
||||
export function useNostr() {
|
||||
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
|
||||
const hasExtension = computed(() => !!window.nostr)
|
||||
|
||||
// Restore session on first load — re-verify with server
|
||||
if (pubkey.value && !bot.value) {
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.exists) {
|
||||
bot.value = data.bot
|
||||
store('bf_bot', data.bot)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
if (!window.nostr) {
|
||||
throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.')
|
||||
@@ -44,10 +70,11 @@ export function useNostr() {
|
||||
try {
|
||||
const pk = await window.nostr.getPublicKey()
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
// Fetch Nostr profile pic from relay
|
||||
const pic = await fetchNostrProfilePic(pk)
|
||||
if (pic) profilePicUrl.value = pic
|
||||
if (pic) { profilePicUrl.value = pic; store('bf_pic', pic) }
|
||||
|
||||
// Check if this pubkey has a bot
|
||||
const res = await fetch('/api/auth/login', {
|
||||
@@ -60,6 +87,7 @@ export function useNostr() {
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
bot.value = data.bot
|
||||
store('bf_bot', data.bot)
|
||||
return { pubkey: pk, bot: data.bot }
|
||||
}
|
||||
}
|
||||
@@ -101,6 +129,7 @@ export function useNostr() {
|
||||
bestStreak: 0,
|
||||
tier: 0,
|
||||
}
|
||||
store('bf_bot', bot.value)
|
||||
|
||||
return bot.value
|
||||
}
|
||||
@@ -109,6 +138,9 @@ export function useNostr() {
|
||||
pubkey.value = null
|
||||
bot.value = null
|
||||
profilePicUrl.value = null
|
||||
store('bf_pubkey', null)
|
||||
store('bf_bot', null)
|
||||
store('bf_pic', null)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -548,265 +548,9 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
|
||||
}
|
||||
|
||||
// === Ren & Stimpy style grotesque close-up overlays ===
|
||||
// When bots scale up big, we overlay gross details: bulging eyes, veins, teeth, sweat
|
||||
let grotesqueObjects: any[] = []
|
||||
|
||||
function spawnGrotesqueDetails(fighter: any, scaleFactor: number) {
|
||||
const cx = fighter.pos.x
|
||||
const cy = fighter.pos.y
|
||||
const dir = fighter.scale.x > 0 ? 1 : -1
|
||||
const sz = scaleFactor // bigger = more detail
|
||||
|
||||
// Bulging bloodshot eye (the iconic Ren & Stimpy look)
|
||||
const eyeX = cx + dir * 8 * sz
|
||||
const eyeY = cy - 35 * sz
|
||||
const eyeWhite = k.add([
|
||||
k.circle(7 * sz), k.pos(eyeX, eyeY),
|
||||
k.color(safeColor(k,'#ffffdd')), k.opacity(0.85), k.z(32),
|
||||
])
|
||||
grotesqueObjects.push(eyeWhite)
|
||||
|
||||
// Bloodshot veins on eye
|
||||
for (let v = 0; v < 4; v++) {
|
||||
const angle = (v / 4) * Math.PI * 2 + Math.random() * 0.5
|
||||
const vLen = 4 * sz + Math.random() * 3 * sz
|
||||
const vein = k.add([
|
||||
k.rect(vLen, 0.8 * sz), k.pos(eyeX, eyeY),
|
||||
k.color(safeColor(k,'#cc2222')), k.opacity(0.7), k.z(33),
|
||||
k.rotate(angle * 180 / Math.PI),
|
||||
])
|
||||
grotesqueObjects.push(vein)
|
||||
}
|
||||
|
||||
// Pupil — twitchy
|
||||
const pupil = k.add([
|
||||
k.circle(3 * sz), k.pos(eyeX + dir * 2 * sz, eyeY),
|
||||
k.color(safeColor(k,'#111111')), k.opacity(0.9), k.z(34),
|
||||
])
|
||||
pupil.onUpdate(() => {
|
||||
pupil.pos.x = eyeX + dir * 2 * sz + Math.sin(k.time() * 12) * sz
|
||||
pupil.pos.y = eyeY + Math.cos(k.time() * 9) * 0.8 * sz
|
||||
})
|
||||
grotesqueObjects.push(pupil)
|
||||
|
||||
// Second eye (smaller, off-axis for grotesque asymmetry)
|
||||
const eye2X = cx - dir * 4 * sz
|
||||
const eye2Y = cy - 33 * sz
|
||||
const eye2 = k.add([
|
||||
k.circle(5 * sz), k.pos(eye2X, eye2Y),
|
||||
k.color(safeColor(k,'#ffffcc')), k.opacity(0.8), k.z(32),
|
||||
])
|
||||
const pupil2 = k.add([
|
||||
k.circle(2.5 * sz), k.pos(eye2X - dir * sz, eye2Y + sz),
|
||||
k.color(safeColor(k,'#111111')), k.opacity(0.85), k.z(34),
|
||||
])
|
||||
pupil2.onUpdate(() => {
|
||||
pupil2.pos.x = eye2X - dir * sz + Math.sin(k.time() * 15 + 1) * 0.8 * sz
|
||||
pupil2.pos.y = eye2Y + sz + Math.cos(k.time() * 11 + 1) * 0.6 * sz
|
||||
})
|
||||
grotesqueObjects.push(eye2, pupil2)
|
||||
|
||||
// Grimacing teeth / grin
|
||||
const teethY = cy - 18 * sz
|
||||
for (let t = 0; t < 5; t++) {
|
||||
const tx = cx + (t - 2) * 4 * sz * dir
|
||||
const tooth = k.add([
|
||||
k.rect(3 * sz, 4 * sz + Math.random() * 2 * sz),
|
||||
k.pos(tx, teethY),
|
||||
k.color(safeColor(k,Math.random() > 0.3 ? '#ffffcc' : '#cccc88')),
|
||||
k.opacity(0.8), k.z(33),
|
||||
])
|
||||
grotesqueObjects.push(tooth)
|
||||
}
|
||||
|
||||
// Forehead veins — pulsing
|
||||
for (let v = 0; v < 3; v++) {
|
||||
const vx = cx + (Math.random() - 0.5) * 18 * sz
|
||||
const vy = cy - 42 * sz - Math.random() * 8 * sz
|
||||
const vein = k.add([
|
||||
k.rect(8 * sz + Math.random() * 6 * sz, 0.7 * sz),
|
||||
k.pos(vx, vy),
|
||||
k.color(safeColor(k,'#6633aa')),
|
||||
k.opacity(0.4), k.z(31),
|
||||
k.rotate(-20 + Math.random() * 40),
|
||||
])
|
||||
vein.onUpdate(() => {
|
||||
vein.opacity = 0.3 + Math.sin(k.time() * 6 + v * 2) * 0.15
|
||||
})
|
||||
grotesqueObjects.push(vein)
|
||||
}
|
||||
|
||||
// Sweat drops — animated falling
|
||||
for (let s = 0; s < 2 + Math.floor(Math.random() * 2); s++) {
|
||||
const sx = cx + (Math.random() - 0.5) * 20 * sz
|
||||
const sy = cy - 40 * sz - Math.random() * 10 * sz
|
||||
const drop = k.add([
|
||||
k.circle(1.5 * sz), k.pos(sx, sy),
|
||||
k.color(safeColor(k,'#88ccff')), k.opacity(0.7), k.z(35),
|
||||
])
|
||||
drop.onUpdate(() => {
|
||||
drop.pos.y += 40 * sz * k.dt()
|
||||
drop.opacity -= 0.8 * k.dt()
|
||||
if (drop.opacity <= 0 && drop.exists()) drop.destroy()
|
||||
})
|
||||
grotesqueObjects.push(drop)
|
||||
}
|
||||
|
||||
// Nostril flare
|
||||
const nostrilY = cy - 24 * sz
|
||||
for (let n = 0; n < 2; n++) {
|
||||
const nx = cx + (n === 0 ? -2 : 2) * sz * dir
|
||||
const nostril = k.add([
|
||||
k.circle(1.8 * sz), k.pos(nx, nostrilY),
|
||||
k.color(safeColor(k,'#331111')), k.opacity(0.6), k.z(33),
|
||||
k.scale(1),
|
||||
])
|
||||
nostril.onUpdate(() => {
|
||||
const pulse = 1 + Math.sin(k.time() * 8) * 0.3
|
||||
nostril.scale.x = pulse; nostril.scale.y = pulse
|
||||
})
|
||||
grotesqueObjects.push(nostril)
|
||||
}
|
||||
|
||||
// === RANDOM EXTRA EXPRESSION (varies each close-up) ===
|
||||
const expression = Math.floor(Math.random() * 6)
|
||||
|
||||
if (expression === 0) {
|
||||
// RAGE FACE — eyebrows angled down, mouth wide open, steam from ears
|
||||
const browL = k.add([
|
||||
k.rect(8 * sz, 1.5 * sz), k.pos(eyeX - 4 * sz, eyeY - 6 * sz),
|
||||
k.color(safeColor(k,'#442200')), k.opacity(0.8), k.z(35),
|
||||
k.rotate(dir > 0 ? 25 : -25),
|
||||
])
|
||||
const browR = k.add([
|
||||
k.rect(8 * sz, 1.5 * sz), k.pos(eye2X - 4 * sz, eye2Y - 5 * sz),
|
||||
k.color(safeColor(k,'#442200')), k.opacity(0.8), k.z(35),
|
||||
k.rotate(dir > 0 ? -25 : 25),
|
||||
])
|
||||
grotesqueObjects.push(browL, browR)
|
||||
// Steam puffs from ears
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const earX = cx + dir * 18 * sz
|
||||
const puff = k.add([
|
||||
k.circle(2 * sz + s * sz), k.pos(earX, cy - 30 * sz - s * 5 * sz),
|
||||
k.color(safeColor(k,'#cccccc')), k.opacity(0.5), k.z(36),
|
||||
])
|
||||
puff.onUpdate(() => {
|
||||
puff.pos.y -= 15 * sz * k.dt()
|
||||
puff.opacity -= 0.6 * k.dt()
|
||||
if (puff.opacity <= 0 && puff.exists()) puff.destroy()
|
||||
})
|
||||
grotesqueObjects.push(puff)
|
||||
}
|
||||
} else if (expression === 1) {
|
||||
// TONGUE OUT — big pink tongue lolling, drool drops
|
||||
const tongueX = cx + dir * 2 * sz
|
||||
const tongueY = cy - 14 * sz
|
||||
const tongue = k.add([
|
||||
k.rect(5 * sz, 10 * sz, { radius: 3 * sz }), k.pos(tongueX, tongueY),
|
||||
k.color(safeColor(k,'#ff6688')), k.opacity(0.8), k.z(34),
|
||||
])
|
||||
tongue.onUpdate(() => {
|
||||
tongue.pos.x = tongueX + Math.sin(k.time() * 6) * sz
|
||||
tongue.pos.y = tongueY + Math.sin(k.time() * 4) * 0.5 * sz
|
||||
})
|
||||
grotesqueObjects.push(tongue)
|
||||
// Drool
|
||||
const drool = k.add([
|
||||
k.circle(1.2 * sz), k.pos(tongueX + 2 * sz, tongueY + 10 * sz),
|
||||
k.color(safeColor(k,'#88ccff')), k.opacity(0.6), k.z(35),
|
||||
])
|
||||
drool.onUpdate(() => {
|
||||
drool.pos.y += 25 * sz * k.dt()
|
||||
drool.opacity -= 0.4 * k.dt()
|
||||
if (drool.opacity <= 0 && drool.exists()) drool.destroy()
|
||||
})
|
||||
grotesqueObjects.push(drool)
|
||||
} else if (expression === 2) {
|
||||
// CROSS-EYED — both pupils drift toward center
|
||||
pupil.onUpdate(() => {
|
||||
pupil.pos.x = eyeX - dir * 3 * sz + Math.sin(k.time() * 3) * 0.5 * sz
|
||||
pupil.pos.y = eyeY + 2 * sz
|
||||
})
|
||||
pupil2.onUpdate(() => {
|
||||
pupil2.pos.x = eye2X + dir * 3 * sz + Math.sin(k.time() * 3 + 1) * 0.5 * sz
|
||||
pupil2.pos.y = eye2Y + 2 * sz
|
||||
})
|
||||
} else if (expression === 3) {
|
||||
// CRYING — tear streams + quivering lip
|
||||
for (let side = 0; side < 2; side++) {
|
||||
const tearBaseX = side === 0 ? eyeX : eye2X
|
||||
const tearBaseY = (side === 0 ? eyeY : eye2Y) + 5 * sz
|
||||
for (let t = 0; t < 3; t++) {
|
||||
const tear = k.add([
|
||||
k.circle(1.2 * sz), k.pos(tearBaseX, tearBaseY),
|
||||
k.color(safeColor(k,'#4488ff')), k.opacity(0.7), k.z(36),
|
||||
])
|
||||
const startDelay = t * 0.3
|
||||
let elapsed = -startDelay
|
||||
tear.onUpdate(() => {
|
||||
elapsed += k.dt()
|
||||
if (elapsed < 0) return
|
||||
tear.pos.y = tearBaseY + elapsed * 40 * sz
|
||||
tear.opacity = 0.7 - elapsed * 0.8
|
||||
if (tear.opacity <= 0 && tear.exists()) tear.destroy()
|
||||
})
|
||||
grotesqueObjects.push(tear)
|
||||
}
|
||||
}
|
||||
// Quivering lower lip
|
||||
const lip = k.add([
|
||||
k.rect(10 * sz, 2 * sz, { radius: sz }), k.pos(cx - 5 * sz, cy - 16 * sz),
|
||||
k.color(safeColor(k,'#cc4466')), k.opacity(0.6), k.z(34),
|
||||
])
|
||||
lip.onUpdate(() => { lip.pos.y = cy - 16 * sz + Math.sin(k.time() * 20) * 0.5 * sz })
|
||||
grotesqueObjects.push(lip)
|
||||
} else if (expression === 4) {
|
||||
// SCARED FACE — wide eyes (pupils tiny), mouth agape
|
||||
// Shrink pupils
|
||||
pupil.onUpdate(() => {
|
||||
pupil.pos.x = eyeX + Math.sin(k.time() * 20) * 2 * sz
|
||||
pupil.pos.y = eyeY + Math.cos(k.time() * 18) * 1.5 * sz
|
||||
})
|
||||
// Giant open mouth
|
||||
const mouth = k.add([
|
||||
k.circle(6 * sz), k.pos(cx, cy - 16 * sz),
|
||||
k.color(safeColor(k,'#110000')), k.opacity(0.7), k.z(33),
|
||||
k.scale(1),
|
||||
])
|
||||
mouth.onUpdate(() => {
|
||||
const pulse = 1 + Math.sin(k.time() * 10) * 0.15
|
||||
mouth.scale.x = pulse; mouth.scale.y = pulse * 0.7
|
||||
})
|
||||
grotesqueObjects.push(mouth)
|
||||
} else {
|
||||
// SMUG GRIN — half-lidded eyes, wide smirk
|
||||
// Eyelids (half cover the eyes)
|
||||
const lidL = k.add([
|
||||
k.rect(16 * sz, 5 * sz), k.pos(eyeX - 8 * sz, eyeY - 6 * sz),
|
||||
k.color(safeColor(k,'#886644')), k.opacity(0.5), k.z(35),
|
||||
])
|
||||
const lidR = k.add([
|
||||
k.rect(12 * sz, 4 * sz), k.pos(eye2X - 6 * sz, eye2Y - 5 * sz),
|
||||
k.color(safeColor(k,'#886644')), k.opacity(0.5), k.z(35),
|
||||
])
|
||||
// Wide smirk
|
||||
const smirk = k.add([
|
||||
k.rect(14 * sz, 2 * sz, { radius: sz }), k.pos(cx - 3 * sz * dir, cy - 19 * sz),
|
||||
k.color(safeColor(k,'#cc3344')), k.opacity(0.7), k.z(34),
|
||||
k.rotate(dir > 0 ? 10 : -10),
|
||||
])
|
||||
grotesqueObjects.push(lidL, lidR, smirk)
|
||||
}
|
||||
}
|
||||
|
||||
function destroyGrotesqueDetails() {
|
||||
for (const obj of grotesqueObjects) {
|
||||
if (obj.exists()) obj.destroy()
|
||||
}
|
||||
grotesqueObjects = []
|
||||
}
|
||||
// Grotesque close-up stubs (disabled — looked bad)
|
||||
function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
|
||||
function destroyGrotesqueDetails() {}
|
||||
|
||||
// Arena-specific background decoration
|
||||
function drawArenaDecor() {
|
||||
@@ -5577,36 +5321,8 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
k.tween(0.8, 0, 1.5, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
|
||||
}
|
||||
|
||||
// Crowd cheering signs pop up from bottom
|
||||
function spawnCrowdSigns(count: number, color: string, text?: string) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sx = 20 + Math.random() * (W - 40)
|
||||
const sy = GROUND_Y + 10 + Math.random() * 20
|
||||
const sign = k.add([
|
||||
k.rect(18 + Math.random() * 12, 10 + Math.random() * 6),
|
||||
k.pos(sx, sy), k.color(safeColor(k,color)),
|
||||
k.opacity(0.7), k.z(2), k.anchor('center'),
|
||||
])
|
||||
const stick = k.add([
|
||||
k.rect(2, 12), k.pos(sx, sy + 8),
|
||||
k.color(safeColor(k,'#aa8855')), k.opacity(0.6), k.z(1), k.anchor('center'),
|
||||
])
|
||||
const startY = sy
|
||||
sign.onUpdate(() => {
|
||||
sign.pos.y = startY + Math.sin(k.time() * (3 + i * 0.5)) * 5
|
||||
stick.pos.y = sign.pos.y + 8
|
||||
})
|
||||
if (text) {
|
||||
const label = k.add([
|
||||
k.text(safeText(text), { size: 6 }), k.pos(sx, sy),
|
||||
k.color(safeColor(k,'#000000')), k.opacity(0.8), k.z(3), k.anchor('center'),
|
||||
])
|
||||
label.onUpdate(() => { label.pos.x = sign.pos.x; label.pos.y = sign.pos.y })
|
||||
setTimeout(() => { if (label.exists()) label.destroy() }, 2500)
|
||||
}
|
||||
setTimeout(() => { if (sign.exists()) sign.destroy(); if (stick.exists()) stick.destroy() }, 2000 + Math.random() * 1000)
|
||||
}
|
||||
}
|
||||
// Crowd signs stub (disabled — too small to look good)
|
||||
function spawnCrowdSigns(_count: number, _color: string, _text?: string) {}
|
||||
|
||||
// Respectful bow animation
|
||||
async function playBow(fighter: any) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -12,6 +13,7 @@ interface BotStats {
|
||||
id: string
|
||||
name: string
|
||||
avatarSeed: string
|
||||
archetype: string
|
||||
profilePicUrl: string | null
|
||||
eloRating: number
|
||||
wins: number
|
||||
@@ -36,6 +38,19 @@ interface BotStats {
|
||||
}[]
|
||||
}
|
||||
|
||||
function getStatusTitle(s: BotStats): string {
|
||||
if (s.totalFights === 0) return 'FRESH MEAT'
|
||||
if (s.winStreak >= 10) return 'UNSTOPPABLE'
|
||||
if (s.winStreak >= 5) return 'ON FIRE'
|
||||
if (s.winStreak >= 3) return 'HOT STREAK'
|
||||
if (s.winRate >= 80 && s.totalFights >= 10) return 'DOMINANT'
|
||||
if (s.winRate >= 60) return 'RISING'
|
||||
if (s.winRate >= 40) return 'SCRAPPY'
|
||||
if (s.winRate < 20 && s.totalFights >= 5) return 'PUNCHING BAG'
|
||||
if (s.losses > s.wins && s.totalFights >= 5) return 'UNDERDOG'
|
||||
return 'CONTENDER'
|
||||
}
|
||||
|
||||
interface QueueEntry {
|
||||
botId: string
|
||||
botName: string
|
||||
@@ -127,23 +142,30 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<!-- Header with large character -->
|
||||
<div class="text-center mb-5">
|
||||
<img
|
||||
v-if="stats.profilePicUrl"
|
||||
:src="stats.profilePicUrl"
|
||||
alt=""
|
||||
class="w-16 h-16 rounded-full mx-auto mb-2 border-2"
|
||||
:style="{ borderColor: stats.tierColor }"
|
||||
/>
|
||||
<p class="font-display text-xs font-bold tracking-[0.2em] mb-1"
|
||||
:style="{ color: stats.tierColor }">
|
||||
{{ stats.tierName }}
|
||||
</p>
|
||||
<div class="relative inline-block mb-3">
|
||||
<SpritePreview
|
||||
:seed="stats.avatarSeed || stats.name"
|
||||
:archetype="stats.archetype"
|
||||
:tier="stats.tier"
|
||||
:size="160"
|
||||
class="mx-auto drop-shadow-[0_0_20px_var(--glow)]"
|
||||
:style="{ '--glow': stats.tierColor + '80' } as any"
|
||||
/>
|
||||
<div class="absolute -bottom-1 left-1/2 -translate-x-1/2 px-3 py-0.5 border text-[9px] font-display font-black tracking-widest whitespace-nowrap"
|
||||
:style="{ borderColor: stats.tierColor, color: stats.tierColor, backgroundColor: 'rgba(0,0,0,0.8)' }">
|
||||
{{ stats.tierName }}
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
|
||||
{{ stats.name }}
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-[10px] mt-1">
|
||||
<p class="font-display text-sm font-bold tracking-widest mt-1"
|
||||
:style="{ color: stats.tierColor }">
|
||||
{{ getStatusTitle(stats) }}
|
||||
</p>
|
||||
<p class="font-mono text-text-muted text-[10px] mt-0.5">
|
||||
#{{ stats.rank }} of {{ stats.totalBots }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -99,6 +99,7 @@ botsRouter.get('/', async (c) => {
|
||||
bestStreak: schema.bots.bestStreak,
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).orderBy(schema.bots.eloRating)
|
||||
|
||||
@@ -119,6 +120,7 @@ botsRouter.get('/:name', async (c) => {
|
||||
bestStreak: schema.bots.bestStreak,
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||
|
||||
@@ -144,6 +146,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
bestStreak: schema.bots.bestStreak,
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user