Skip to content

暗夜幸存者 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 coolddowncooldown in 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.vue

  • Modify: 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

本站非赢利性质诛仙私服,任何人均可直接进入游戏,仅供个人娱乐、探讨、研究。 如果您认为我们侵犯了您的权益,请联系我们。