feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots
- Fight Card page: dramatic poster background with cross-hatch, spotlights, vignettes, corner brackets, scan lines; 3D VS orb with punch animation; selectable undercard with main event always pinned at top - PosterSprite: high-quality 480px poster frame with 6-pass renderer (aura, glow, bevel, specular, particles); PixelGlove component - 12-char bot name limit across all forms and server validation - Mock bots: all 100 now have diverse archetypes (25 types), 25% human fighters; seedMockBots updates existing bots on restart - Leaderboard: inline SpritePreview next to each bot name - Nostr auth: persistent login, nsec copy button - Wallet: NWC + Lightning Address, ranked fight flow - Server: payments, ranked queue, customization endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ccf4196647
commit
f6eb7d2845
@@ -26,8 +26,8 @@ interface Round {
|
||||
|
||||
interface FightData {
|
||||
id: string
|
||||
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
||||
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
||||
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
|
||||
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
|
||||
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
|
||||
arena: string
|
||||
winnerId: string | null
|
||||
@@ -47,6 +47,7 @@ const logEl = ref<HTMLElement>()
|
||||
let scene: FightSceneController | null = null
|
||||
const sceneReady = ref(false)
|
||||
let cleanupTimerHandle: ReturnType<typeof setTimeout> | null = null
|
||||
let destroyed = false
|
||||
|
||||
const isReplaying = ref(false)
|
||||
const displayHpA = ref(100)
|
||||
@@ -96,6 +97,7 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined):
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyed = true
|
||||
stopAllAudio()
|
||||
sceneReady.value = false
|
||||
if (scene) { scene.destroy(); scene = null }
|
||||
@@ -157,7 +159,14 @@ const challengeLabel = (type: string) => {
|
||||
}
|
||||
|
||||
const tierClass = (t: number) => `tier-${t}`
|
||||
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (destroyed) reject(new Error('unmounted'))
|
||||
else resolve()
|
||||
}, ms)
|
||||
})
|
||||
}
|
||||
function scrollLog() { nextTick(() => { logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' }) }) }
|
||||
|
||||
// Show floating announcement over the canvas
|
||||
@@ -220,6 +229,14 @@ async function replay() {
|
||||
if (isReplaying.value || !props.fight.botA || !props.fight.botB) return
|
||||
isReplaying.value = true
|
||||
showingFinal.value = false
|
||||
try { await _doReplay() } catch (e) {
|
||||
if (e instanceof Error && e.message === 'unmounted') return
|
||||
console.error('[FightViewer] replay error:', e)
|
||||
} finally { isReplaying.value = false }
|
||||
}
|
||||
|
||||
async function _doReplay() {
|
||||
if (!props.fight.botA || !props.fight.botB) return
|
||||
displayHpA.value = 100
|
||||
displayHpB.value = 100
|
||||
logItems.value = []
|
||||
@@ -412,7 +429,6 @@ async function replay() {
|
||||
cleanupTimerHandle = null
|
||||
}, 3000)
|
||||
|
||||
isReplaying.value = false
|
||||
emit('replay-done')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { generatePosterFrame } from '../game/sprites/poster'
|
||||
import { ANIMATIONS } from '../game/sprites'
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string
|
||||
archetype?: string
|
||||
tier?: number
|
||||
size?: number
|
||||
pose?: keyof typeof ANIMATIONS
|
||||
glowColor?: 'cyan' | 'pink' | 'purple' | 'yellow'
|
||||
}>()
|
||||
|
||||
const imgSrc = ref('')
|
||||
|
||||
async function generate() {
|
||||
// Always render at high resolution (480px) for quality, display size is controlled by CSS
|
||||
imgSrc.value = await generatePosterFrame(
|
||||
props.seed,
|
||||
props.tier || 0,
|
||||
props.archetype,
|
||||
props.pose || 'attack',
|
||||
480,
|
||||
undefined,
|
||||
props.glowColor || 'cyan',
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => generate())
|
||||
|
||||
watch(() => [props.seed, props.archetype, props.tier, props.pose, props.glowColor], () => generate())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="imgSrc"
|
||||
:src="imgSrc"
|
||||
alt=""
|
||||
class="poster-sprite-img"
|
||||
:style="{ width: `${size || 400}px`, height: `${size || 400}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poster-sprite-img {
|
||||
image-rendering: pixelated;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
||||
tier?: number
|
||||
size?: number
|
||||
customization?: SpriteCustomization
|
||||
pose?: keyof typeof ANIMATIONS
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
@@ -24,11 +25,11 @@ function render() {
|
||||
ctx.clearRect(0, 0, displaySize, displaySize)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
const idleAnim = ANIMATIONS.idle
|
||||
const f = frame % idleAnim.frames
|
||||
const anim = ANIMATIONS[props.pose || 'idle']
|
||||
const f = frame % anim.frames
|
||||
ctx.drawImage(
|
||||
img,
|
||||
f * FRAME_SIZE, idleAnim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
|
||||
f * FRAME_SIZE, anim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
|
||||
0, 0, displaySize, displaySize,
|
||||
)
|
||||
|
||||
@@ -46,7 +47,7 @@ function loadSprite() {
|
||||
|
||||
onMounted(() => loadSprite())
|
||||
|
||||
watch(() => [props.seed, props.archetype, props.customization], () => {
|
||||
watch(() => [props.seed, props.archetype, props.customization, props.pose], () => {
|
||||
if (animHandle) clearTimeout(animHandle)
|
||||
frame = 0
|
||||
loadSprite()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'
|
||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
||||
import { nsecEncode } from 'nostr-tools/nip19'
|
||||
|
||||
interface BotCustomization {
|
||||
archetype?: string
|
||||
@@ -76,19 +79,30 @@ export function useNostr() {
|
||||
}
|
||||
|
||||
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.')
|
||||
// Try NIP-07 extension first, fall back to stored nsec
|
||||
let pk: string
|
||||
|
||||
if (window.nostr) {
|
||||
pk = await window.nostr.getPublicKey()
|
||||
} else {
|
||||
const storedNsec = localStorage.getItem('bf_nsec')
|
||||
if (storedNsec) {
|
||||
const secretKey = hexToBytes(storedNsec)
|
||||
pk = getPublicKey(secretKey)
|
||||
} else {
|
||||
throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.')
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
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; store('bf_pic', pic) }
|
||||
profilePicUrl.value = pic
|
||||
store('bf_pic', pic)
|
||||
|
||||
// Check if this pubkey has a bot
|
||||
const res = await fetch('/api/auth/login', {
|
||||
@@ -106,6 +120,63 @@ export function useNostr() {
|
||||
}
|
||||
}
|
||||
|
||||
// No bot for this key — clear stale state
|
||||
bot.value = null
|
||||
store('bf_bot', null)
|
||||
|
||||
return { pubkey: pk, bot: null }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh Nostr keypair locally — no extension needed */
|
||||
function generateLogin(): { pubkey: string; nsec: string } {
|
||||
const secretKey = generateSecretKey()
|
||||
const pk = getPublicKey(secretKey)
|
||||
const nsecHex = bytesToHex(secretKey)
|
||||
const nsecBech32 = nsecEncode(secretKey)
|
||||
|
||||
// Clear stale state from previous identity
|
||||
bot.value = null
|
||||
profilePicUrl.value = null
|
||||
store('bf_bot', null)
|
||||
store('bf_pic', null)
|
||||
|
||||
// Store locally
|
||||
localStorage.setItem('bf_nsec', nsecHex)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
return { pubkey: pk, nsec: nsecBech32 }
|
||||
}
|
||||
|
||||
/** Login with an existing nsec (hex) */
|
||||
async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
const secretKey = hexToBytes(nsecHex)
|
||||
const pk = getPublicKey(secretKey)
|
||||
|
||||
localStorage.setItem('bf_nsec', nsecHex)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pk }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
bot.value = data.bot
|
||||
store('bf_bot', data.bot)
|
||||
return { pubkey: pk, bot: data.bot }
|
||||
}
|
||||
}
|
||||
|
||||
return { pubkey: pk, bot: null }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -223,6 +294,15 @@ export function useNostr() {
|
||||
store('bf_pubkey', null)
|
||||
store('bf_bot', null)
|
||||
store('bf_pic', null)
|
||||
// Don't clear bf_nsec on logout — user may want to log back in
|
||||
}
|
||||
|
||||
/** Check if user has a locally stored key (no extension needed) */
|
||||
const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec'))
|
||||
|
||||
/** Get the stored nsec hex for backup display */
|
||||
function getStoredNsec(): string | null {
|
||||
return localStorage.getItem('bf_nsec')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -232,10 +312,14 @@ export function useNostr() {
|
||||
isLoggedIn,
|
||||
isLoading: readonly(isLoading),
|
||||
hasExtension,
|
||||
hasStoredKey,
|
||||
login,
|
||||
generateLogin,
|
||||
loginWithNsec,
|
||||
registerBot,
|
||||
registerHuman,
|
||||
updateCustomization,
|
||||
getStoredNsec,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
@@ -261,8 +345,10 @@ async function fetchNostrProfilePic(pk: string): Promise<string | null> {
|
||||
|
||||
function queryRelay(url: string, pk: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
timedOut = true
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close()
|
||||
resolve(null)
|
||||
}, 3000)
|
||||
|
||||
@@ -270,6 +356,7 @@ function queryRelay(url: string, pk: string): Promise<string | null> {
|
||||
const subId = Math.random().toString(36).slice(2, 10)
|
||||
|
||||
ws.onopen = () => {
|
||||
if (timedOut) { ws.close(); return }
|
||||
// Request kind 0 (metadata) for this pubkey
|
||||
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
import { useNostr } from './useNostr.js'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import * as nip04 from 'nostr-tools/nip04'
|
||||
import * as nip44 from 'nostr-tools/nip44'
|
||||
import { hexToBytes } from 'nostr-tools/utils'
|
||||
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
|
||||
|
||||
type WalletMethod = 'nwc' | 'lnaddress' | null
|
||||
type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed'
|
||||
@@ -150,14 +151,29 @@ export function useWallet() {
|
||||
const { bolt11, paymentId } = await invoiceRes.json()
|
||||
pendingPayment.value = { paymentId, bolt11 }
|
||||
|
||||
// If NWC connected, auto-pay via NWC
|
||||
// Dev mode: server auto-confirmed, skip payment
|
||||
if (bolt11 === 'dev_auto_confirmed') {
|
||||
paymentStatus.value = 'confirmed'
|
||||
return paymentId
|
||||
}
|
||||
|
||||
// If NWC connected, auto-pay via NWC and confirm directly
|
||||
const nwcUrl = localStorage.getItem('bf_nwc_url')
|
||||
if (nwcUrl) {
|
||||
paymentStatus.value = 'paying'
|
||||
await payViaNWC(nwcUrl, bolt11)
|
||||
const preimage = await payViaNWC(nwcUrl, bolt11)
|
||||
|
||||
// Tell server payment is confirmed (skip lookup_invoice polling)
|
||||
await fetch(`/api/payments/confirm/${paymentId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preimage, pubkey: pubkey.value }),
|
||||
})
|
||||
paymentStatus.value = 'confirmed'
|
||||
return paymentId
|
||||
}
|
||||
|
||||
// Poll for confirmation
|
||||
// No NWC — poll for confirmation (manual payment / QR code flow)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const checkRes = await fetch(`/api/payments/check/${paymentId}`)
|
||||
@@ -220,13 +236,13 @@ export function useWallet() {
|
||||
}
|
||||
|
||||
/** Send a pay_invoice request via NWC WebSocket */
|
||||
async function payViaNWC(nwcUrl: string, bolt11: string): Promise<void> {
|
||||
async function payViaNWC(nwcUrl: string, bolt11: string): Promise<string | undefined> {
|
||||
const nwc = parseNwcUrl(nwcUrl)
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey)
|
||||
const secretHex = bytesToHex(nwc.secret)
|
||||
|
||||
const content = nip44.v2.encrypt(
|
||||
// Use NIP-04 encryption (BTCPay/LND compatibility)
|
||||
const content = await nip04.encrypt(secretHex, nwc.pubkey,
|
||||
JSON.stringify({ method: 'pay_invoice', params: { invoice: bolt11 } }),
|
||||
conversationKey,
|
||||
)
|
||||
|
||||
const event = finalizeEvent({
|
||||
@@ -240,41 +256,48 @@ async function payViaNWC(nwcUrl: string, bolt11: string): Promise<void> {
|
||||
const ws = new WebSocket(nwc.relay)
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
// Don't reject — the server-side poll will catch confirmation
|
||||
resolve()
|
||||
resolve(undefined)
|
||||
}, 30_000)
|
||||
|
||||
ws.onopen = () => {
|
||||
// Subscribe for response
|
||||
const subId = Math.random().toString(36).slice(2, 10)
|
||||
ws.send(JSON.stringify(['REQ', subId, {
|
||||
kinds: [23195],
|
||||
authors: [nwc.pubkey],
|
||||
'#e': [event.id],
|
||||
}]))
|
||||
|
||||
// Publish the payment request
|
||||
ws.send(JSON.stringify(['EVENT', event]))
|
||||
}
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
ws.onmessage = async (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data)
|
||||
if (data[0] === 'EVENT' && data[2]?.kind === 23195) {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
// Payment sent — server-side poll will confirm
|
||||
resolve()
|
||||
// Decrypt response — try NIP-04 first, fall back to NIP-44
|
||||
let decrypted: string
|
||||
try {
|
||||
decrypted = await nip04.decrypt(secretHex, nwc.pubkey, data[2].content)
|
||||
} catch {
|
||||
const convKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey)
|
||||
decrypted = nip44.v2.decrypt(data[2].content, convKey)
|
||||
}
|
||||
const result = JSON.parse(decrypted)
|
||||
if (result.error) {
|
||||
reject(new Error(result.error.message || 'NWC payment failed'))
|
||||
} else {
|
||||
resolve(result.result?.preimage)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
// Don't reject — server-side poll is the source of truth
|
||||
resolve()
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+44
-24
@@ -5,23 +5,27 @@ let sfxGain: GainNode | null = null
|
||||
let musicPlaying = false
|
||||
let musicTimeout: number | null = null
|
||||
|
||||
// Lazily create AudioContext + gain nodes on first use.
|
||||
// Chrome may warn about autoplay policy but the context will resume once ensureAudioContext()
|
||||
// is called from a user gesture. All SFX calls before that are queued/silent.
|
||||
// AudioContext is ONLY created inside ensureAudioContext() (called from user gesture).
|
||||
// Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings.
|
||||
let _audioUnlocked = false
|
||||
|
||||
function initCtx() {
|
||||
if (ctx) return
|
||||
ctx = new AudioContext()
|
||||
musicGain = ctx.createGain()
|
||||
musicGain.gain.value = masterMuted ? 0 : 0.12
|
||||
musicGain.connect(ctx.destination)
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : 0.25
|
||||
sfxGain.connect(ctx.destination)
|
||||
}
|
||||
|
||||
function getCtx(): AudioContext {
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext()
|
||||
musicGain = ctx.createGain()
|
||||
musicGain.gain.value = masterMuted ? 0 : 0.12
|
||||
musicGain.connect(ctx.destination)
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : 0.25
|
||||
sfxGain.connect(ctx.destination)
|
||||
if (!ctx) initCtx()
|
||||
if (ctx!.state === 'suspended') {
|
||||
ctx!.resume().catch(() => {})
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
return ctx
|
||||
return ctx!
|
||||
}
|
||||
|
||||
// Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid)
|
||||
@@ -340,13 +344,21 @@ export function stopAllAudio() {
|
||||
stopMusic()
|
||||
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||
_speechQueueDepth = 0
|
||||
// Disconnect sfxGain to instantly kill all in-flight oscillators/buffers,
|
||||
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
|
||||
// then reconnect so future sounds still work
|
||||
if (sfxGain && ctx) {
|
||||
sfxGain.disconnect()
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : 0.25
|
||||
sfxGain.connect(ctx.destination)
|
||||
if (ctx) {
|
||||
if (musicGain) {
|
||||
musicGain.disconnect()
|
||||
musicGain = ctx.createGain()
|
||||
musicGain.gain.value = masterMuted ? 0 : MUSIC_VOL
|
||||
musicGain.connect(ctx.destination)
|
||||
}
|
||||
if (sfxGain) {
|
||||
sfxGain.disconnect()
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = masterMuted ? 0 : SFX_VOL
|
||||
sfxGain.connect(ctx.destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1891,14 +1903,22 @@ export function isMasterMuted(): boolean {
|
||||
return masterMuted
|
||||
}
|
||||
|
||||
let _speechUnlocked = false
|
||||
export async function ensureAudioContext() {
|
||||
const c = getCtx()
|
||||
if (c.state === 'suspended') {
|
||||
try { await c.resume() } catch {}
|
||||
}
|
||||
// Prime speech synthesis on user gesture — some browsers need this
|
||||
if (typeof speechSynthesis !== 'undefined' && !voicesLoaded) {
|
||||
loadVoices()
|
||||
// Prime speech synthesis on user gesture — mobile browsers require
|
||||
// a speak() call inside a user gesture to unlock speechSynthesis
|
||||
if (typeof speechSynthesis !== 'undefined') {
|
||||
if (!voicesLoaded) loadVoices()
|
||||
if (!_speechUnlocked) {
|
||||
_speechUnlocked = true
|
||||
const unlock = new SpeechSynthesisUtterance('')
|
||||
unlock.volume = 0
|
||||
speechSynthesis.speak(unlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -540,3 +540,23 @@ export function generateSpriteSheet(
|
||||
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
|
||||
/** Load a sprite sheet data URL into a canvas (async for reliable image decode) */
|
||||
export async function loadSpriteSheetCanvas(
|
||||
seed: string, tier: number, primaryColor: string, secondaryColor: string,
|
||||
archetypeOverride?: string, customization?: SpriteCustomization,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const dataUrl = generateSpriteSheet(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization)
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image()
|
||||
i.onload = () => resolve(i)
|
||||
i.onerror = reject
|
||||
i.src = dataUrl
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = FRAME_SIZE * MAX_FRAMES
|
||||
canvas.height = FRAME_SIZE * TOTAL_ROWS
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
return canvas
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { FRAME_SIZE, ANIMATIONS } from './constants'
|
||||
import { loadSpriteSheetCanvas } from './index'
|
||||
import { getBotColors } from './palette'
|
||||
import type { SpriteCustomization } from './index'
|
||||
|
||||
type PoseKey = keyof typeof ANIMATIONS
|
||||
|
||||
/**
|
||||
* Generate a high-quality poster frame for a single bot.
|
||||
* Takes the normal 96x96 sprite frame and renders it to a large canvas
|
||||
* with pixel-art-aware enhancements: 3D bevel, glow outline, energy aura, sparkles.
|
||||
*/
|
||||
export async function generatePosterFrame(
|
||||
seed: string,
|
||||
tier: number,
|
||||
archetype?: string,
|
||||
pose: PoseKey = 'attack',
|
||||
outputSize = 480,
|
||||
customization?: SpriteCustomization,
|
||||
glowColor: 'cyan' | 'pink' | 'purple' | 'yellow' = 'cyan',
|
||||
): Promise<string> {
|
||||
const colors = getBotColors(seed)
|
||||
|
||||
// 1. Get the sprite sheet as a canvas
|
||||
const sheetCanvas = await loadSpriteSheetCanvas(seed, tier, colors.primary, colors.secondary, archetype, customization)
|
||||
const sheetCtx = sheetCanvas.getContext('2d')!
|
||||
|
||||
// 2. Extract a single mid-animation frame
|
||||
const anim = ANIMATIONS[pose]
|
||||
// Use early frames to avoid arm/leg clipping at frame edges
|
||||
const frameIdx = pose === 'attack' ? 1 : pose === 'kick' ? 1 : Math.min(Math.floor(anim.frames / 2), anim.frames - 1)
|
||||
const srcX = frameIdx * FRAME_SIZE
|
||||
const srcY = anim.row * FRAME_SIZE
|
||||
|
||||
const frameCanvas = document.createElement('canvas')
|
||||
frameCanvas.width = FRAME_SIZE
|
||||
frameCanvas.height = FRAME_SIZE
|
||||
const frameCtx = frameCanvas.getContext('2d')!
|
||||
frameCtx.imageSmoothingEnabled = false
|
||||
frameCtx.drawImage(sheetCanvas, srcX, srcY, FRAME_SIZE, FRAME_SIZE, 0, 0, FRAME_SIZE, FRAME_SIZE)
|
||||
|
||||
// 3. Read pixel data
|
||||
const frameData = frameCtx.getImageData(0, 0, FRAME_SIZE, FRAME_SIZE)
|
||||
const px = frameData.data
|
||||
|
||||
// 4. Create output canvas
|
||||
const out = document.createElement('canvas')
|
||||
out.width = outputSize
|
||||
out.height = outputSize
|
||||
const ctx = out.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
const scale = outputSize / FRAME_SIZE
|
||||
|
||||
// Helpers
|
||||
function getPixel(x: number, y: number): [number, number, number, number] {
|
||||
if (x < 0 || x >= FRAME_SIZE || y < 0 || y >= FRAME_SIZE) return [0, 0, 0, 0]
|
||||
const i = (y * FRAME_SIZE + x) * 4
|
||||
return [px[i], px[i + 1], px[i + 2], px[i + 3]]
|
||||
}
|
||||
|
||||
function isOpaque(x: number, y: number): boolean {
|
||||
return getPixel(x, y)[3] > 20
|
||||
}
|
||||
|
||||
function isDark(x: number, y: number): boolean {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
return a > 20 && r < 30 && g < 30 && b < 30
|
||||
}
|
||||
|
||||
// 5. Build edge map (silhouette border)
|
||||
const edgeMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!isOpaque(x, y)) continue
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (dx === 0 && dy === 0) continue
|
||||
if (!isOpaque(x + dx, y + dy)) { edgeMap[y * FRAME_SIZE + x] = 1; break }
|
||||
}
|
||||
if (edgeMap[y * FRAME_SIZE + x]) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Glow color RGB
|
||||
const GLOW: Record<string, number[]> = {
|
||||
cyan: [0, 240, 255],
|
||||
pink: [255, 45, 120],
|
||||
purple: [168, 85, 247],
|
||||
yellow: [255, 215, 0],
|
||||
}
|
||||
const gc = GLOW[glowColor]
|
||||
|
||||
// 6. Find character bounding box for aura
|
||||
let bMinX = FRAME_SIZE, bMaxX = 0, bMinY = FRAME_SIZE, bMaxY = 0
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (isOpaque(x, y)) {
|
||||
bMinX = Math.min(bMinX, x)
|
||||
bMaxX = Math.max(bMaxX, x)
|
||||
bMinY = Math.min(bMinY, y)
|
||||
bMaxY = Math.max(bMaxY, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
const cx = ((bMinX + bMaxX) / 2) * scale
|
||||
const cy = ((bMinY + bMaxY) / 2) * scale
|
||||
const charR = Math.max(bMaxX - bMinX, bMaxY - bMinY) * scale * 0.65
|
||||
|
||||
// ═══ RENDER PASSES ═══
|
||||
|
||||
// Pass 1: Background energy aura
|
||||
const auraGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR)
|
||||
const auraIntensity = tier >= 3 ? 0.2 : tier >= 1 ? 0.1 : 0.05
|
||||
auraGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity})`)
|
||||
auraGrad.addColorStop(0.4, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity * 0.4})`)
|
||||
auraGrad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = auraGrad
|
||||
ctx.fillRect(0, 0, outputSize, outputSize)
|
||||
|
||||
// Second aura layer (tighter, brighter)
|
||||
if (tier >= 2) {
|
||||
const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR * 0.5)
|
||||
innerGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`)
|
||||
innerGrad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = innerGrad
|
||||
ctx.fillRect(0, 0, outputSize, outputSize)
|
||||
}
|
||||
|
||||
// Pass 2: Outer glow (wide, soft)
|
||||
const outerR = Math.ceil(scale * 1.5)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!edgeMap[y * FRAME_SIZE + x]) continue
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`
|
||||
ctx.fillRect(dx - outerR, dy - outerR, scale + outerR * 2, scale + outerR * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Inner glow (tight, brighter)
|
||||
const innerR = Math.ceil(scale * 0.6)
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
if (!edgeMap[y * FRAME_SIZE + x]) continue
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.2)`
|
||||
ctx.fillRect(dx - innerR, dy - innerR, scale + innerR * 2, scale + innerR * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Main pixel render with bevel
|
||||
const bevel = Math.max(1, Math.floor(scale / 5))
|
||||
for (let y = 0; y < FRAME_SIZE; y++) {
|
||||
for (let x = 0; x < FRAME_SIZE; x++) {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
if (a < 20) continue
|
||||
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
const dark = isDark(x, y)
|
||||
|
||||
// Base pixel
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`
|
||||
ctx.fillRect(dx, dy, scale, scale)
|
||||
|
||||
// 3D bevel on colored pixels (not outlines)
|
||||
if (!dark && scale >= 3) {
|
||||
// Top edge highlight
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.14)'
|
||||
ctx.fillRect(dx, dy, scale, bevel)
|
||||
// Left edge highlight
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.07)'
|
||||
ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2)
|
||||
// Bottom edge shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)'
|
||||
ctx.fillRect(dx, dy + scale - bevel, scale, bevel)
|
||||
// Right edge shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.10)'
|
||||
ctx.fillRect(dx + scale - bevel, dy + bevel, bevel, scale - bevel * 2)
|
||||
}
|
||||
|
||||
// Subtle glow tint on edge pixels
|
||||
if (edgeMap[y * FRAME_SIZE + x] && !dark) {
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.06)`
|
||||
ctx.fillRect(dx, dy, scale, scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 5: Specular highlights on large colored regions
|
||||
for (let y = 1; y < FRAME_SIZE - 1; y++) {
|
||||
for (let x = 1; x < FRAME_SIZE - 1; x++) {
|
||||
const [r, g, b, a] = getPixel(x, y)
|
||||
if (a < 20 || isDark(x, y)) continue
|
||||
// Only add specular if surrounded by similar pixels (interior of a region)
|
||||
if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) {
|
||||
const dx = x * scale
|
||||
const dy = y * scale
|
||||
// Tiny specular dot in the upper-left of the pixel
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.fillRect(dx + 1, dy + 1, Math.ceil(scale / 3), Math.ceil(scale / 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 6: Tier-gated energy particles
|
||||
if (tier >= 3) {
|
||||
const count = 4 + tier * 2
|
||||
const seedHash = Array.from(seed).reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const h = Math.abs((seedHash * 1337 + i * 997) | 0)
|
||||
const angle = (h % 360) * Math.PI / 180
|
||||
const dist = charR * 0.3 + (h % 100) / 100 * charR * 0.5
|
||||
const ppx = cx + Math.cos(angle) * dist
|
||||
const ppy = cy + Math.sin(angle) * dist
|
||||
const sz = 1 + (h % 3)
|
||||
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${0.4 + (h % 3) * 0.15})`
|
||||
ctx.fillRect(ppx, ppy, sz, sz)
|
||||
// Cross sparkle for tier 4+
|
||||
if (tier >= 4) {
|
||||
const sparkLen = 2 + (h % 3)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'
|
||||
ctx.fillRect(ppx - sparkLen, ppy, sparkLen * 2 + sz, 1)
|
||||
ctx.fillRect(ppx + Math.floor(sz / 2), ppy - sparkLen, 1, sparkLen * 2 + sz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out.toDataURL()
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import PosterSprite from '../components/PosterSprite.vue'
|
||||
import PixelGlove from '../components/PixelGlove.vue'
|
||||
|
||||
interface BotInfo {
|
||||
name: string
|
||||
avatarSeed: string
|
||||
archetype: string
|
||||
tier: number
|
||||
eloRating: number
|
||||
botType?: string
|
||||
}
|
||||
|
||||
interface UpcomingFight {
|
||||
id: string
|
||||
botA: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
|
||||
botB: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
|
||||
botA: BotInfo | null
|
||||
botB: BotInfo | null
|
||||
arenaInfo: { name: string } | null
|
||||
arena: string
|
||||
status: string
|
||||
@@ -16,7 +26,32 @@ interface UpcomingFight {
|
||||
|
||||
const fights = ref<UpcomingFight[]>([])
|
||||
const isLoading = ref(true)
|
||||
const featured = ref<UpcomingFight | null>(null)
|
||||
const mainEvent = ref<UpcomingFight | null>(null)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const isPunching = ref(false)
|
||||
let punchInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Derive featured from selectedId — always look up from fights array for fresh data
|
||||
const featured = computed<UpcomingFight | null>(() => {
|
||||
if (selectedId.value) {
|
||||
const found = fights.value.find(f => f.id === selectedId.value)
|
||||
if (found && found.botA && found.botB) return found
|
||||
}
|
||||
return mainEvent.value
|
||||
})
|
||||
|
||||
// Is the main event currently featured?
|
||||
const isMainFeatured = computed(() => !selectedId.value || selectedId.value === mainEvent.value?.id)
|
||||
|
||||
// Undercard fights (everything except main event)
|
||||
const undercardFights = computed(() => {
|
||||
if (!mainEvent.value) return fights.value
|
||||
return fights.value.filter(f => f.id !== mainEvent.value!.id)
|
||||
})
|
||||
|
||||
// Safe accessors for featured fight bots (never null when featured exists)
|
||||
const fighterA = computed<BotInfo | null>(() => featured.value?.botA ?? null)
|
||||
const fighterB = computed<BotInfo | null>(() => featured.value?.botB ?? null)
|
||||
|
||||
const tierNames: Record<number, string> = {
|
||||
0: 'ROOKIE', 1: 'BRAWLER', 2: 'WARRIOR', 3: 'CHAMPION', 4: 'LEGEND', 5: 'MYTHIC',
|
||||
@@ -25,26 +60,45 @@ const tierColors: Record<number, string> = {
|
||||
0: '#8a8a9a', 1: '#4ade80', 2: '#38bdf8', 3: '#a855f7', 4: '#f97316', 5: '#ef4444',
|
||||
}
|
||||
|
||||
function selectFight(fightId: string) {
|
||||
const fight = fights.value.find(f => f.id === fightId)
|
||||
if (!fight || !fight.botA || !fight.botB) return
|
||||
selectedId.value = fightId
|
||||
}
|
||||
|
||||
function selectMainEvent() {
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/fights')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const data: UpcomingFight[] = await res.json()
|
||||
fights.value = data
|
||||
// Feature the most recent fight with both bots
|
||||
featured.value = data.find((f: UpcomingFight) => f.botA && f.botB) || null
|
||||
// Main event = first fight with both bots present
|
||||
mainEvent.value = data.find(f => f.botA && f.botB) || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[FightCard] load failed:', err)
|
||||
}
|
||||
isLoading.value = false
|
||||
|
||||
punchInterval = setInterval(() => {
|
||||
isPunching.value = true
|
||||
setTimeout(() => { isPunching.value = false }, 800)
|
||||
}, 6000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (punchInterval) clearInterval(punchInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center overflow-y-auto relative">
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col overflow-hidden relative">
|
||||
|
||||
<!-- Background neon grid -->
|
||||
<!-- Page background -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[200%] h-full opacity-20"
|
||||
style="background: radial-gradient(ellipse at 50% 0%, rgba(255,45,120,0.4) 0%, transparent 60%)" />
|
||||
@@ -57,137 +111,240 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<template v-else-if="featured">
|
||||
<!-- MAIN EVENT CARD -->
|
||||
<div class="w-full max-w-2xl mx-auto px-4 py-6 sm:py-10 relative z-10">
|
||||
<!-- Desktop: poster 2/3 + fight list 1/3 | Mobile: 50/50 -->
|
||||
<div class="flex-1 min-h-0 flex flex-col lg:flex-row relative z-10">
|
||||
|
||||
<!-- Neon sign header -->
|
||||
<div class="text-center mb-6 sm:mb-10">
|
||||
<p class="font-pixel text-[10px] sm:text-xs text-neon-yellow tracking-[0.4em] mb-2 animate-pulse">
|
||||
TONIGHT'S MAIN EVENT
|
||||
</p>
|
||||
<div class="relative inline-block">
|
||||
<h1 class="font-neon text-4xl sm:text-6xl md:text-7xl text-neon-pink glow-pink neon-flicker tracking-wider">
|
||||
FIGHT CARD
|
||||
</h1>
|
||||
<div class="absolute -inset-4 border-2 border-neon-pink/30 rounded-lg neon-border-pink" />
|
||||
<div class="absolute -inset-6 border border-neon-pink/10 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main fight card with 3D perspective -->
|
||||
<!-- LEFT: Fight poster -->
|
||||
<RouterLink
|
||||
:to="`/arena/${featured.id}`"
|
||||
class="block group"
|
||||
class="h-1/2 lg:h-full lg:w-2/3 flex flex-col overflow-hidden lg:border-r lg:border-border/30 group relative poster-bg"
|
||||
>
|
||||
<div class="fight-card-3d relative">
|
||||
<div class="fight-card-inner border-2 border-neon-pink/40 rounded-2xl overflow-hidden
|
||||
bg-gradient-to-b from-surface-raised via-black/90 to-surface-raised
|
||||
shadow-[0_0_40px_rgba(255,45,120,0.15),0_0_80px_rgba(255,45,120,0.05)]
|
||||
group-hover:border-neon-pink/60 group-hover:shadow-[0_0_60px_rgba(255,45,120,0.25)]
|
||||
transition-all duration-300">
|
||||
<!-- ═══ Dramatic poster background layers ═══ -->
|
||||
|
||||
<!-- Top banner -->
|
||||
<div class="bg-gradient-to-r from-neon-pink/20 via-neon-purple/20 to-neon-cyan/20 px-4 py-2 text-center border-b border-white/5">
|
||||
<p class="font-display font-black text-[10px] sm:text-xs tracking-[0.3em] text-neon-yellow">
|
||||
<!-- Dark vignette base -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background: radial-gradient(ellipse at 50% 45%, transparent 30%, rgba(0,0,0,0.6) 100%)" />
|
||||
|
||||
<!-- Dual spotlights — cyan left, pink right -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background:
|
||||
conic-gradient(from 200deg at 15% 0%, rgba(0,240,255,0.15) 0deg, transparent 40deg),
|
||||
conic-gradient(from -20deg at 85% 0%, rgba(255,45,120,0.15) 0deg, transparent 40deg)" />
|
||||
|
||||
<!-- Center clash glow -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background:
|
||||
radial-gradient(ellipse at 25% 55%, rgba(0,240,255,0.14) 0%, transparent 45%),
|
||||
radial-gradient(ellipse at 75% 55%, rgba(255,45,120,0.14) 0%, transparent 45%),
|
||||
radial-gradient(circle at 50% 50%, rgba(168,85,247,0.08) 0%, transparent 35%)" />
|
||||
|
||||
<!-- Top pink wash -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background: radial-gradient(ellipse at 50% -10%, rgba(255,45,120,0.25) 0%, transparent 40%)" />
|
||||
|
||||
<!-- Bottom purple haze -->
|
||||
<div class="absolute inset-0 pointer-events-none"
|
||||
style="background: radial-gradient(ellipse at 50% 110%, rgba(168,85,247,0.12) 0%, transparent 35%)" />
|
||||
|
||||
<!-- Cross-hatched lines -->
|
||||
<div class="absolute inset-0 pointer-events-none poster-crosshatch" />
|
||||
|
||||
<!-- Scan lines overlay -->
|
||||
<div class="absolute inset-0 pointer-events-none poster-scanlines" />
|
||||
|
||||
<!-- Neon edge lines -->
|
||||
<div class="absolute top-0 inset-x-0 h-[2px] bg-gradient-to-r from-transparent via-neon-pink/70 to-transparent" />
|
||||
<div class="absolute bottom-0 inset-x-0 h-[2px] bg-gradient-to-r from-transparent via-neon-cyan/70 to-transparent" />
|
||||
<div class="absolute left-0 inset-y-0 w-[2px] bg-gradient-to-b from-neon-pink/30 via-neon-purple/20 to-neon-cyan/30 hidden lg:block" />
|
||||
|
||||
<!-- Corner accents -->
|
||||
<div class="absolute top-0 left-0 w-8 h-8 sm:w-12 sm:h-12 border-t-2 border-l-2 border-neon-pink/30" />
|
||||
<div class="absolute top-0 right-0 w-8 h-8 sm:w-12 sm:h-12 border-t-2 border-r-2 border-neon-pink/30" />
|
||||
<div class="absolute bottom-0 left-0 w-8 h-8 sm:w-12 sm:h-12 border-b-2 border-l-2 border-neon-cyan/30" />
|
||||
<div class="absolute bottom-0 right-0 w-8 h-8 sm:w-12 sm:h-12 border-b-2 border-r-2 border-neon-cyan/30" />
|
||||
|
||||
<!-- Ring ropes (horizontal accent lines) -->
|
||||
<div class="absolute left-0 right-0 top-[30%] h-px bg-gradient-to-r from-transparent via-neon-yellow/10 to-transparent" />
|
||||
<div class="absolute left-0 right-0 top-[70%] h-px bg-gradient-to-r from-transparent via-neon-yellow/10 to-transparent" />
|
||||
|
||||
<!-- Title -->
|
||||
<div class="text-center pt-2 sm:pt-3 lg:pt-5 px-4 relative z-10 shrink-0">
|
||||
<p class="font-pixel text-[10px] sm:text-sm lg:text-base text-neon-yellow/90 tracking-[0.5em] animate-pulse">
|
||||
{{ isMainFeatured ? 'TONIGHT\'S MAIN EVENT' : 'UNDERCARD BOUT' }}
|
||||
</p>
|
||||
<div class="relative inline-block mt-0.5 sm:mt-1">
|
||||
<h1 class="font-neon text-3xl sm:text-5xl lg:text-8xl text-neon-pink glow-pink neon-flicker tracking-wider leading-none poster-title">
|
||||
FIGHT NIGHT
|
||||
</h1>
|
||||
<div class="absolute -inset-2 sm:-inset-3 border-2 border-neon-pink/30 rounded neon-border-pink" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1.5 sm:mt-2 max-w-sm sm:max-w-md mx-auto">
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent to-neon-purple/30" />
|
||||
<div class="shrink-0 px-3 sm:px-4 py-0.5 sm:py-1 border border-neon-purple/25 rounded bg-neon-purple/5">
|
||||
<p class="font-display font-black text-[7px] sm:text-[10px] lg:text-xs tracking-[0.3em] text-neon-purple/80">
|
||||
{{ featured.arenaInfo?.name?.toUpperCase() || 'THE RING' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 h-px bg-gradient-to-l from-transparent to-neon-purple/30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fighter showcase -->
|
||||
<div class="px-4 sm:px-8 py-6 sm:py-10">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- Fighters -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center relative z-10 min-h-0 px-2 sm:px-4 lg:px-6">
|
||||
|
||||
<!-- Fighter A -->
|
||||
<div class="flex-1 flex flex-col items-center text-center min-w-0">
|
||||
<div class="relative mb-3 sm:mb-4">
|
||||
<div class="absolute -inset-3 rounded-full bg-neon-cyan/10 blur-xl" />
|
||||
<SpritePreview
|
||||
v-if="featured.botA"
|
||||
:seed="featured.botA.avatarSeed || featured.botA.name"
|
||||
:archetype="featured.botA.archetype || 'standard'"
|
||||
:tier="featured.botA.tier"
|
||||
:size="100"
|
||||
class="relative z-10 drop-shadow-[0_0_20px_rgba(0,240,255,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-cyan glow-cyan truncate w-full">
|
||||
{{ featured.botA?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
|
||||
:style="{ color: tierColors[featured.botA?.tier || 0] }">
|
||||
{{ tierNames[featured.botA?.tier || 0] }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
|
||||
{{ Math.round(featured.botA?.eloRating || 0) }} ELO
|
||||
</p>
|
||||
</div>
|
||||
<!-- Sprites -->
|
||||
<div class="flex items-end justify-center w-full max-w-4xl">
|
||||
<div class="flex-1 flex flex-col items-center min-w-0">
|
||||
<PosterSprite
|
||||
v-if="fighterA"
|
||||
:key="`a-${featured.id}-${fighterA.avatarSeed}`"
|
||||
:seed="fighterA.avatarSeed || fighterA.name"
|
||||
:archetype="fighterA.archetype || 'standard'"
|
||||
:tier="fighterA.tier"
|
||||
:size="160"
|
||||
pose="win"
|
||||
glow-color="cyan"
|
||||
class="relative z-10 fighter-bob
|
||||
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center min-w-0 -ml-6 sm:-ml-12 lg:-ml-20">
|
||||
<div style="transform: scaleX(-1)">
|
||||
<PosterSprite
|
||||
v-if="fighterB"
|
||||
:key="`b-${featured.id}-${fighterB.avatarSeed}`"
|
||||
:seed="fighterB.avatarSeed || fighterB.name"
|
||||
:archetype="fighterB.archetype || 'standard'"
|
||||
:tier="fighterB.tier"
|
||||
:size="160"
|
||||
pose="idle"
|
||||
glow-color="pink"
|
||||
class="relative z-10 fighter-bob-delayed
|
||||
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS center with gloves -->
|
||||
<div class="shrink-0 flex flex-col items-center gap-1 px-2">
|
||||
<PixelGlove :size="28" class="sm:!w-[40px] rotate-12 glove-punch-left" />
|
||||
<div class="relative">
|
||||
<span class="font-neon text-3xl sm:text-5xl text-neon-purple glow-purple vs-pulse tracking-wider">VS</span>
|
||||
</div>
|
||||
<PixelGlove :size="28" flip class="sm:!w-[40px] -rotate-12 glove-punch-right" />
|
||||
</div>
|
||||
<!-- Names with VS orb in center -->
|
||||
<div class="flex items-stretch w-full max-w-3xl mt-1 sm:mt-2 lg:mt-3 shrink-0 relative">
|
||||
|
||||
<!-- Fighter B -->
|
||||
<div class="flex-1 flex flex-col items-center text-center min-w-0">
|
||||
<div class="relative mb-3 sm:mb-4">
|
||||
<div class="absolute -inset-3 rounded-full bg-neon-pink/10 blur-xl" />
|
||||
<SpritePreview
|
||||
v-if="featured.botB"
|
||||
:seed="featured.botB.avatarSeed || featured.botB.name"
|
||||
:archetype="featured.botB.archetype || 'standard'"
|
||||
:tier="featured.botB.tier"
|
||||
:size="100"
|
||||
class="relative z-10 drop-shadow-[0_0_20px_rgba(255,45,120,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob-delayed"
|
||||
style="transform: scaleX(-1)"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-pink glow-pink truncate w-full">
|
||||
{{ featured.botB?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
|
||||
:style="{ color: tierColors[featured.botB?.tier || 0] }">
|
||||
{{ tierNames[featured.botB?.tier || 0] }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
|
||||
{{ Math.round(featured.botB?.eloRating || 0) }} ELO
|
||||
</p>
|
||||
<!-- Fighter A — cyan -->
|
||||
<div class="flex-1 min-w-0 name-block-cyan border-2 border-neon-cyan/40 rounded-l-lg px-2 sm:px-3 pr-16 sm:pr-20 lg:pr-24 py-1.5 sm:py-2 lg:py-3 text-center">
|
||||
<p class="font-funky text-base sm:text-2xl lg:text-4xl tracking-widest text-neon-cyan truncate pb-0.5 poster-name-cyan uppercase">
|
||||
{{ fighterA?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[6px] sm:text-[8px] lg:text-[11px] tracking-[0.2em]"
|
||||
:style="{ color: tierColors[fighterA?.tier ?? 0] }">
|
||||
{{ tierNames[fighterA?.tier ?? 0] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- VS orb — 3D circle, bigger than name cards -->
|
||||
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-30">
|
||||
<div class="vs-orb" :class="{ 'vs-punching': isPunching }">
|
||||
<div class="vs-orb-inner flex items-center justify-center">
|
||||
<PixelGlove :size="22" class="sm:!w-[32px] lg:!w-[42px] vs-glove-left" :class="{ 'glove-smash-left': isPunching }" />
|
||||
<span v-if="!isPunching" class="font-pixel text-xl sm:text-3xl lg:text-4xl text-white tracking-widest leading-none mx-1.5 sm:mx-2">VS</span>
|
||||
<span v-else class="font-pixel text-2xl sm:text-4xl lg:text-5xl text-neon-yellow tracking-widest leading-none mx-0 vs-impact">💥</span>
|
||||
<PixelGlove :size="22" flip class="sm:!w-[32px] lg:!w-[42px] vs-glove-right" :class="{ 'glove-smash-right': isPunching }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom banner -->
|
||||
<div class="bg-gradient-to-r from-neon-cyan/10 via-neon-purple/10 to-neon-pink/10 px-4 py-3 text-center border-t border-white/5">
|
||||
<p class="font-display font-black text-xs sm:text-sm tracking-[0.2em] text-neon-purple group-hover:text-neon-cyan transition-colors">
|
||||
{{ featured.status === 'finished' ? 'WATCH REPLAY' : featured.status === 'live' ? 'WATCH LIVE' : 'VIEW FIGHT' }}
|
||||
<!-- Fighter B — pink -->
|
||||
<div class="flex-1 min-w-0 name-block-pink border-2 border-neon-pink/40 border-l-0 rounded-r-lg px-2 sm:px-3 pl-16 sm:pl-20 lg:pl-24 py-1.5 sm:py-2 lg:py-3 text-center">
|
||||
<p class="font-funky text-base sm:text-2xl lg:text-4xl tracking-widest text-neon-pink truncate pb-0.5 poster-name-pink uppercase">
|
||||
{{ fighterB?.name || '???' }}
|
||||
</p>
|
||||
<p class="font-pixel text-[6px] sm:text-[8px] lg:text-[11px] tracking-[0.2em]"
|
||||
:style="{ color: tierColors[fighterB?.tier ?? 0] }">
|
||||
{{ tierNames[fighterB?.tier ?? 0] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA button -->
|
||||
<div class="relative z-10 shrink-0 border-t border-white/5 px-4 py-2 sm:py-3 lg:py-4 text-center
|
||||
bg-gradient-to-r from-neon-pink/8 via-neon-purple/8 to-neon-cyan/8">
|
||||
<span class="inline-block px-6 sm:px-10 py-1.5 sm:py-2.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-[10px] sm:text-xs tracking-widest
|
||||
group-hover:bg-neon-pink/20 transition-all neon-border-pink">
|
||||
{{ featured.status === 'finished' ? 'WATCH REPLAY' : featured.status === 'live' ? 'WATCH LIVE' : 'VIEW FIGHT' }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Undercard fights -->
|
||||
<div v-if="fights.length > 1" class="mt-8 sm:mt-12">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
<p class="font-pixel text-[10px] text-neon-purple tracking-[0.3em]">UNDERCARD</p>
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
<!-- RIGHT: Fight list (bottom half on mobile, 1/3 on desktop) -->
|
||||
<div class="h-1/2 lg:h-full lg:w-1/3 flex flex-col overflow-hidden border-t lg:border-t-0 border-border/30">
|
||||
|
||||
<!-- Main event card — ALWAYS visible at top -->
|
||||
<button
|
||||
v-if="mainEvent && mainEvent.botA && mainEvent.botB"
|
||||
class="mx-4 mt-3 lg:mt-5 mb-2 px-3 py-2 border-2 rounded-lg transition-all text-left"
|
||||
:class="isMainFeatured
|
||||
? 'border-neon-yellow/50 bg-neon-yellow/10 ring-1 ring-neon-yellow/20'
|
||||
: 'border-neon-yellow/20 bg-neon-yellow/5 hover:bg-neon-yellow/10 hover:border-neon-yellow/40'"
|
||||
@click.prevent="selectMainEvent"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-pixel text-[8px] text-neon-yellow/70 tracking-[0.3em]">★ MAIN EVENT</p>
|
||||
<span v-if="isMainFeatured" class="font-pixel text-[7px] text-neon-yellow/50 tracking-wider">VIEWING</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1 flex items-center justify-end gap-1.5 min-w-0">
|
||||
<p class="font-display font-bold text-xs text-neon-cyan truncate">{{ mainEvent.botA.name }}</p>
|
||||
<SpritePreview
|
||||
:seed="mainEvent.botA.avatarSeed || mainEvent.botA.name"
|
||||
:archetype="mainEvent.botA.archetype || 'standard'"
|
||||
:tier="mainEvent.botA.tier"
|
||||
:size="20"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-pixel text-neon-yellow text-[10px] px-2 shrink-0">VS</span>
|
||||
<div class="flex-1 flex items-center gap-1.5 min-w-0">
|
||||
<SpritePreview
|
||||
:seed="mainEvent.botB.avatarSeed || mainEvent.botB.name"
|
||||
:archetype="mainEvent.botB.archetype || 'standard'"
|
||||
:tier="mainEvent.botB.tier"
|
||||
:size="20"
|
||||
class="shrink-0"
|
||||
style="transform: scaleX(-1)"
|
||||
/>
|
||||
<p class="font-display font-bold text-xs text-neon-pink truncate">{{ mainEvent.botB.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Undercard header -->
|
||||
<div class="px-4 py-2 lg:py-3 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
<p class="font-pixel text-[9px] sm:text-[10px] text-neon-purple tracking-[0.3em]">
|
||||
{{ undercardFights.length > 0 ? 'UNDERCARD' : 'NO OTHER BOUTS' }}
|
||||
</p>
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<RouterLink
|
||||
v-for="fight in fights.filter(f => f.id !== featured?.id).slice(0, 6)"
|
||||
<!-- Fight list -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-4 pb-2 lg:pb-4 space-y-1.5">
|
||||
<button
|
||||
v-for="fight in undercardFights"
|
||||
:key="fight.id"
|
||||
:to="`/arena/${fight.id}`"
|
||||
class="flex items-center px-3 sm:px-4 py-2.5 border border-border/50 rounded-lg
|
||||
bg-surface-raised/30 hover:border-neon-purple/30 hover:bg-surface-overlay/20
|
||||
transition-all group/card"
|
||||
class="w-full flex items-center px-3 py-2 border rounded-lg transition-all text-left"
|
||||
:class="fight.id === selectedId
|
||||
? 'border-neon-purple/50 bg-neon-purple/10 ring-1 ring-neon-purple/20'
|
||||
: (fight.botA && fight.botB)
|
||||
? 'border-border/50 bg-surface-raised/30 hover:border-neon-purple/30 hover:bg-surface-overlay/20'
|
||||
: 'border-border/30 bg-surface-raised/10 opacity-50 cursor-not-allowed'"
|
||||
:disabled="!fight.botA || !fight.botB"
|
||||
@click.prevent="fight.botA && fight.botB && selectFight(fight.id)"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-end gap-2 min-w-0">
|
||||
<p class="font-display font-bold text-xs sm:text-sm truncate"
|
||||
<div class="flex-1 flex items-center justify-end gap-1.5 min-w-0">
|
||||
<p class="font-display font-bold text-[11px] truncate"
|
||||
:class="fight.botA ? 'text-text-primary' : 'text-text-muted'">
|
||||
{{ fight.botA?.name || '???' }}
|
||||
</p>
|
||||
@@ -196,43 +353,48 @@ onMounted(async () => {
|
||||
:seed="fight.botA.avatarSeed || fight.botA.name"
|
||||
:archetype="fight.botA.archetype || 'standard'"
|
||||
:tier="fight.botA.tier"
|
||||
:size="28"
|
||||
:size="22"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-funky text-neon-purple text-xs sm:text-sm px-2 sm:px-3 shrink-0">VS</span>
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="font-funky text-neon-purple text-[10px] px-1.5 shrink-0">VS</span>
|
||||
<div class="flex-1 flex items-center gap-1.5 min-w-0">
|
||||
<SpritePreview
|
||||
v-if="fight.botB"
|
||||
:seed="fight.botB.avatarSeed || fight.botB.name"
|
||||
:archetype="fight.botB.archetype || 'standard'"
|
||||
:tier="fight.botB.tier"
|
||||
:size="28"
|
||||
:size="22"
|
||||
class="shrink-0"
|
||||
style="transform: scaleX(-1)"
|
||||
/>
|
||||
<p class="font-display font-bold text-xs sm:text-sm truncate"
|
||||
<p class="font-display font-bold text-[11px] truncate"
|
||||
:class="fight.botB ? 'text-text-primary' : 'text-text-muted'">
|
||||
{{ fight.botB?.name || '???' }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="fight.status === 'finished'" class="font-mono text-[9px] text-text-muted ml-2 shrink-0">R{{ fight.totalRounds }}</span>
|
||||
<span v-else-if="fight.status === 'live'" class="font-display text-[10px] text-neon-yellow ml-2 shrink-0 animate-pulse">LIVE</span>
|
||||
<div class="ml-1.5 shrink-0">
|
||||
<span v-if="fight.botA?.botType === 'classic' || fight.botB?.botType === 'classic'"
|
||||
class="font-display text-[8px] text-text-muted/50 tracking-wider">PRAC</span>
|
||||
<span v-else-if="fight.status === 'finished'" class="font-mono text-[8px] text-text-muted">R{{ fight.totalRounds }}</span>
|
||||
<span v-else-if="fight.status === 'live'" class="font-display text-[9px] text-neon-yellow animate-pulse">LIVE</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Enter the ring -->
|
||||
<div class="px-4 pb-3 lg:pb-5 shrink-0">
|
||||
<RouterLink
|
||||
to="/join"
|
||||
class="block text-center px-6 py-2.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-[10px] sm:text-xs tracking-widest
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink"
|
||||
>
|
||||
ENTER THE RING
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-8 sm:mt-10 text-center">
|
||||
<RouterLink
|
||||
to="/join"
|
||||
class="inline-block px-8 sm:px-12 py-3 sm:py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-sm sm:text-base tracking-widest
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink"
|
||||
>
|
||||
ENTER THE RING
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -250,17 +412,149 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 3D card effect */
|
||||
.fight-card-3d {
|
||||
perspective: 1000px;
|
||||
/* ═══ Poster background effects ═══ */
|
||||
.poster-bg {
|
||||
background: linear-gradient(180deg,
|
||||
rgba(15, 5, 25, 1) 0%,
|
||||
rgba(10, 8, 20, 1) 40%,
|
||||
rgba(8, 12, 22, 1) 100%);
|
||||
}
|
||||
.fight-card-inner {
|
||||
transform: rotateX(2deg);
|
||||
transform-origin: center bottom;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
/* Cross-hatched diagonal lines */
|
||||
.poster-crosshatch {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 30px,
|
||||
rgba(255, 255, 255, 0.04) 30px,
|
||||
rgba(255, 255, 255, 0.04) 32px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 30px,
|
||||
rgba(255, 255, 255, 0.04) 30px,
|
||||
rgba(255, 255, 255, 0.04) 32px
|
||||
);
|
||||
}
|
||||
.fight-card-3d:hover .fight-card-inner {
|
||||
transform: rotateX(0deg) scale(1.01);
|
||||
|
||||
/* CRT scan lines */
|
||||
.poster-scanlines {
|
||||
opacity: 0.04;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.8) 2px,
|
||||
rgba(0, 0, 0, 0.8) 4px
|
||||
);
|
||||
}
|
||||
|
||||
/* Poster name glow — 3D embossed neon */
|
||||
.poster-name-cyan {
|
||||
-webkit-text-stroke: 1px rgba(0, 180, 200, 0.6);
|
||||
text-shadow:
|
||||
2px 2px 0 rgba(0, 100, 130, 0.8),
|
||||
0 0 10px rgba(0, 240, 255, 0.6),
|
||||
0 0 30px rgba(0, 240, 255, 0.3),
|
||||
0 0 60px rgba(0, 240, 255, 0.15);
|
||||
}
|
||||
.poster-name-pink {
|
||||
-webkit-text-stroke: 1px rgba(200, 30, 90, 0.6);
|
||||
text-shadow:
|
||||
2px 2px 0 rgba(130, 20, 60, 0.8),
|
||||
0 0 10px rgba(255, 45, 120, 0.6),
|
||||
0 0 30px rgba(255, 45, 120, 0.3),
|
||||
0 0 60px rgba(255, 45, 120, 0.15);
|
||||
}
|
||||
.poster-title {
|
||||
text-shadow:
|
||||
0 0 15px rgba(255, 45, 120, 0.6),
|
||||
0 0 40px rgba(255, 45, 120, 0.3),
|
||||
0 0 80px rgba(255, 45, 120, 0.15);
|
||||
}
|
||||
|
||||
/* Name block containers */
|
||||
.name-block-cyan {
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.08) 0%, rgba(0, 240, 255, 0.02) 100%);
|
||||
}
|
||||
.name-block-pink {
|
||||
background: linear-gradient(225deg, rgba(255, 45, 120, 0.08) 0%, rgba(255, 45, 120, 0.02) 100%);
|
||||
}
|
||||
|
||||
/* ═══ VS Orb — 3D circle ═══ */
|
||||
.vs-orb {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%, rgba(220, 160, 255, 0.9) 0%, rgba(168, 85, 247, 0.95) 40%, rgba(100, 30, 180, 1) 100%);
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow:
|
||||
0 0 20px rgba(168, 85, 247, 0.6),
|
||||
0 0 40px rgba(168, 85, 247, 0.3),
|
||||
0 0 60px rgba(168, 85, 247, 0.15),
|
||||
inset 0 -4px 8px rgba(0,0,0,0.4),
|
||||
inset 0 4px 8px rgba(255,255,255,0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.vs-orb { width: 144px; height: 144px; border-width: 4px; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.vs-orb { width: 176px; height: 176px; border-width: 5px; }
|
||||
}
|
||||
.vs-orb-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Punch animation */
|
||||
.vs-punching {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.vs-impact {
|
||||
animation: impactShake 0.3s ease-out;
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
@keyframes impactShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-2px) rotate(-5deg); }
|
||||
75% { transform: translateX(2px) rotate(5deg); }
|
||||
}
|
||||
.glove-smash-left {
|
||||
animation: smashLeft 0.4s ease-out !important;
|
||||
}
|
||||
.glove-smash-right {
|
||||
animation: smashRight 0.4s ease-out !important;
|
||||
}
|
||||
@keyframes smashLeft {
|
||||
0% { transform: rotate(12deg) translateX(-8px); }
|
||||
40% { transform: rotate(30deg) translateX(4px); }
|
||||
60% { transform: rotate(15deg) translateX(2px); }
|
||||
100% { transform: rotate(12deg) translateX(0); }
|
||||
}
|
||||
@keyframes smashRight {
|
||||
0% { transform: rotate(-12deg) translateX(8px); }
|
||||
40% { transform: rotate(-30deg) translateX(-4px); }
|
||||
60% { transform: rotate(-15deg) translateX(-2px); }
|
||||
100% { transform: rotate(-12deg) translateX(0); }
|
||||
}
|
||||
|
||||
/* Normal glove idle */
|
||||
.vs-glove-left {
|
||||
animation: punchLeft 2s ease-in-out infinite;
|
||||
}
|
||||
.vs-glove-right {
|
||||
animation: punchRight 2s ease-in-out infinite 0.3s;
|
||||
}
|
||||
|
||||
/* Fighter bobbing */
|
||||
@@ -276,12 +570,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* Glove punch animations */
|
||||
.glove-punch-left {
|
||||
animation: punchLeft 2s ease-in-out infinite;
|
||||
}
|
||||
.glove-punch-right {
|
||||
animation: punchRight 2s ease-in-out infinite 0.3s;
|
||||
}
|
||||
@keyframes punchLeft {
|
||||
0%, 70%, 100% { transform: rotate(12deg) translateX(0); }
|
||||
80% { transform: rotate(25deg) translateX(6px); }
|
||||
@@ -292,18 +580,4 @@ onMounted(async () => {
|
||||
80% { transform: rotate(-25deg) translateX(-6px); }
|
||||
85% { transform: rotate(-8deg) translateX(2px); }
|
||||
}
|
||||
|
||||
/* VS pulse */
|
||||
.vs-pulse {
|
||||
animation: vsPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes vsPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* Neon purple glow utility */
|
||||
.glow-purple {
|
||||
text-shadow: 0 0 10px rgba(168, 85, 247, 0.6), 0 0 30px rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -153,7 +153,15 @@ const myBotId = computed(() => {
|
||||
|
||||
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
|
||||
|
||||
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
|
||||
let _pageDestroyed = false
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (_pageDestroyed) reject(new Error('unmounted'))
|
||||
else resolve()
|
||||
}, ms)
|
||||
})
|
||||
}
|
||||
function scrollLiveLog() { nextTick(() => { liveLogEl.value?.scrollTo({ top: liveLogEl.value.scrollHeight, behavior: 'smooth' }) }) }
|
||||
|
||||
const challengeLabel = (type: string) => {
|
||||
@@ -401,7 +409,7 @@ function connectSSE() {
|
||||
|
||||
eventSource.addEventListener('round_end', (e) => {
|
||||
try {
|
||||
handleRoundEnd(JSON.parse(e.data))
|
||||
handleRoundEnd(JSON.parse(e.data)).catch(() => {})
|
||||
} catch (err) {
|
||||
console.warn('[FightPage] SSE round_end failed:', err)
|
||||
}
|
||||
@@ -409,7 +417,7 @@ function connectSSE() {
|
||||
|
||||
eventSource.addEventListener('fight_end', (e) => {
|
||||
try {
|
||||
handleFightEnd(JSON.parse(e.data))
|
||||
handleFightEnd(JSON.parse(e.data)).catch(() => {})
|
||||
} catch (err) {
|
||||
console.warn('[FightPage] SSE fight_end failed:', err)
|
||||
}
|
||||
@@ -660,6 +668,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
_pageDestroyed = true
|
||||
if (pollHandle) clearInterval(pollHandle)
|
||||
stopHumanPolling()
|
||||
disconnectSSE()
|
||||
|
||||
@@ -8,7 +8,10 @@ import HumanPreview from '../components/HumanPreview.vue'
|
||||
import WalletConnect from '../components/WalletConnect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, registerHuman, logout } = useNostr()
|
||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr()
|
||||
const showNsecBackup = ref(false)
|
||||
const generatedNsec = ref('')
|
||||
const nsecInput = ref('')
|
||||
const { isWalletConnected, payEntryFee, paymentStatus } = useWallet()
|
||||
|
||||
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
|
||||
@@ -19,6 +22,7 @@ const selectedHumanSeed = ref('baby_fighter_1')
|
||||
const error = ref('')
|
||||
const isJoining = ref(false)
|
||||
const isJoiningRanked = ref(false)
|
||||
const isJoiningPractice = ref(false)
|
||||
const queueCount = ref(0)
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -29,6 +33,7 @@ const humanName = ref('')
|
||||
const webhookUrl = ref('')
|
||||
const showCode = ref(false)
|
||||
const codeCopied = ref(false)
|
||||
const nsecCopied = ref(false)
|
||||
|
||||
const BOT_CODE = `const http = require('http')
|
||||
|
||||
@@ -57,6 +62,12 @@ function copyCode() {
|
||||
setTimeout(() => { codeCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function copyNsec() {
|
||||
navigator.clipboard.writeText(generatedNsec.value)
|
||||
nsecCopied.value = true
|
||||
setTimeout(() => { nsecCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
const archetypeList = [
|
||||
{ id: 'standard', label: 'FIGHTER', desc: 'Classic brawler' },
|
||||
{ id: 'lobster', label: 'LOBSTER', desc: 'Pinchy menace' },
|
||||
@@ -125,6 +136,71 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerateLogin() {
|
||||
error.value = ''
|
||||
const { nsec } = generateLogin()
|
||||
generatedNsec.value = nsec
|
||||
showNsecBackup.value = true
|
||||
}
|
||||
|
||||
async function handleNsecBackupDone() {
|
||||
showNsecBackup.value = false
|
||||
try {
|
||||
const result = await login()
|
||||
if (result.bot) {
|
||||
isHumanMode.value = !!result.bot.isHuman
|
||||
step.value = 'ready'
|
||||
} else {
|
||||
step.value = 'choose-mode'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNsecLogin() {
|
||||
error.value = ''
|
||||
const input = nsecInput.value.trim()
|
||||
if (!input) {
|
||||
error.value = 'Enter your nsec1... or hex key.'
|
||||
return
|
||||
}
|
||||
|
||||
let hexKey: string
|
||||
if (input.startsWith('nsec1')) {
|
||||
try {
|
||||
const { decode } = await import('nostr-tools/nip19')
|
||||
const decoded = decode(input)
|
||||
if (decoded.type !== 'nsec') {
|
||||
error.value = 'Invalid nsec key.'
|
||||
return
|
||||
}
|
||||
const { bytesToHex } = await import('nostr-tools/utils')
|
||||
hexKey = bytesToHex(decoded.data)
|
||||
} catch {
|
||||
error.value = 'Invalid nsec key.'
|
||||
return
|
||||
}
|
||||
} else if (/^[0-9a-f]{64}$/.test(input)) {
|
||||
hexKey = input
|
||||
} else {
|
||||
error.value = 'Enter a valid nsec1... or 64-char hex key.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginWithNsec(hexKey)
|
||||
if (result.bot) {
|
||||
isHumanMode.value = !!result.bot.isHuman
|
||||
step.value = 'ready'
|
||||
} else {
|
||||
step.value = 'choose-mode'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed.'
|
||||
}
|
||||
}
|
||||
|
||||
function pickCharacter(id: string) {
|
||||
selectedArchetype.value = id
|
||||
step.value = 'name-bot'
|
||||
@@ -271,6 +347,25 @@ async function fightRanked() {
|
||||
isJoiningRanked.value = false
|
||||
}
|
||||
|
||||
async function practice() {
|
||||
if (!bot.value || isJoiningPractice.value) return
|
||||
isJoiningPractice.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch(`/api/fights/practice/${bot.value.id}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/arena/${data.fightId}`)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
error.value = data.error || 'Failed to start practice fight.'
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Network error.'
|
||||
}
|
||||
isJoiningPractice.value = false
|
||||
}
|
||||
|
||||
function handleSignOut() {
|
||||
logout()
|
||||
step.value = 'login'
|
||||
@@ -299,31 +394,98 @@ function handleSignOut() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasExtension"
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
|
||||
</button>
|
||||
|
||||
<div v-else class="text-center p-6 border-2 border-border bg-surface">
|
||||
<p class="font-display font-bold text-sm text-text-secondary tracking-wider mb-3">
|
||||
NOSTR EXTENSION REQUIRED
|
||||
<!-- Nsec backup modal -->
|
||||
<div v-if="showNsecBackup" class="p-6 border-2 border-neon-yellow/50 bg-neon-yellow/5 space-y-4">
|
||||
<p class="font-display font-black text-sm tracking-wider text-neon-yellow">
|
||||
SAVE YOUR SECRET KEY
|
||||
</p>
|
||||
<p class="font-mono text-xs text-text-muted leading-relaxed">
|
||||
Install a NIP-07 browser extension like
|
||||
<span class="text-neon-cyan">nos2x</span>,
|
||||
<span class="text-neon-cyan">Alby</span>, or
|
||||
<span class="text-neon-cyan">Flamingo</span>
|
||||
to sign in.
|
||||
This is your login key. Save it somewhere safe. If you lose it, you lose your account.
|
||||
</p>
|
||||
<div class="relative">
|
||||
<div class="bg-black/50 p-3 pr-16 border border-border font-mono text-xs text-neon-cyan break-all select-all">
|
||||
{{ generatedNsec }}
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-2 right-2 px-2 py-1 text-[9px] font-display font-bold tracking-wider
|
||||
border border-border hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
|
||||
:class="nsecCopied ? 'text-neon-cyan border-neon-cyan/40' : 'text-text-muted'"
|
||||
@click="copyNsec"
|
||||
>
|
||||
{{ nsecCopied ? 'COPIED' : 'COPY' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-3 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
|
||||
font-display font-black text-sm tracking-widest
|
||||
hover:bg-neon-green/20 hover:border-neon-green transition-all"
|
||||
@click="handleNsecBackupDone"
|
||||
>
|
||||
I SAVED IT — CONTINUE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!showNsecBackup" class="space-y-3">
|
||||
<!-- Sign in with extension -->
|
||||
<button
|
||||
v-if="hasExtension"
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
|
||||
</button>
|
||||
|
||||
<!-- Sign in with stored key -->
|
||||
<button
|
||||
v-if="!hasExtension && hasStoredKey"
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN' }}
|
||||
</button>
|
||||
|
||||
<!-- Always show Generate Login -->
|
||||
<button
|
||||
class="w-full py-4 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-green/20 hover:border-neon-green transition-all"
|
||||
@click="handleGenerateLogin"
|
||||
>
|
||||
GENERATE NEW IDENTITY
|
||||
</button>
|
||||
|
||||
<!-- Import existing key -->
|
||||
<div class="text-center font-mono text-[10px] text-text-muted">OR IMPORT EXISTING KEY</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="nsecInput"
|
||||
type="password"
|
||||
placeholder="Paste nsec1... or hex key"
|
||||
class="flex-1 px-3 py-2 bg-black/30 border border-border font-mono text-xs text-text-primary
|
||||
placeholder-text-muted/50 focus:outline-none focus:border-neon-purple/50"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 bg-neon-purple/10 border border-neon-purple/50 text-neon-purple
|
||||
font-display font-bold text-xs tracking-wider
|
||||
hover:bg-neon-purple/20 transition-all"
|
||||
@click="handleNsecLogin"
|
||||
>
|
||||
LOGIN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -413,15 +575,15 @@ function handleSignOut() {
|
||||
v-model="botName"
|
||||
type="text"
|
||||
required
|
||||
maxlength="32"
|
||||
placeholder="skull_crusher_9000"
|
||||
maxlength="12"
|
||||
placeholder="skull_crush"
|
||||
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-neon-cyan/50 transition-colors"
|
||||
@keyup.enter="confirmName"
|
||||
/>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-1.5">
|
||||
Letters, numbers, hyphens, underscores. 2-32 chars.
|
||||
Letters, numbers, hyphens, underscores. 2-12 chars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -667,15 +829,15 @@ function handleSignOut() {
|
||||
v-model="humanName"
|
||||
type="text"
|
||||
required
|
||||
maxlength="32"
|
||||
placeholder="big_brain_gary"
|
||||
maxlength="12"
|
||||
placeholder="big_brain"
|
||||
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-pink/50 transition-colors"
|
||||
@keyup.enter="confirmHumanName"
|
||||
/>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-1.5">
|
||||
Letters, numbers, hyphens, underscores. 2-32 chars.
|
||||
Letters, numbers, hyphens, underscores. 2-12 chars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -836,6 +998,25 @@ function handleSignOut() {
|
||||
<!-- Wallet connect (shown if no wallet) -->
|
||||
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
|
||||
|
||||
<!-- Practice fight — against bland classic bots, free -->
|
||||
<div class="pt-2 border-t border-border/30">
|
||||
<button
|
||||
class="w-full py-3 bg-white/3 border border-white/10 text-text-muted
|
||||
font-display font-bold text-sm tracking-widest
|
||||
hover:bg-white/5 hover:text-text-secondary transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-2"
|
||||
:disabled="isJoiningPractice"
|
||||
@click="practice"
|
||||
>
|
||||
<span v-if="isJoiningPractice" class="w-4 h-4 border-2 border-white/20 border-t-white/50 rounded-full animate-spin" />
|
||||
{{ isJoiningPractice ? 'STARTING...' : 'PRACTICE' }}
|
||||
</button>
|
||||
<p class="font-mono text-[10px] text-text-muted/50 text-center mt-1">
|
||||
Free sparring against practice bots — no ELO impact
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
|
||||
interface Bot {
|
||||
id: string
|
||||
name: string
|
||||
avatarSeed: string
|
||||
archetype: string
|
||||
eloRating: number
|
||||
wins: number
|
||||
losses: number
|
||||
@@ -50,7 +53,7 @@ const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50 neon-border-purple">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50">
|
||||
<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]">
|
||||
@@ -73,8 +76,15 @@ const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
|
||||
{{ 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">
|
||||
<RouterLink :to="`/bot/${bot.name}`" class="flex items-center gap-2 hover:text-neon-cyan transition-colors">
|
||||
<SpritePreview
|
||||
:seed="bot.avatarSeed || bot.name"
|
||||
:archetype="bot.archetype || 'standard'"
|
||||
:tier="bot.tier"
|
||||
:size="24"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="font-display font-bold text-sm tracking-wide text-text-primary truncate">
|
||||
{{ bot.name }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -78,8 +78,8 @@ function goFight() {
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
maxlength="32"
|
||||
placeholder="skull_crusher_9000"
|
||||
maxlength="12"
|
||||
placeholder="skull_crush"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user