diff --git a/frontend/src/components/SpritePreview.vue b/frontend/src/components/SpritePreview.vue index 5ab26ee..2467207 100644 --- a/frontend/src/components/SpritePreview.vue +++ b/frontend/src/components/SpritePreview.vue @@ -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 diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index 761557a..2a0fa7b 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -26,15 +26,41 @@ declare global { } } -const pubkey = ref(null) -const bot = ref(null) -const profilePicUrl = ref(null) +// Persist auth state across page navigations and HMR reloads +function loadStored(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(loadStored('bf_pubkey')) +const bot = ref(loadStored('bf_bot')) +const profilePicUrl = ref(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 { diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index dfceb77..ea99c08 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -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) { diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index d9bcf87..3e4bd1d 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -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}`