暗夜幸存者 Implementation Plan
For agentic workers: This plan implements the "暗夜幸存者" Vampire Survivors-like game in the existing Vue 3 + Canvas game project.
Goal: Build a playable top-down survivor game where the player auto-attacks enemies, collects XP, levels up, and survives as long as possible.
Architecture: Engine/Renderer separation (matching game/vue/tank/ and game/vue/plane/ patterns). Vue SFC is the glue layer between Canvas game loop and DOM overlays (HUD, level-up menu, game over).
Tech Stack: Vue 3 Composition API, TypeScript, HTML5 Canvas, Web Audio API
Task 1: Create constants.ts
Files:
Create:
game/vue/vampire/constants.ts[ ] Step 1: Create constants.ts with all game configuration
typescript
export const CANVAS_WIDTH = 800
export const CANVAS_HEIGHT = 600
export const PLAYER_RADIUS = 14
export const PLAYER_SPEED = 200
export const PLAYER_MAX_HP = 100
export const XP_PER_LEVEL_BASE = 50
export const XP_PER_LEVEL_MULT = 1.3
export const GAME_DURATION = 1200 // 20 minutes in seconds
export const WAVE_INTERVAL = 30 // seconds between waves
export const ENEMY_SPAWN_MARGIN = 50 // spawn outside screen
export const GEM_RADIUS = 6
export const GEM_SPEED = 200 // magnetic pull speed toward player
export const GEM_MAGNET_RANGE = 80
export interface WeaponDef {
id: string
name: string
desc: string
damage: number
cooldown: number
range: number
projectileSpeed: number
projectileRadius: number
area: number
color: string
type: 'projectile' | 'aura' | 'orbit' | 'lightning'
}
export const WEAPONS: Record<string, WeaponDef> = {
magicKnife: {
id: 'magicKnife',
name: '魔法飞刀',
desc: '朝最近敌人发射穿透飞刀',
damage: 15,
cooldown: 1.0,
range: 400,
projectileSpeed: 500,
projectileRadius: 4,
area: 1,
color: '#f1c40f',
type: 'projectile',
},
fireAura: {
id: 'fireAura',
name: '烈焰光环',
desc: '周身燃烧,靠近的敌人持续受伤',
damage: 10,
cooldown: 0.3,
range: 60,
projectileSpeed: 0,
projectileRadius: 0,
area: 1,
color: '#e74c3c',
type: 'aura',
},
lightning: {
id: 'lightning',
name: '闪电链',
desc: '随机劈中敌人并传导',
damage: 25,
cooldown: 2.0,
range: 500,
projectileSpeed: 0,
projectileRadius: 0,
area: 3,
color: '#9b59b6',
type: 'lightning',
},
boomerang: {
id: 'boomerang',
name: '回旋镖',
desc: '围绕角色旋转飞行',
damage: 12,
cooldown: 0.8,
range: 100,
projectileSpeed: 300,
projectileRadius: 6,
area: 1,
color: '#2ecc71',
type: 'orbit',
},
}
export interface EnemyDef {
id: string
name: string
hp: number
speed: number
damage: number
xp: number
size: number
color: string
}
export const ENEMIES: Record<string, EnemyDef> = {
bat: {
id: 'bat', name: '蝙蝠', hp: 8, speed: 120,
damage: 5, xp: 2, size: 10, color: '#8e44ad',
},
skeleton: {
id: 'skeleton', name: '骷髅兵', hp: 20, speed: 70,
damage: 10, xp: 5, size: 14, color: '#7f8c8d',
},
wolf: {
id: 'wolf', name: '恶狼', hp: 35, speed: 180,
damage: 15, xp: 8, size: 16, color: '#e67e22',
},
boss: {
id: 'boss', name: 'BOSS', hp: 200, speed: 50,
damage: 20, xp: 50, size: 24, color: '#c0392b',
},
}
export interface WaveDef {
enemies: { type: string; count: number }[]
}
export function generateWaveDef(wave: number): WaveDef {
const baseBats = 3 + wave * 2
const baseSkeletons = Math.max(0, wave - 1) * 1
const baseWolves = Math.max(0, wave - 3) * 1
const hasBoss = wave > 0 && wave % 5 === 0
return {
enemies: [
{ type: 'bat', count: baseBats },
...(baseSkeletons > 0 ? [{ type: 'skeleton' as const, count: baseSkeletons }] : []),
...(baseWolves > 0 ? [{ type: 'wolf' as const, count: baseWolves }] : []),
...(hasBoss ? [{ type: 'boss' as const, count: 1 }] : []),
],
}
}- [ ] Step 2: Verify constants.ts has no syntax errors
Run: npx tsc --noEmit game/vue/vampire/constants.ts 2>&1 || true
Task 2: Create engine.ts
Files:
Create:
game/vue/vampire/engine.ts[ ] Step 1: Create engine.ts with core game logic
typescript
import {
CANVAS_WIDTH, CANVAS_HEIGHT,
PLAYER_RADIUS, PLAYER_SPEED, PLAYER_MAX_HP,
XP_PER_LEVEL_BASE, XP_PER_LEVEL_MULT,
WAVE_INTERVAL, ENEMY_SPAWN_MARGIN,
GEM_RADIUS, GEM_SPEED, GEM_MAGNET_RANGE,
WEAPONS, ENEMIES, generateWaveDef,
WeaponDef, EnemyDef,
} from './constants'
export interface Player {
x: number; y: number
hp: number; maxHp: number
speed: number; radius: number
level: number; xp: number; xpToNext: number
weapons: { def: WeaponDef; level: number; cooldown: number }[]
kills: number
alive: boolean
}
export interface Enemy {
x: number; y: number
def: EnemyDef
hp: number
vx: number; vy: number
alive: boolean
}
export interface Projectile {
x: number; y: number
vx: number; vy: number
radius: number
damage: number
color: string
alive: boolean
piercing: boolean
}
export interface XpGem {
x: number; y: number
value: number
alive: boolean
}
export interface LightningBolt {
x: number; y: number
damage: number
area: number
color: string
timer: number
}
export interface GameState {
player: Player
enemies: Enemy[]
projectiles: Projectile[]
xpGems: XpGem[]
lightningBolts: LightningBolt[]
wave: number
elapsed: number
waveTimer: number
paused: boolean
gameOver: boolean
victory: boolean
levelingUp: boolean
levelUpChoices: { type: 'upgrade' | 'newWeapon' | 'heal'; weaponId?: string; label: string; desc: string }[]
}
export function createPlayer(): Player {
return {
x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT / 2,
hp: PLAYER_MAX_HP, maxHp: PLAYER_MAX_HP,
speed: PLAYER_SPEED, radius: PLAYER_RADIUS,
level: 1, xp: 0, xpToNext: XP_PER_LEVEL_BASE,
weapons: [{ def: WEAPONS.magicKnife, level: 1, cooldown: 0 }],
kills: 0,
alive: true,
}
}
let enemyIdCounter = 0
export function createEnemy(def: EnemyDef, x: number, y: number, targetX: number, targetY: number): Enemy {
const dx = targetX - x
const dy = targetY - y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
return {
x, y, def,
hp: def.hp,
vx: (dx / dist) * def.speed,
vy: (dy / dist) * def.speed,
alive: true,
}
}
export function updateGame(state: GameState, dt: number, inputDir: { x: number; y: number }): void {
if (state.paused || state.gameOver || state.victory || state.levelingUp) return
state.elapsed += dt
state.waveTimer += dt
// Wave spawning
if (state.waveTimer >= WAVE_INTERVAL) {
state.waveTimer -= WAVE_INTERVAL
state.wave++
const waveDef = generateWaveDef(state.wave)
spawnWave(state, waveDef)
}
// Player movement
const p = state.player
if (inputDir.x !== 0 || inputDir.y !== 0) {
const len = Math.sqrt(inputDir.x ** 2 + inputDir.y ** 2)
p.x += (inputDir.x / len) * p.speed * dt
p.y += (inputDir.y / len) * p.speed * dt
}
p.x = Math.max(p.radius, Math.min(CANVAS_WIDTH - p.radius, p.x))
p.y = Math.max(p.radius, Math.min(CANVAS_HEIGHT - p.radius, p.y))
// Weapon cooldowns
for (const w of p.weapons) {
w.cooldown -= dt
}
// Fire weapons
for (const w of p.weapons) {
if (w.cooldown > 0) continue
const def = w.def
w.cooldown = def.coolddown / (1 + (w.level - 1) * 0.15)
switch (def.type) {
case 'projectile': {
const target = findNearestEnemy(state, p.x, p.y, def.range)
if (!target) break
const dx = target.x - p.x
const dy = target.y - p.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
state.projectiles.push({
x: p.x, y: p.y,
vx: (dx / dist) * def.projectileSpeed,
vy: (dy / dist) * def.projectileSpeed,
radius: def.projectileRadius,
damage: def.damage * (1 + (w.level - 1) * 0.2),
color: def.color,
alive: true,
piercing: true,
})
break
}
case 'aura': {
const damage = def.damage * (1 + (w.level - 1) * 0.2) * dt
const range = def.range * (1 + (w.level - 1) * 0.1)
for (const e of state.enemies) {
if (!e.alive) continue
const dx = e.x - p.x
const dy = e.y - p.y
if (dx * dx + dy * dy < range * range) {
e.hp -= damage
if (e.hp <= 0) killEnemy(state, e)
}
}
break
}
case 'lightning': {
const target = findNearestEnemy(state, p.x, p.y, def.range)
if (!target) break
const damage = def.damage * (1 + (w.level - 1) * 0.25)
target.hp -= damage
if (target.hp <= 0) killEnemy(state, target)
state.lightningBolts.push({
x: target.x, y: target.y,
damage, area: def.area,
color: def.color,
timer: 0.3,
})
break
}
case 'orbit': {
const damage = def.damage * (1 + (w.level - 1) * 0.2)
const speed = def.projectileSpeed
const range = def.range * (1 + (w.level - 1) * 0.1)
const count = 1 + Math.floor((w.level - 1) / 2)
for (let i = 0; i < count; i++) {
const angle = (Math.PI * 2 / count) * i
state.projectiles.push({
x: p.x + Math.cos(angle) * range,
y: p.y + Math.sin(angle) * range,
vx: 0, vy: 0,
radius: 6,
damage, color: def.color,
alive: true,
piercing: true,
orbitCenter: p,
orbitAngle: angle,
orbitSpeed: speed / range,
orbitRadius: range,
} as any)
}
break
}
}
}
// Update projectiles
for (const pr of state.projectiles) {
if (!pr.alive) continue
if ((pr as any).orbitCenter) {
const o = pr as any
o.orbitAngle += o.orbitSpeed * dt
pr.x = o.orbitCenter.x + Math.cos(o.orbitAngle) * o.orbitRadius
pr.y = o.orbitCenter.y + Math.sin(o.orbitAngle) * o.orbitRadius
} else {
pr.x += pr.vx * dt
pr.y += pr.vy * dt
}
// Out of bounds check for non-orbit
if (!(pr as any).orbitCenter) {
if (pr.x < -100 || pr.x > CANVAS_WIDTH + 100 || pr.y < -100 || pr.y > CANVAS_HEIGHT + 100) {
pr.alive = false
continue
}
}
// Projectile-enemy collision
for (const e of state.enemies) {
if (!e.alive) continue
const dx = pr.x - e.x
const dy = pr.y - e.y
const hitDist = pr.radius + e.def.size
if (dx * dx + dy * dy < hitDist * hitDist) {
e.hp -= pr.damage
if (e.hp <= 0) killEnemy(state, e)
pr.alive = false
break
}
}
}
state.projectiles = state.projectiles.filter(p => p.alive)
// Update lightning bolts
for (const lb of state.lightningBolts) {
lb.timer -= dt
}
state.lightningBolts = state.lightningBolts.filter(lb => lb.timer > 0)
// Update enemies
for (const e of state.enemies) {
if (!e.alive) continue
e.x += e.vx * dt
e.y += e.vy * dt
// Enemy-player collision
const dx = e.x - p.x
const dy = e.y - p.y
const hitDist = e.def.size + p.radius
if (dx * dx + dy * dy < hitDist * hitDist) {
p.hp -= e.def.damage * dt
// Push enemy away
const dist = Math.sqrt(dx * dx + dy * dy) || 1
e.x += (dx / dist) * 20
e.y += (dy / dist) * 20
}
// Remove enemies that go too far off screen
if (e.x < -200 || e.x > CANVAS_WIDTH + 200 || e.y < -200 || e.y > CANVAS_HEIGHT + 200) {
e.alive = false
}
}
state.enemies = state.enemies.filter(e => e.alive)
// XP gem magnetic pull
for (const gem of state.xpGems) {
if (!gem.alive) continue
const dx = p.x - gem.x
const dy = p.y - gem.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < GEM_MAGNET_RANGE) {
gem.x += (dx / dist) * GEM_SPEED * dt
gem.y += (dy / dist) * GEM_SPEED * dt
}
if (dist < GEM_RADIUS + p.radius) {
gem.alive = false
p.xp += gem.value
if (p.xp >= p.xpToNext) {
p.xp -= p.xpToNext
p.level++
p.xpToNext = Math.floor(XP_PER_LEVEL_BASE * Math.pow(XP_PER_LEVEL_MULT, p.level))
triggerLevelUp(state)
}
}
}
state.xpGems = state.xpGems.filter(g => g.alive)
// Game over check
if (p.hp <= 0) {
p.hp = 0
p.alive = false
state.gameOver = true
}
// Victory check (20 minutes)
if (state.elapsed >= 1200) {
state.victory = true
}
}
function spawnWave(state: GameState, waveDef: { enemies: { type: string; count: number }[] }): void {
const p = state.player
for (const group of waveDef.enemies) {
const def = ENEMIES[group.type]
if (!def) continue
for (let i = 0; i < group.count; i++) {
const side = Math.floor(Math.random() * 4)
let x: number, y: number
switch (side) {
case 0: x = -ENEMY_SPAWN_MARGIN; y = Math.random() * CANVAS_HEIGHT; break
case 1: x = CANVAS_WIDTH + ENEMY_SPAWN_MARGIN; y = Math.random() * CANVAS_HEIGHT; break
case 2: x = Math.random() * CANVAS_WIDTH; y = -ENEMY_SPAWN_MARGIN; break
default: x = Math.random() * CANVAS_WIDTH; y = CANVAS_HEIGHT + ENEMY_SPAWN_MARGIN; break
}
state.enemies.push(createEnemy(def, x, y, p.x, p.y))
}
}
}
function findNearestEnemy(state: GameState, x: number, y: number, range: number): Enemy | null {
let nearest: Enemy | null = null
let minDist = range * range
for (const e of state.enemies) {
if (!e.alive) continue
const dx = e.x - x
const dy = e.y - y
const dist = dx * dx + dy * dy
if (dist < minDist) {
minDist = dist
nearest = e
}
}
return nearest
}
function killEnemy(state: GameState, enemy: Enemy): void {
enemy.alive = false
state.player.kills++
// Drop XP gem
state.xpGems.push({
x: enemy.x + (Math.random() - 0.5) * 10,
y: enemy.y + (Math.random() - 0.5) * 10,
value: enemy.def.xp,
alive: true,
})
}
function triggerLevelUp(state: GameState): void {
state.levelingUp = true
const choices: typeof state.levelUpChoices = []
const availableWeapons = Object.values(WEAPONS)
const ownedIds = new Set(state.player.weapons.map(w => w.def.id))
// Option 1: Upgrade existing weapon
const owned = state.player.weapons[Math.floor(Math.random() * state.player.weapons.length)]
choices.push({
type: 'upgrade',
weaponId: owned.def.id,
label: owned.def.name,
desc: `${owned.def.desc} (Lv.${owned.level} → Lv.${owned.level + 1})`,
})
// Option 2: New weapon if available
const newWeapons = availableWeapons.filter(w => !ownedIds.has(w.id))
if (newWeapons.length > 0) {
const pick = newWeapons[Math.floor(Math.random() * newWeapons.length)]
choices.push({
type: 'newWeapon',
weaponId: pick.id,
label: pick.name,
desc: `获得新武器:${pick.desc}`,
})
}
// Option 3: Heal
choices.push({
type: 'heal',
label: '生命恢复',
desc: '立即恢复 40 HP',
})
state.levelUpChoices = choices
}
export function applyLevelChoice(state: GameState, index: number): void {
const choice = state.levelUpChoices[index]
if (!choice) return
switch (choice.type) {
case 'upgrade': {
const w = state.player.weapons.find(w => w.def.id === choice.weaponId)
if (w) w.level++
break
}
case 'newWeapon': {
const def = WEAPONS[choice.weaponId!]
if (def) {
state.player.weapons.push({ def, level: 1, cooldown: 0 })
}
break
}
case 'heal': {
state.player.hp = Math.min(state.player.maxHp, state.player.hp + 40)
break
}
}
state.levelingUp = false
state.levelUpChoices = []
}
export function createInitialState(): GameState {
return {
player: createPlayer(),
enemies: [],
projectiles: [],
xpGems: [],
lightningBolts: [],
wave: 0,
elapsed: 0,
waveTimer: 0,
paused: false,
gameOver: false,
victory: false,
levelingUp: false,
levelUpChoices: [],
}
}- [ ] Step 2: Fix the typo
coolddown→cooldownin the engine
The constants.ts defines cooldown but engine.ts has a typo def.coolddown. Fix it:
typescript
w.cooldown = def.cooldown / (1 + (w.level - 1) * 0.15)Task 3: Create renderer.ts
Files:
Create:
game/vue/vampire/renderer.ts[ ] Step 1: Create renderer.ts with Canvas drawing logic
typescript
import {
CANVAS_WIDTH, CANVAS_HEIGHT, PLAYER_RADIUS, GEM_RADIUS,
} from './constants'
import { GameState, Player, Enemy, Projectile, XpGem, LightningBolt } from './engine'
export function renderGame(ctx: CanvasRenderingContext2D, state: GameState): void {
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
drawBackground(ctx)
drawGems(ctx, state.xpGems)
drawEnemies(ctx, state.enemies)
drawLightningBolts(ctx, state.lightningBolts)
drawProjectiles(ctx, state.projectiles)
drawPlayer(ctx, state.player)
drawWaveNumber(ctx, state.wave, state.elapsed)
}
function drawBackground(ctx: CanvasRenderingContext2D): void {
// Dark background
ctx.fillStyle = '#0a0a14'
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)'
ctx.lineWidth = 1
const gridSize = 40
for (let x = 0; x <= CANVAS_WIDTH; x += gridSize) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, CANVAS_HEIGHT)
ctx.stroke()
}
for (let y = 0; y <= CANVAS_HEIGHT; y += gridSize) {
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(CANVAS_WIDTH, y)
ctx.stroke()
}
}
function drawPlayer(ctx: CanvasRenderingContext2D, p: Player): void {
if (!p.alive) return
// Glow
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, PLAYER_RADIUS * 3)
gradient.addColorStop(0, 'rgba(46, 134, 193, 0.15)')
gradient.addColorStop(1, 'rgba(46, 134, 193, 0)')
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(p.x, p.y, PLAYER_RADIUS * 3, 0, Math.PI * 2)
ctx.fill()
// Body
ctx.fillStyle = '#5dade2'
ctx.beginPath()
ctx.arc(p.x, p.y, PLAYER_RADIUS, 0, Math.PI * 2)
ctx.fill()
// Inner highlight
ctx.fillStyle = 'rgba(255,255,255,0.3)'
ctx.beginPath()
ctx.arc(p.x - 3, p.y - 3, PLAYER_RADIUS * 0.35, 0, Math.PI * 2)
ctx.fill()
// HP bar above
const barW = 30
const barH = 4
const barX = p.x - barW / 2
const barY = p.y - PLAYER_RADIUS - 10
ctx.fillStyle = '#2a1a1a'
ctx.fillRect(barX, barY, barW, barH)
ctx.fillStyle = p.hp / p.maxHp > 0.3 ? '#2ecc71' : '#e74c3c'
ctx.fillRect(barX, barY, barW * (p.hp / p.maxHp), barH)
}
function drawEnemies(ctx: CanvasRenderingContext2D, enemies: Enemy[]): void {
for (const e of enemies) {
if (!e.alive) continue
const s = e.def.size
ctx.fillStyle = e.def.color
if (e.def.id === 'wolf') {
// Rectangle for wolf
ctx.fillRect(e.x - s, e.y - s / 2, s * 2, s)
} else {
ctx.beginPath()
ctx.arc(e.x, e.y, s, 0, Math.PI * 2)
ctx.fill()
}
// HP bar
const barW = s * 2
const barH = 3
const barX = e.x - barW / 2
const barY = e.y - s - 6
ctx.fillStyle = 'rgba(0,0,0,0.5)'
ctx.fillRect(barX, barY, barW, barH)
ctx.fillStyle = '#e74c3c'
ctx.fillRect(barX, barY, barW * (e.hp / e.def.hp), barH)
}
}
function drawProjectiles(ctx: CanvasRenderingContext2D, projectiles: Projectile[]): void {
for (const pr of projectiles) {
if (!pr.alive) continue
ctx.fillStyle = pr.color
ctx.shadowColor = pr.color
ctx.shadowBlur = 10
ctx.beginPath()
ctx.arc(pr.x, pr.y, pr.radius, 0, Math.PI * 2)
ctx.fill()
ctx.shadowBlur = 0
}
}
function drawGems(ctx: CanvasRenderingContext2D, gems: XpGem[]): void {
const floatOff = Math.sin(Date.now() / 200) * 2
for (const gem of gems) {
if (!gem.alive) continue
const gy = gem.y + floatOff
ctx.fillStyle = '#2ecc71'
ctx.shadowColor = '#2ecc71'
ctx.shadowBlur = 8
ctx.beginPath()
ctx.moveTo(gem.x, gy - GEM_RADIUS)
ctx.lineTo(gem.x + GEM_RADIUS, gy)
ctx.lineTo(gem.x, gy + GEM_RADIUS)
ctx.lineTo(gem.x - GEM_RADIUS, gy)
ctx.closePath()
ctx.fill()
ctx.shadowBlur = 0
}
}
function drawLightningBolts(ctx: CanvasRenderingContext2D, bolts: LightningBolt[]): void {
for (const lb of bolts) {
ctx.strokeStyle = lb.color
ctx.lineWidth = 3
ctx.shadowColor = lb.color
ctx.shadowBlur = 15
ctx.globalAlpha = Math.min(1, lb.timer * 5)
// Draw branching lines
for (let i = 0; i < 5; i++) {
const endX = lb.x + (Math.random() - 0.5) * lb.area * 20
const endY = lb.y + (Math.random() - 0.5) * lb.area * 20
ctx.beginPath()
ctx.moveTo(lb.x, lb.y - 20)
ctx.lineTo((lb.x + endX) / 2, (lb.y + endY) / 2 - 10)
ctx.lineTo(endX, endY)
ctx.stroke()
}
ctx.globalAlpha = 1
ctx.shadowBlur = 0
}
}
function drawWaveNumber(ctx: CanvasRenderingContext2D, wave: number, elapsed: number): void {
const minutes = Math.floor(elapsed / 60)
const seconds = Math.floor(elapsed % 60)
ctx.fillStyle = 'rgba(255,255,255,0.08)'
ctx.font = '14px monospace'
ctx.textAlign = 'center'
ctx.fillText(`Wave ${wave} ${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`, CANVAS_WIDTH / 2, 24)
}Task 4: Create vampire.vue (main SFC)
Files:
Create:
game/vue/vampire.vue[ ] Step 1: Create the main Vue SFC
vue
<template>
<div class="vampire-root" ref="rootEl">
<div class="game-header">
<button class="btn-back" @click="navigate('hall')">← 游戏大厅</button>
<h2>暗夜幸存者</h2>
<div class="header-actions">
<button class="btn-icon" @click="togglePause" :title="paused ? '继续' : '暂停'">
{{ paused ? '▶' : '⏸' }}
</button>
<button class="btn-icon" @click="toggleMuted">
{{ muted ? '🔇' : '🔊' }}
</button>
<button class="btn-icon" @click="toggleFullscreen">⛶</button>
</div>
</div>
<div class="game-body">
<!-- HUD -->
<div class="hud">
<div class="hud-left">
<span class="hud-label">生命</span>
<div class="hp-bar"><div class="hp-fill" :style="{ width: hpPercent + '%' }"></div></div>
<span class="hud-val" :class="{ 'hp-low': playerHp < 30 }">{{ playerHp }}/{{ playerMaxHp }}</span>
</div>
<div class="hud-center">
<span class="time">{{ timeDisplay }}</span>
</div>
<div class="hud-right">
<span class="hud-label">击杀</span>
<span class="hud-val">{{ kills }}</span>
<span class="hud-divider">|</span>
<span class="hud-label">等级</span>
<span class="hud-val">{{ level }}</span>
</div>
</div>
<div class="xp-bar-wrapper">
<div class="xp-bar"><div class="xp-fill" :style="{ width: xpPercent + '%' }"></div></div>
<span class="xp-text">{{ currentXp }} / {{ xpToNext }}</span>
</div>
<!-- Canvas -->
<canvas ref="canvasEl" :width="canvasW" :height="canvasH" class="game-canvas"></canvas>
<!-- Level Up Overlay -->
<div v-if="levelingUp" class="overlay levelup-overlay">
<div class="levelup-title">▲ 升级!▲</div>
<div class="levelup-choices">
<div
v-for="(choice, i) in levelUpChoices"
:key="i"
class="choice-card"
@click="selectLevelUp(i)"
>
<div class="choice-label">{{ choice.label }}</div>
<div class="choice-desc">{{ choice.desc }}</div>
</div>
</div>
</div>
<!-- Game Over Overlay -->
<div v-if="gameOver" class="overlay gameover-overlay">
<div class="gameover-title">💀 你倒下了</div>
<div class="gameover-stats">
<div>存活时间:<strong>{{ gameOverTime }}</strong></div>
<div>击杀敌人:<strong>{{ kills }}</strong></div>
<div>到达等级:<strong>{{ level }}</strong></div>
</div>
<button class="btn-restart" @click="restartGame">再来一次</button>
</div>
<!-- Victory Overlay -->
<div v-if="victory" class="overlay victory-overlay">
<div class="victory-title">🏆 胜利!</div>
<div class="gameover-stats">
<div>存活 <strong>20 分钟</strong></div>
<div>击杀敌人:<strong>{{ kills }}</strong></div>
<div>到达等级:<strong>{{ level }}</strong></div>
</div>
<button class="btn-restart" @click="restartGame">再来一次</button>
</div>
</div>
<!-- Mobile joystick -->
<VJoystick v-if="isMobile" @move="onJoystickMove" @end="onJoystickEnd" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { CANVAS_WIDTH, CANVAS_HEIGHT } from './vampire/constants'
import { createInitialState, updateGame, applyLevelChoice, GameState } from './vampire/engine'
import { renderGame } from './vampire/renderer'
import VJoystick from './components/VJoystick.vue'
const emit = defineEmits<{ navigate: [to: string] }>()
const rootEl = ref<HTMLDivElement>()
const canvasEl = ref<HTMLCanvasElement>()
const canvasW = ref(CANVAS_WIDTH)
const canvasH = ref(CANVAS_HEIGHT)
const state = ref<GameState>(createInitialState())
const muted = ref(false)
const paused = ref(false)
const isMobile = ref(false)
const playerHp = computed(() => Math.floor(state.value.player.hp))
const playerMaxHp = computed(() => state.value.player.maxHp)
const hpPercent = computed(() => (state.value.player.hp / state.value.player.maxHp) * 100)
const kills = computed(() => state.value.player.kills)
const level = computed(() => state.value.player.level)
const currentXp = computed(() => state.value.player.xp)
const xpToNext = computed(() => state.value.player.xpToNext)
const xpPercent = computed(() => (state.value.player.xp / state.value.player.xpToNext) * 100)
const gameOver = computed(() => state.value.gameOver)
const victory = computed(() => state.value.victory)
const levelingUp = computed(() => state.value.levelingUp)
const levelUpChoices = computed(() => state.value.levelUpChoices)
const timeDisplay = computed(() => {
const t = state.value.elapsed
const m = String(Math.floor(t / 60)).padStart(2, '0')
const s = String(Math.floor(t % 60)).padStart(2, '0')
return `${m}:${s}`
})
const gameOverTime = computed(() => {
const t = state.value.elapsed
const m = Math.floor(t / 60)
const s = Math.floor(t % 60)
return `${m} 分 ${s} 秒`
})
const inputDir = ref({ x: 0, y: 0 })
let animId = 0
let lastTime = 0
function gameLoop(time: number) {
const dt = Math.min((time - lastTime) / 1000, 0.05)
lastTime = time
updateGame(state.value, dt, inputDir.value)
const ctx = canvasEl.value?.getContext('2d')
if (ctx) renderGame(ctx, state.value)
animId = requestAnimationFrame(gameLoop)
}
function restartGame() {
state.value = createInitialState()
paused.value = false
}
function togglePause() {
paused.value = !paused.value
state.value.paused = paused.value
}
function toggleMuted() {
muted.value = !muted.value
}
function toggleFullscreen() {
// useFullscreen composable would go here
}
function selectLevelUp(index: number) {
applyLevelChoice(state.value, index)
}
function onJoystickMove(dir: { x: number; y: number }) {
inputDir.value = dir
}
function onJoystickEnd() {
inputDir.value = { x: 0, y: 0 }
}
function navigate(to: string) {
emit('navigate', to)
}
// Keyboard input
function onKeyDown(e: KeyboardEvent) {
switch (e.key) {
case 'w': case 'W': inputDir.value.y = -1; break
case 's': case 'S': inputDir.value.y = 1; break
case 'a': case 'A': inputDir.value.x = -1; break
case 'd': case 'D': inputDir.value.x = 1; break
case ' ': e.preventDefault(); togglePause(); break
}
}
function onKeyUp(e: KeyboardEvent) {
switch (e.key) {
case 'w': case 'W': if (inputDir.value.y < 0) inputDir.value.y = 0; break
case 's': case 'S': if (inputDir.value.y > 0) inputDir.value.y = 0; break
case 'a': case 'A': if (inputDir.value.x < 0) inputDir.value.x = 0; break
case 'd': case 'D': if (inputDir.value.x > 0) inputDir.value.x = 0; break
}
}
onMounted(() => {
isMobile.value = 'ontouchstart' in window
lastTime = performance.now()
animId = requestAnimationFrame(gameLoop)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
})
</script>
<style scoped>
.vampire-root {
position: relative;
width: 100%;
height: 100vh;
background: #0a0a14;
display: flex;
flex-direction: column;
overflow: hidden;
}
.game-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #12121f;
border-bottom: 1px solid #2a2a4a;
}
.game-header h2 {
margin: 0;
font-size: 16px;
color: #f1c40f;
}
.btn-back {
background: none;
border: 1px solid #2a2a4a;
color: #888;
padding: 4px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.btn-back:hover { color: #fff; border-color: #555; }
.header-actions { display: flex; gap: 8px; }
.btn-icon {
background: none;
border: none;
color: #666;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
}
.btn-icon:hover { color: #fff; }
.game-body {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.hud {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 800px;
padding: 8px 16px;
gap: 16px;
}
.hud-left, .hud-center, .hud-right {
display: flex;
align-items: center;
gap: 8px;
}
.hud-label { color: #666; font-size: 12px; }
.hud-val { color: #fff; font-size: 13px; font-weight: bold; }
.hud-val.hp-low { color: #e74c3c; }
.hud-divider { color: #333; }
.time { color: #f1c40f; font-size: 18px; font-weight: bold; font-family: monospace; }
.hp-bar {
width: 100px;
height: 8px;
background: #2a1a1a;
border-radius: 4px;
overflow: hidden;
}
.hp-fill {
height: 100%;
background: linear-gradient(90deg, #e74c3c, #e67e22);
border-radius: 4px;
transition: width 0.1s;
}
.xp-bar-wrapper {
width: 100%;
max-width: 768px;
padding: 0 16px 8px;
display: flex;
align-items: center;
gap: 8px;
}
.xp-bar {
flex: 1;
height: 6px;
background: #1a1a2e;
border-radius: 3px;
overflow: hidden;
}
.xp-fill {
height: 100%;
background: linear-gradient(90deg, #3498db, #9b59b6);
border-radius: 3px;
transition: width 0.15s;
}
.xp-text { color: #666; font-size: 11px; white-space: nowrap; }
.game-canvas {
flex: 1;
max-width: 100%;
max-height: calc(100vh - 120px);
border: 1px solid #1a1a2e;
border-radius: 8px;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 50;
}
.levelup-overlay {
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
}
.levelup-title {
font-size: 28px;
color: #f1c40f;
margin-bottom: 24px;
text-shadow: 0 0 20px rgba(241, 196, 15, 0.5);
}
.levelup-choices {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
}
.choice-card {
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 10px;
padding: 20px;
width: 160px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.choice-card:hover {
border-color: #f1c40f;
box-shadow: 0 0 20px rgba(241, 196, 15, 0.2);
transform: translateY(-4px);
}
.choice-label { font-size: 16px; color: #fff; margin-bottom: 6px; }
.choice-desc { font-size: 12px; color: #888; line-height: 1.5; }
.gameover-overlay {
background: rgba(10, 10, 20, 0.85);
}
.gameover-title { font-size: 32px; color: #e74c3c; margin-bottom: 16px; }
.victory-title { font-size: 32px; color: #f1c40f; margin-bottom: 16px; }
.gameover-stats {
font-size: 15px;
color: #aaa;
line-height: 2;
text-align: center;
margin-bottom: 24px;
}
.gameover-stats strong { color: #fff; }
.btn-restart {
background: #2e86c1;
border: none;
color: #fff;
padding: 10px 32px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.btn-restart:hover { background: #3498db; }
</style>Task 5: Add audio integration
Files:
Modify:
game/vue/vampire.vue(add audio calls)Reference:
game/vue/audio.ts(existing audio module)[ ] Step 1: Import and integrate audio.ts into vampire.vue
Add to the imports section of vampire.vue:
typescript
import { playStart, playGameOver, playVictory } from './audio'Then add audio calls to the game loop and lifecycle:
typescript
function gameLoop(time: number) {
const dt = Math.min((time - lastTime) / 1000, 0.05)
lastTime = time
const prevHp = state.value.player.hp
const prevGameOver = state.value.gameOver
const prevVictory = state.value.victory
const prevLevelingUp = state.value.levelingUp
const prevLevel = state.value.level
const prevKills = state.value.player.kills
updateGame(state.value, dt, inputDir.value)
// Play sound on hit
if (state.value.player.hp < prevHp && state.value.player.alive) {
playTone(200, 0.05, 'square', 0.1)
}
// Play sound on level up
if (state.value.levelingUp && !prevLevelingUp) {
playTone(523, 0.1, 'sine', 0.15)
setTimeout(() => playTone(659, 0.1, 'sine', 0.15), 100)
setTimeout(() => playTone(784, 0.15, 'sine', 0.15), 200)
}
// Play sound on kill
if (state.value.player.kills > prevKills) {
playTone(300 + Math.random() * 200, 0.03, 'square', 0.05)
}
// Play game over
if (state.value.gameOver && !prevGameOver) {
playGameOver()
}
// Play victory
if (state.value.victory && !prevVictory) {
playVictory()
}
const ctx = canvasEl.value?.getContext('2d')
if (ctx) renderGame(ctx, state.value)
animId = requestAnimationFrame(gameLoop)
}Add playStart() in onMounted:
typescript
onMounted(() => {
playStart()
// ... rest of existing code
})Also import playTone alongside the other audio functions:
typescript
import { playTone, playStart, playGameOver, playVictory } from './audio'Task 6: Register game in router and hall
Files:
Modify:
game/vue/game-router.vueModify:
game/vue/game-hall.vue[ ] Step 1: Add async import in game-router.vue
Find the existing async component imports in game-router.vue and add:
typescript
const Vampire = defineAsyncComponent(() => import('./vampire.vue'))Then register it in the game map alongside existing entries (same pattern as tetris, tank, etc.):
typescript
const gameMap: Record<string, Component> = {
// ... existing entries ...
vampire: markRaw(Vampire),
}- [ ] Step 2: Add hall card in game-hall.vue
Add a new card entry in game-hall.vue matching the existing card pattern:
vue
<div class="game-card" @click="navigate('vampire')">
<div class="game-icon" style="background: linear-gradient(135deg, #1a1a2e, #0a0a14); border: 2px solid #2e86c1;">
<span style="font-size: 32px;">🧛</span>
</div>
<div class="game-info">
<div class="game-name">暗夜幸存者</div>
<div class="game-desc">走位升级,撑到最后一刻</div>
</div>
</div>Task 7: Run and verify
Files:
Verify: whole game works end-to-end
[ ] Step 1: Start the dev server and navigate to the game
Run: npm run dev (or whatever command the project uses to start)
Verify:
- The game hall shows the "暗夜幸存者" card
- Clicking the card navigates to the game
- Canvas renders with player in center
- WASD moves the player
- Enemies spawn and move toward player
- Weapons auto-fire and kill enemies
- XP gems drop and get collected
- Level up triggers the overlay with 3 choices
- Game over works when HP reaches 0
- Victory triggers after 20 minutes (or can be tested by modifying elapsed time)
- Mobile joystick works on touch devices
