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<{
|
const props = defineProps<{
|
||||||
seed: string
|
seed: string
|
||||||
archetype?: string
|
archetype?: string
|
||||||
|
tier?: number
|
||||||
size?: number
|
size?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ function render() {
|
|||||||
|
|
||||||
function loadSprite() {
|
function loadSprite() {
|
||||||
const colors = getBotColors(props.seed)
|
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 = new Image()
|
||||||
img.onload = () => render()
|
img.onload = () => render()
|
||||||
img.src = dataUrl
|
img.src = dataUrl
|
||||||
|
|||||||
@@ -26,15 +26,41 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pubkey = ref<string | null>(null)
|
// Persist auth state across page navigations and HMR reloads
|
||||||
const bot = ref<BotData | null>(null)
|
function loadStored<T>(key: string): T | null {
|
||||||
const profilePicUrl = ref<string | null>(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)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
export function useNostr() {
|
export function useNostr() {
|
||||||
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
|
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
|
||||||
const hasExtension = computed(() => !!window.nostr)
|
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 }> {
|
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||||
if (!window.nostr) {
|
if (!window.nostr) {
|
||||||
throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.')
|
throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.')
|
||||||
@@ -44,10 +70,11 @@ export function useNostr() {
|
|||||||
try {
|
try {
|
||||||
const pk = await window.nostr.getPublicKey()
|
const pk = await window.nostr.getPublicKey()
|
||||||
pubkey.value = pk
|
pubkey.value = pk
|
||||||
|
store('bf_pubkey', pk)
|
||||||
|
|
||||||
// Fetch Nostr profile pic from relay
|
// Fetch Nostr profile pic from relay
|
||||||
const pic = await fetchNostrProfilePic(pk)
|
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
|
// Check if this pubkey has a bot
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/auth/login', {
|
||||||
@@ -60,6 +87,7 @@ export function useNostr() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.exists) {
|
if (data.exists) {
|
||||||
bot.value = data.bot
|
bot.value = data.bot
|
||||||
|
store('bf_bot', data.bot)
|
||||||
return { pubkey: pk, bot: data.bot }
|
return { pubkey: pk, bot: data.bot }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +129,7 @@ export function useNostr() {
|
|||||||
bestStreak: 0,
|
bestStreak: 0,
|
||||||
tier: 0,
|
tier: 0,
|
||||||
}
|
}
|
||||||
|
store('bf_bot', bot.value)
|
||||||
|
|
||||||
return bot.value
|
return bot.value
|
||||||
}
|
}
|
||||||
@@ -109,6 +138,9 @@ export function useNostr() {
|
|||||||
pubkey.value = null
|
pubkey.value = null
|
||||||
bot.value = null
|
bot.value = null
|
||||||
profilePicUrl.value = null
|
profilePicUrl.value = null
|
||||||
|
store('bf_pubkey', null)
|
||||||
|
store('bf_bot', null)
|
||||||
|
store('bf_pic', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -548,265 +548,9 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
|
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Ren & Stimpy style grotesque close-up overlays ===
|
// Grotesque close-up stubs (disabled — looked bad)
|
||||||
// When bots scale up big, we overlay gross details: bulging eyes, veins, teeth, sweat
|
function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
|
||||||
let grotesqueObjects: any[] = []
|
function destroyGrotesqueDetails() {}
|
||||||
|
|
||||||
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 = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arena-specific background decoration
|
// Arena-specific background decoration
|
||||||
function drawArenaDecor() {
|
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() })
|
k.tween(0.8, 0, 1.5, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crowd cheering signs pop up from bottom
|
// Crowd signs stub (disabled — too small to look good)
|
||||||
function spawnCrowdSigns(count: number, color: string, text?: string) {
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respectful bow animation
|
// Respectful bow animation
|
||||||
async function playBow(fighter: any) {
|
async function playBow(fighter: any) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -12,6 +13,7 @@ interface BotStats {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
avatarSeed: string
|
avatarSeed: string
|
||||||
|
archetype: string
|
||||||
profilePicUrl: string | null
|
profilePicUrl: string | null
|
||||||
eloRating: number
|
eloRating: number
|
||||||
wins: 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 {
|
interface QueueEntry {
|
||||||
botId: string
|
botId: string
|
||||||
botName: string
|
botName: string
|
||||||
@@ -127,23 +142,30 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Header -->
|
<!-- Header with large character -->
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
<img
|
<div class="relative inline-block mb-3">
|
||||||
v-if="stats.profilePicUrl"
|
<SpritePreview
|
||||||
:src="stats.profilePicUrl"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
alt=""
|
:archetype="stats.archetype"
|
||||||
class="w-16 h-16 rounded-full mx-auto mb-2 border-2"
|
:tier="stats.tier"
|
||||||
:style="{ borderColor: stats.tierColor }"
|
:size="160"
|
||||||
/>
|
class="mx-auto drop-shadow-[0_0_20px_var(--glow)]"
|
||||||
<p class="font-display text-xs font-bold tracking-[0.2em] mb-1"
|
:style="{ '--glow': stats.tierColor + '80' } as any"
|
||||||
:style="{ color: stats.tierColor }">
|
/>
|
||||||
{{ stats.tierName }}
|
<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"
|
||||||
</p>
|
: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">
|
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
|
||||||
{{ stats.name }}
|
{{ stats.name }}
|
||||||
</h2>
|
</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 }}
|
#{{ stats.rank }} of {{ stats.totalBots }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ botsRouter.get('/', async (c) => {
|
|||||||
bestStreak: schema.bots.bestStreak,
|
bestStreak: schema.bots.bestStreak,
|
||||||
tier: schema.bots.tier,
|
tier: schema.bots.tier,
|
||||||
isActive: schema.bots.isActive,
|
isActive: schema.bots.isActive,
|
||||||
|
archetype: schema.bots.archetype,
|
||||||
createdAt: schema.bots.createdAt,
|
createdAt: schema.bots.createdAt,
|
||||||
}).from(schema.bots).orderBy(schema.bots.eloRating)
|
}).from(schema.bots).orderBy(schema.bots.eloRating)
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ botsRouter.get('/:name', async (c) => {
|
|||||||
bestStreak: schema.bots.bestStreak,
|
bestStreak: schema.bots.bestStreak,
|
||||||
tier: schema.bots.tier,
|
tier: schema.bots.tier,
|
||||||
isActive: schema.bots.isActive,
|
isActive: schema.bots.isActive,
|
||||||
|
archetype: schema.bots.archetype,
|
||||||
createdAt: schema.bots.createdAt,
|
createdAt: schema.bots.createdAt,
|
||||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
}).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,
|
bestStreak: schema.bots.bestStreak,
|
||||||
tier: schema.bots.tier,
|
tier: schema.bots.tier,
|
||||||
isActive: schema.bots.isActive,
|
isActive: schema.bots.isActive,
|
||||||
|
archetype: schema.bots.archetype,
|
||||||
createdAt: schema.bots.createdAt,
|
createdAt: schema.bots.createdAt,
|
||||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user