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:
Dorian
2026-03-08 10:33:30 +00:00
co-authored by Claude Opus 4.6
parent ccf4196647
commit f6eb7d2845
32 changed files with 1743 additions and 467 deletions
+20 -4
View File
@@ -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>
+50
View File
@@ -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>
+5 -4
View File
@@ -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()