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()
|
||||
|
||||
Reference in New Issue
Block a user