feat: production deployment — Dockerfile, docker-compose, SPA serving, classic bot fights
- Add Dockerfile (multi-stage: build frontend + server, serve from single container) - Add docker-compose.yml for Portainer stack deployment - Server serves frontend SPA in production (static assets + SPA fallback) - Auto-run migrations and seed mock bots on server startup - DB path configurable via DB_PATH env var - Add "Fight a Classic Bot" button for instant mock bot matches - FIGHT button queues for real AI opponents Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
120250c01a
commit
6bf9fe27b3
@@ -336,7 +336,7 @@ async function replay() {
|
||||
sfxDrumRoll()
|
||||
await sleep(500)
|
||||
announceFinishHim()
|
||||
await showOverlay('FINISH HIM!', '#ff2d2d', 900)
|
||||
await showOverlay('FINISH IT!', '#ff2d2d', 900)
|
||||
await sleep(150)
|
||||
|
||||
if (isPerfect) {
|
||||
@@ -344,7 +344,6 @@ async function replay() {
|
||||
announceFlawlessVictory()
|
||||
} else {
|
||||
await scene.playKO(winningSide, winnerName)
|
||||
announceFatality()
|
||||
}
|
||||
|
||||
glitching.value = true
|
||||
@@ -387,16 +386,27 @@ async function replay() {
|
||||
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
|
||||
</div>
|
||||
|
||||
<div ref="logEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1 leading-relaxed">
|
||||
<div ref="logEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
|
||||
<div v-for="(item, idx) in logItems" :key="idx">
|
||||
<div v-if="item.type === 'divider'" class="py-2" />
|
||||
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-2">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'prompt'" class="text-text-muted text-xs italic pl-2 pb-1">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'responseA'" class="text-neon-cyan text-sm pl-2">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'responseB'" class="text-neon-pink text-sm pl-2">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'narration'" class="text-neon-yellow font-bold text-sm pl-2 py-1">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
|
||||
<div v-if="item.type === 'divider'" class="py-1.5">
|
||||
<div class="border-t border-white/5" />
|
||||
</div>
|
||||
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
|
||||
<div v-else-if="item.type === 'prompt'" class="bg-white/[0.04] border border-white/[0.08] rounded-md px-3 py-2 my-1.5">
|
||||
<span class="text-neon-purple/60 font-bold text-[10px] uppercase tracking-widest block mb-1">Challenge</span>
|
||||
<p class="text-text-primary text-sm leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4 opacity-60">{{ item.text }}</p>
|
||||
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
|
||||
</div>
|
||||
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { generateHumanSpriteSheet, getBotColors, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_FRAME_SIZE } from '../game/sprites'
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string
|
||||
archetype?: string
|
||||
size?: number
|
||||
winRate?: number
|
||||
anim?: keyof typeof HUMAN_ANIMATIONS
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
let img: HTMLImageElement | null = null
|
||||
let frame = 0
|
||||
let animHandle: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function render() {
|
||||
if (!canvasRef.value || !img?.complete) return
|
||||
const ctx = canvasRef.value.getContext('2d')!
|
||||
const displaySize = props.size || 64
|
||||
canvasRef.value.width = displaySize
|
||||
canvasRef.value.height = displaySize
|
||||
ctx.clearRect(0, 0, displaySize, displaySize)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
const animName = props.anim || 'idle'
|
||||
const animCfg = HUMAN_ANIMATIONS[animName]
|
||||
const f = frame % animCfg.frames
|
||||
ctx.drawImage(
|
||||
img,
|
||||
f * HUMAN_FRAME_SIZE, animCfg.row * HUMAN_FRAME_SIZE, HUMAN_FRAME_SIZE, HUMAN_FRAME_SIZE,
|
||||
0, 0, displaySize, displaySize,
|
||||
)
|
||||
|
||||
frame++
|
||||
animHandle = setTimeout(() => render(), 180)
|
||||
}
|
||||
|
||||
function loadSprite() {
|
||||
const colors = getBotColors(props.seed)
|
||||
const dataUrl = generateHumanSpriteSheet(props.seed, props.archetype || 'standard', colors.primary, colors.secondary, props.winRate ?? 0.5)
|
||||
img = new Image()
|
||||
img.onload = () => render()
|
||||
img.src = dataUrl
|
||||
}
|
||||
|
||||
onMounted(() => loadSprite())
|
||||
|
||||
watch(() => [props.seed, props.archetype, props.winRate, props.anim], () => {
|
||||
if (animHandle) clearTimeout(animHandle)
|
||||
frame = 0
|
||||
loadSprite()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animHandle) clearTimeout(animHandle)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:style="{ width: `${size || 64}px`, height: `${size || 64}px`, imageRendering: 'pixelated' }"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user