【Web】使用Vue3+PlayCanvas开发3D游戏(二)3D 地图自由巡视闯关游戏

文章目录

一、效果

二、简介

在上一篇博客《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(一)3D 立方体交互式游戏》中,我们初步掌握了 PlayCanvas 的基础使用、Vue3 与 PlayCanvas 的结合方式,以及简单 3D 交互的实现逻辑。本文将在此基础上,进一步拓展开发难度,实现一款具备多关卡地图、角色移动、摄像机控制、小地图、闯关重置等核心功能的 3D 地图自由巡视闯关游戏,完整覆盖从基础交互到游戏化场景的开发全流程。

三、环境

  • OS:Windows11
  • Browser:Google
  • Node:v24.14.0
  • NPM:11.9.0
  • Vue:3.5.25
  • Vite:7.3.1

四、步骤

4.1、项目部署

创建项目和安装PlayCanvas我就不赘述了,想知道的,参考我写的上一篇《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(一)3D 立方体交互式游戏》文章

4.2、游戏功能规划

本次开发的 3D 地图闯关游戏需实现以下核心功能:

  1. 基础交互:方向键(↑↓←→)控制角色移动,WASD 键控制摄像机旋转;
  2. 视觉定制:自定义场景背景、地面、障碍物颜色(障碍物灰色、背景白色、每关地面差异化);
  3. 关卡系统:至少 3 张差异化地图,到达终点后按回车键进入下一关;
  4. 辅助功能:小地图实时显示角色、起点、终点、障碍物位置;
  5. 碰撞检测:角色与障碍物、地图边界碰撞,防止穿模;
  6. 通关判定:角色到达终点触发通关提示,支持回车重置关卡。

4.3、脚本逻辑(Script Setup)

脚本部分是游戏的核心,包含 PlayCanvas 初始化、关卡配置、角色移动、摄像机控制、碰撞检测、小地图绘制、通关判定等关键逻辑。

html 复制代码
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as pc from 'playcanvas'

// 游戏状态管理
const currentLevel = ref(1)  // 当前关卡
const isWon = ref(false)     // 是否通关
let app = null               // PlayCanvas应用实例
let player = null            // 玩家实体
let startPoint = null        // 起点实体
let endPoint = null          // 终点实体
let cameraEntity = null      // 摄像机实体
let globalEnterHandler = null// 全局回车监听(兜底)

// 基础配置
const moveSpeed = 8                // 角色移动速度
const cameraRotateSpeed = 60       // 摄像机旋转速度(度/秒)
let cameraYaw = 0                  // 摄像机水平旋转角(Y轴)
let cameraPitch = -30              // 摄像机垂直旋转角(X轴)
const cameraDistance = 15          // 摄像机到玩家的距离

// 小地图相关
let minimapCanvas = null
let minimapCtx = null
const minimapScale = 5             // 小地图缩放比例
let obstacles = []                 // 障碍物信息(碰撞+小地图)

// ========== 1. 多关卡配置 ==========
const levelConfigs = [
  // 第1关:20x20地面,3个障碍物,绿色地面
  {
    groundSize: 20,
    startPos: new pc.Vec3(-8, 0.5, -8),
    endPos: new pc.Vec3(8, 0.5, 8),
    obstacleCount: 3,
    groundColor: [0.3, 0.7, 0.3],    // 绿色地面
    obstacleColor: [0.5, 0.5, 0.5],  // 中灰色障碍物
    sceneBgColor: [1, 1, 1]          // 白色背景
  },
  // 第2关:25x25地面,5个障碍物,青绿色地面
  {
    groundSize: 25,
    startPos: new pc.Vec3(-10, 0.5, 0),
    endPos: new pc.Vec3(10, 0.5, 0),
    obstacleCount: 5,
    groundColor: [0.3, 0.7, 0.7],    // 青绿色地面
    obstacleColor: [0.6, 0.6, 0.6],  // 浅灰色障碍物
    sceneBgColor: [1, 1, 1]
  },
  // 第3关:30x30地面,7个障碍物,黄绿色地面
  {
    groundSize: 30,
    startPos: new pc.Vec3(0, 0.5, -12),
    endPos: new pc.Vec3(0, 0.5, 12),
    obstacleCount: 7,
    groundColor: [0.7, 0.7, 0.3],    // 黄绿色地面
    obstacleColor: [0.4, 0.4, 0.4],  // 深灰色障碍物
    sceneBgColor: [1, 1, 1]
  }
]

// ========== 2. 核心方法 ==========
/**
 * 下一关逻辑(抽离为独立方法,便于复用)
 */
const goToNextLevel = () => {
  if (isWon.value) {
    console.log('进入下一关,当前关卡:', currentLevel.value)
    // 关卡循环(超过配置数回到第一关)
    currentLevel.value = currentLevel.value % levelConfigs.length + 1
    isWon.value = false
    generateNewLevel()
    // 重新聚焦画布,确保键盘事件生效
    document.getElementById('pc-canvas').focus()
  }
}

/**
 * 初始化PlayCanvas应用
 */
async function initPlayCanvas() {
  try {
    await nextTick()
    const canvas = document.getElementById('pc-canvas')
    if (!canvas) {
      console.error('Canvas元素未找到!')
      return
    }

    // 关键:确保Canvas获取焦点(解决键盘事件不触发)
    canvas.focus()
    canvas.addEventListener('click', () => canvas.focus())

    // 初始化小地图
    initMinimap()

    // 1. 创建图形设备(禁用物理引擎,降低依赖)
    const graphicsDevice = new pc.WebglGraphicsDevice(canvas, {
      antialias: true,
      powerPreference: "high-performance"
    })
    graphicsDevice.maxPixelRatio = window.devicePixelRatio

    // 2. 创建PlayCanvas应用实例
    app = new pc.Application(canvas, {
      graphicsDevice: graphicsDevice,
      mouse: new pc.Mouse(canvas),
      keyboard: new pc.Keyboard(window),
      createCanvas: false,
      physics: false // 禁用物理引擎,纯数学碰撞检测
    })

    // 3. 启动应用
    await app.start()

    // 4. 配置场景
    if (!app.scene) app.scene = new pc.Scene(app.graphicsDevice)
    app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW)
    app.setCanvasResolution(pc.RESOLUTION_AUTO)

    // 生成第一关地图
    generateNewLevel()
    
    // 注册帧更新循环
    app.on('update', update)
    
    // ========== 键盘监听(双重保障) ==========
    // 1. PlayCanvas内置监听
    app.keyboard.off(pc.KEY_ENTER, goToNextLevel)
    app.keyboard.on(pc.KEY_ENTER, goToNextLevel)
    // 数字码兜底(兼容不同PlayCanvas版本)
    app.keyboard.off(13, goToNextLevel)
    app.keyboard.on(13, goToNextLevel)

    // 2. 全局键盘监听(防止内置监听失效)
    globalEnterHandler = (e) => {
      if (e.key === 'Enter' || e.keyCode === 13) {
        e.preventDefault() // 阻止默认行为(如页面刷新)
        goToNextLevel()
      }
    }
    window.addEventListener('keydown', globalEnterHandler)

    // 窗口大小适配
    window.addEventListener('resize', () => {
      if (app && app.graphicsDevice) {
        app.resizeCanvas(window.innerWidth, window.innerHeight)
        app.graphicsDevice.resize(window.innerWidth, window.innerHeight)
      }
    })
    app.resizeCanvas(window.innerWidth, window.innerHeight)
    
  } catch (error) {
    console.error('PlayCanvas初始化失败:', error)
  }
}

/**
 * 初始化小地图
 */
function initMinimap() {
  minimapCanvas = document.getElementById('minimap-canvas')
  minimapCtx = minimapCanvas.getContext('2d')
}

/**
 * 绘制小地图
 */
function drawMinimap() {
  if (!minimapCtx || !player || !startPoint || !endPoint) return

  // 清空小地图
  minimapCtx.clearRect(0, 0, minimapCanvas.width, minimapCanvas.height)
  minimapCtx.fillStyle = '#222'
  minimapCtx.fillRect(0, 0, minimapCanvas.width, minimapCanvas.height)
  
  const centerX = minimapCanvas.width / 2
  const centerY = minimapCanvas.height / 2

  // 绘制障碍物
  minimapCtx.fillStyle = '#666'
  obstacles.forEach(obs => {
    const x = centerX + obs.pos.x * minimapScale
    const y = centerY + obs.pos.z * minimapScale
    const w = obs.scale.x * minimapScale
    const h = obs.scale.z * minimapScale
    minimapCtx.fillRect(x - w/2, y - h/2, w, h)
  })

  // 绘制起点(红色)
  minimapCtx.fillStyle = '#ff0000'
  const startX = centerX + startPoint.getPosition().x * minimapScale
  const startY = centerY + startPoint.getPosition().z * minimapScale
  minimapCtx.beginPath()
  minimapCtx.arc(startX, startY, 4, 0, Math.PI * 2)
  minimapCtx.fill()

  // 绘制终点(绿色)
  minimapCtx.fillStyle = '#00ff00'
  const endX = centerX + endPoint.getPosition().x * minimapScale
  const endY = centerY + endPoint.getPosition().z * minimapScale
  minimapCtx.beginPath()
  minimapCtx.arc(endX, endY, 4, 0, Math.PI * 2)
  minimapCtx.fill()

  // 绘制玩家(蓝色)
  minimapCtx.fillStyle = '#0000ff'
  const playerX = centerX + player.getPosition().x * minimapScale
  const playerY = centerY + player.getPosition().z * minimapScale
  minimapCtx.beginPath()
  minimapCtx.arc(playerX, playerY, 5, 0, Math.PI * 2)
  minimapCtx.fill()

  // 绘制玩家朝向
  minimapCtx.strokeStyle = '#ffffff'
  minimapCtx.lineWidth = 2
  minimapCtx.beginPath()
  minimapCtx.moveTo(playerX, playerY)
  minimapCtx.lineTo(
    playerX + Math.sin(player.getEulerAngles().y * Math.PI / 180) * 8,
    playerY + Math.cos(player.getEulerAngles().y * Math.PI / 180) * 8
  )
  minimapCtx.stroke()
}

/**
 * 碰撞检测(纯数学计算,无物理引擎依赖)
 * @param {pc.Vec3} nextPos 玩家下一步位置
 * @returns {boolean} 是否碰撞
 */
function checkCollision(nextPos) {
  if (!player || obstacles.length === 0) return false
  
  const playerRadius = 0.5 // 玩家碰撞半径
  
  // 1. 检测与障碍物碰撞
  for (let i = 0; i < obstacles.length; i++) {
    const obs = obstacles[i]
    const obsMinX = obs.pos.x - obs.scale.x / 2
    const obsMaxX = obs.pos.x + obs.scale.x / 2
    const obsMinZ = obs.pos.z - obs.scale.z / 2
    const obsMaxZ = obs.pos.z + obs.scale.z / 2
    
    const playerMinX = nextPos.x - playerRadius
    const playerMaxX = nextPos.x + playerRadius
    const playerMinZ = nextPos.z - playerRadius
    const playerMaxZ = nextPos.z + playerRadius
    
    const xOverlap = (playerMaxX > obsMinX) && (playerMinX < obsMaxX)
    const zOverlap = (playerMaxZ > obsMinZ) && (playerMinZ < obsMaxZ)
    
    if (xOverlap && zOverlap) return true
  }
  
  // 2. 检测与地图边界碰撞
  const currentConfig = levelConfigs[currentLevel.value - 1]
  const mapHalfSize = currentConfig.groundSize / 2
  if (Math.abs(nextPos.x) > mapHalfSize - 1 || Math.abs(nextPos.z) > mapHalfSize - 1) {
    return true
  }
  
  return false
}

/**
 * 生成新关卡地图
 */
function generateNewLevel() {
  obstacles = []
  
  // 清空现有场景
  if (app && app.root && app.root.children.length > 0) {
    const children = [...app.root.children]
    children.forEach(child => child.destroy())
  }

  // 获取当前关卡配置
  const config = levelConfigs[currentLevel.value - 1] || levelConfigs[0]
  
  // 设置场景背景色(白色)
  app.scene.background = new pc.Color(...config.sceneBgColor)

  // 1. 创建地面(每关不同颜色)
  const ground = new pc.Entity('ground')
  ground.addComponent('model', { type: 'box', castShadows: true })
  ground.setLocalScale(config.groundSize, 0.1, config.groundSize)
  ground.setLocalPosition(0, 0, 0)
  ground.model.material = createMaterial(...config.groundColor)
  app.root.addChild(ground)

  // 2. 生成障碍物(灰色系)
  for (let i = 0; i < config.obstacleCount; i++) {
    // 随机位置(避开起点/终点)
    let posX, posZ
    let validPos = false
    while (!validPos) {
      posX = getRandomNum(-config.groundSize/2 + 2, config.groundSize/2 - 2)
      posZ = getRandomNum(-config.groundSize/2 + 2, config.groundSize/2 - 2)
      
      const distToStart = Math.hypot(posX - config.startPos.x, posZ - config.startPos.z)
      const distToEnd = Math.hypot(posX - config.endPos.x, posZ - config.endPos.z)
      
      if (distToStart > 3 && distToEnd > 3) validPos = true
    }
    
    // 随机尺寸
    const scaleX = getRandomNum(1, 4)
    const scaleZ = getRandomNum(1, 4)
    
    const obstacle = new pc.Entity(`obstacle-${i}`)
    obstacle.addComponent('model', { type: 'box', castShadows: true })
    obstacle.setLocalPosition(posX, scaleX/2, posZ)
    obstacle.setLocalScale(scaleX, scaleX, scaleZ)
    obstacle.model.material = createMaterial(...config.obstacleColor)
    app.root.addChild(obstacle)
    
    // 存储障碍物信息(用于碰撞+小地图)
    obstacles.push({
      pos: { x: posX, z: posZ },
      scale: { x: scaleX, z: scaleZ }
    })
  }

  // 3. 创建起点(红色球体)
  startPoint = new pc.Entity('start-point')
  startPoint.addComponent('model', { type: 'sphere', castShadows: true })
  startPoint.setLocalPosition(config.startPos)
  startPoint.setLocalScale(0.5, 0.5, 0.5)
  startPoint.model.material = createMaterial(1, 0, 0)
  app.root.addChild(startPoint)

  // 4. 创建终点(绿色球体)
  endPoint = new pc.Entity('end-point')
  endPoint.addComponent('model', { type: 'sphere', castShadows: true })
  endPoint.setLocalPosition(config.endPos)
  endPoint.setLocalScale(0.5, 0.5, 0.5)
  endPoint.model.material = createMaterial(0, 1, 0)
  app.root.addChild(endPoint)

  // 5. 创建玩家(蓝色胶囊体)
  player = new pc.Entity('player')
  player.addComponent('model', { type: 'capsule', castShadows: true })
  player.setLocalPosition(config.startPos)
  player.setLocalScale(0.5, 1, 0.5)
  player.model.material = createMaterial(0, 0, 1)
  app.root.addChild(player)

  // 6. 创建摄像机
  cameraEntity = new pc.Entity('camera')
  cameraEntity.addComponent('camera', {
    clearColor: new pc.Color(...config.sceneBgColor),
    gammaCorrection: pc.GAMMA_SRGB,
    toneMapping: pc.TONEMAP_LINEAR
  })
  updateCameraPosition()
  cameraEntity.lookAt(player.getPosition())
  app.root.addChild(cameraEntity)
  
  // 绘制初始小地图
  drawMinimap()
}

/**
 * 更新摄像机位置(绕玩家旋转)
 */
function updateCameraPosition() {
  if (!cameraEntity || !player) return
  
  const yawRad = cameraYaw * Math.PI / 180
  const pitchRad = cameraPitch * Math.PI / 180

  // 球面坐标转笛卡尔坐标,计算摄像机位置
  const x = Math.sin(yawRad) * Math.cos(pitchRad) * cameraDistance
  const z = Math.cos(yawRad) * Math.cos(pitchRad) * cameraDistance
  const y = Math.sin(pitchRad) * cameraDistance + 8

  const playerPos = player.getPosition()
  cameraEntity.setPosition(playerPos.x + x, playerPos.y + y, playerPos.z + z)
  cameraEntity.lookAt(playerPos)
}

/**
 * 随机数工具函数
 * @param {number} min 最小值
 * @param {number} max 最大值
 * @returns {number} 随机数
 */
function getRandomNum(min, max) {
  return Math.random() * (max - min) + min
}

/**
 * 创建材质
 * @param {number} r 红(0-1)
 * @param {number} g 绿(0-1)
 * @param {number} b 蓝(0-1)
 * @returns {pc.StandardMaterial} 材质实例
 */
function createMaterial(r, g, b) {
  const material = new pc.StandardMaterial()
  material.diffuse.set(r, g, b)
  material.emissive.set(r * 0.2, g * 0.2, b * 0.2) // 轻微自发光,增强视觉
  material.update()
  return material
}

/**
 * 帧更新循环(核心交互逻辑)
 * @param {number} dt 帧间隔时间
 */
function update(dt) {
  if (isWon.value || !app || !player || !endPoint || !cameraEntity) return

  const keyboard = app.keyboard
  
  // 1. WASD控制摄像机旋转
  if (keyboard.isPressed(pc.KEY_A)) cameraYaw += cameraRotateSpeed * dt
  if (keyboard.isPressed(pc.KEY_D)) cameraYaw -= cameraRotateSpeed * dt
  if (keyboard.isPressed(pc.KEY_W)) {
    cameraPitch += cameraRotateSpeed * dt
    cameraPitch = Math.min(cameraPitch, 10) // 限制最大仰角
  }
  if (keyboard.isPressed(pc.KEY_S)) {
    cameraPitch -= cameraRotateSpeed * dt
    cameraPitch = Math.max(cameraPitch, -60) // 限制最大俯角
  }
  updateCameraPosition()

  // 2. 方向键控制角色移动
  const moveStep = moveSpeed * dt
  const currentPos = player.getPosition()
  let newPos = new pc.Vec3(currentPos.x, currentPos.y, currentPos.z)

  if (keyboard.isPressed(pc.KEY_UP)) newPos.z -= moveStep
  if (keyboard.isPressed(pc.KEY_DOWN)) newPos.z += moveStep
  if (keyboard.isPressed(pc.KEY_LEFT)) newPos.x -= moveStep
  if (keyboard.isPressed(pc.KEY_RIGHT)) newPos.x += moveStep

  // 无碰撞则移动
  if (!checkCollision(newPos)) player.setPosition(newPos)

  // 3. 实时更新小地图
  drawMinimap()
  
  // 4. 通关判定(调大阈值,更容易触发)
  const distanceToEnd = Math.hypot(
    player.getPosition().x - endPoint.getPosition().x,
    player.getPosition().z - endPoint.getPosition().z
  )
  if (distanceToEnd < 2.5) {
    isWon.value = true
    console.log('已到达终点,isWon:', isWon.value)
  }
}

// ========== 3. 生命周期 ==========
onMounted(async () => {
  await initPlayCanvas()
})

onUnmounted(() => {
  // 清理PlayCanvas资源
  if (app) {
    app.off('update', update)
    if (app.keyboard) {
      app.keyboard.off(pc.KEY_ENTER, goToNextLevel)
      app.keyboard.off(13, goToNextLevel)
    }
    window.removeEventListener('resize', () => {})
    app.destroy()
    app = null
  }
  
  // 清理全局键盘监听
  if (globalEnterHandler) {
    window.removeEventListener('keydown', globalEnterHandler)
    globalEnterHandler = null
  }
})
</script>

4.4、功能解析

4.4.1、多关卡配置与生成

通过levelConfigs数组定义每关的差异化参数(地面尺寸、起点 / 终点位置、障碍物数量、颜色等),generateNewLevel函数根据当前关卡配置动态生成场景,实现关卡的差异化展示。

4.4.2、角色移动与碰撞检测

  • 角色移动:通过监听方向键输入,计算玩家下一步位置,结合checkCollision函数判断是否碰撞,无碰撞则更新玩家位置;
  • 碰撞检测:纯数学计算实现(无需物理引擎),检测玩家与障碍物、地图边界的重叠,避免穿模问题。

4.4.3、摄像机控制

摄像机采用 "绕玩家旋转" 的第三人称视角,通过 WASD 键控制水平 / 垂直旋转角,结合球面坐标转笛卡尔坐标的公式,实时更新摄像机位置,保证视角始终跟随玩家。

4.4.4、小地图实现

基于 2D Canvas 绘制小地图,核心逻辑:

  • 以小地图中心为原点,将 3D 世界坐标转换为 2D 画布坐标;
  • 分别绘制障碍物、起点、终点、玩家及玩家朝向,实现 3D 场景的 2D 缩略展示。

4.4.5、回车键通关重置

  • 双重键盘监听:同时使用 PlayCanvas 内置监听和全局keydown监听,解决回车键不触发的问题;
  • Canvas 焦点处理:初始化和关卡切换时强制聚焦 Canvas,确保键盘事件能被捕获;
  • 关卡循环:超过配置的关卡数后自动回到第一关,实现无限循环闯关。

五、完整源码

5.1、./App.vue

html 复制代码
<template>
  <div id="app">
    <GameCanvas />
  </div>
</template>

<script setup>
import GameCanvas from './components/GameCanvas.vue'
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body, #app {
  width: 100%;
  height: 100%;
  overflow: hidden;
}
</style>

5.2、./components/GameCanvas.vue

html 复制代码
<template>
  <div class="game-container">
    <h2>3D地图巡视游戏 - 第{{ currentLevel }}关</h2>
    <div id="application-container" class="canvas-container">
      <canvas id="pc-canvas" tabindex="1"></canvas>
      <div class="minimap-container">
        <canvas id="minimap-canvas" width="200" height="200"></canvas>
      </div>
    </div>
    <div class="game-info">
      <p>控制方式:↑↓←→ 方向键移动角色 | WASD 键旋转摄像机</p>
      <p>目标:从<span class="start-point">A点(红色)</span>走到<span class="end-point">B点(绿色)</span></p>
      <p v-if="isWon" class="success">🎉 恭喜过关!按回车键进入下一关</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as pc from 'playcanvas'

// 游戏状态
const currentLevel = ref(1)
const isWon = ref(false)
let app = null
let player = null
let startPoint = null
let endPoint = null
let cameraEntity = null
let moveSpeed = 8          // 角色移动速度
let cameraRotateSpeed = 60 // 摄像机旋转速度(度/秒)
let cameraYaw = 0          // 水平旋转角(绕Y轴)
let cameraPitch = -30      // 垂直旋转角(绕X轴)
let cameraDistance = 15    // 摄像机到玩家的距离

// 全局键盘监听函数(用于兜底)
let globalEnterHandler = null

// 多关卡配置(3+1关,差异化地面颜色)
const levelConfigs = [
  // 第1关
  {
    groundSize: 20,
    startPos: new pc.Vec3(-8, 0.5, -8),
    endPos: new pc.Vec3(8, 0.5, 8),
    obstacleCount: 3,
    groundColor: [0.3, 0.7, 0.3], // 绿色地面
    obstacleColor: [0.5, 0.5, 0.5], // 中灰色障碍物
    sceneBgColor: [1, 1, 1] // 白色背景
  },
  // 第2关
  {
    groundSize: 25,
    startPos: new pc.Vec3(-10, 0.5, 0),
    endPos: new pc.Vec3(10, 0.5, 0),
    obstacleCount: 5,
    groundColor: [0.3, 0.7, 0.7], // 青绿色地面
    obstacleColor: [0.6, 0.6, 0.6], // 浅灰色障碍物
    sceneBgColor: [1, 1, 1] // 白色背景
  },
  // 第3关
  {
    groundSize: 30,
    startPos: new pc.Vec3(0, 0.5, -12),
    endPos: new pc.Vec3(0, 0.5, 12),
    obstacleCount: 7,
    groundColor: [0.7, 0.7, 0.3], // 黄绿色地面
    obstacleColor: [0.4, 0.4, 0.4], // 深灰色障碍物
    sceneBgColor: [1, 1, 1] // 白色背景
  },
  // 第4关
  {
    groundSize: 35,
    startPos: new pc.Vec3(-12, 0.5, 10),
    endPos: new pc.Vec3(12, 0.5, -10),
    obstacleCount: 8,
    groundColor: [0.7, 0.3, 0.7], // 紫色地面
    obstacleColor: [0.55, 0.55, 0.55], // 中灰色障碍物
    sceneBgColor: [1, 1, 1] // 白色背景
  }
]

// 小地图相关
let minimapCanvas = null
let minimapCtx = null
let minimapScale = 5
let obstacles = []         // 存储障碍物信息(用于碰撞+小地图)

// 下一关核心逻辑
const goToNextLevel = () => {
  if (isWon.value) {
    console.log('进入下一关,当前关卡:', currentLevel.value)
    // 循环关卡(超过配置数回到第一关)
    currentLevel.value = currentLevel.value % levelConfigs.length + 1
    isWon.value = false
    generateNewLevel()
    // 重新聚焦Canvas,确保后续操作正常
    document.getElementById('pc-canvas').focus()
  }
}

// 初始化PlayCanvas应用
async function initPlayCanvas() {
  try {
    await nextTick()
    
    const canvas = document.getElementById('pc-canvas')
    if (!canvas) {
      console.error('Canvas元素未找到!')
      return
    }

    // 关键:确保Canvas获取焦点(解决键盘事件不触发)
    canvas.focus()
    canvas.addEventListener('click', () => canvas.focus())

    // 初始化小地图
    initMinimap()

    // 1. 创建图形设备
    const graphicsDevice = new pc.WebglGraphicsDevice(canvas, {
      antialias: true,
      powerPreference: "high-performance"
    })
    graphicsDevice.maxPixelRatio = window.devicePixelRatio

    // 2. 创建应用实例(禁用物理引擎)
    app = new pc.Application(canvas, {
      graphicsDevice: graphicsDevice,
      mouse: new pc.Mouse(canvas),
      keyboard: new pc.Keyboard(window),
      createCanvas: false,
      physics: false
    })

    // 3. 启动应用
    await app.start()

    // 4. 配置场景
    if (!app.scene) {
      app.scene = new pc.Scene(app.graphicsDevice)
    }
    app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW)
    app.setCanvasResolution(pc.RESOLUTION_AUTO)

    // 生成第一关地图
    generateNewLevel()
    
    // 注册更新循环
    app.on('update', update)
    
    // ========== 修复:键盘监听(双重保障) ==========
    // 1. PlayCanvas内置键盘监听(优先)
    app.keyboard.off(pc.KEY_ENTER, goToNextLevel) // 先移除旧监听
    app.keyboard.on(pc.KEY_ENTER, goToNextLevel)
    app.keyboard.off(13, goToNextLevel) // 数字码兜底
    app.keyboard.on(13, goToNextLevel)

    // 2. 全局键盘监听(兜底,防止PlayCanvas监听失效)
    globalEnterHandler = (e) => {
      if (e.key === 'Enter' || e.keyCode === 13) {
        e.preventDefault() // 阻止默认行为(如页面刷新)
        goToNextLevel()
      }
    }
    window.addEventListener('keydown', globalEnterHandler)

    // 窗口大小适配
    window.addEventListener('resize', () => {
      if (app && app.graphicsDevice) {
        app.resizeCanvas(window.innerWidth, window.innerHeight)
        app.graphicsDevice.resize(window.innerWidth, window.innerHeight)
      }
    })

    app.resizeCanvas(window.innerWidth, window.innerHeight)
    
  } catch (error) {
    console.error('PlayCanvas初始化失败:', error)
  }
}

// 初始化小地图
function initMinimap() {
  minimapCanvas = document.getElementById('minimap-canvas')
  minimapCtx = minimapCanvas.getContext('2d')
}

// 绘制小地图
function drawMinimap() {
  if (!minimapCtx || !player || !startPoint || !endPoint) return

  minimapCtx.clearRect(0, 0, minimapCanvas.width, minimapCanvas.height)
  minimapCtx.fillStyle = '#222'
  minimapCtx.fillRect(0, 0, minimapCanvas.width, minimapCanvas.height)
  
  const centerX = minimapCanvas.width / 2
  const centerY = minimapCanvas.height / 2

  // 绘制障碍物
  minimapCtx.fillStyle = '#666'
  obstacles.forEach(obs => {
    const x = centerX + obs.pos.x * minimapScale
    const y = centerY + obs.pos.z * minimapScale
    const w = obs.scale.x * minimapScale
    const h = obs.scale.z * minimapScale
    minimapCtx.fillRect(x - w/2, y - h/2, w, h)
  })

  // 绘制起点
  minimapCtx.fillStyle = '#ff0000'
  const startX = centerX + startPoint.getPosition().x * minimapScale
  const startY = centerY + startPoint.getPosition().z * minimapScale
  minimapCtx.beginPath()
  minimapCtx.arc(startX, startY, 4, 0, Math.PI * 2)
  minimapCtx.fill()

  // 绘制终点
  minimapCtx.fillStyle = '#00ff00'
  const endX = centerX + endPoint.getPosition().x * minimapScale
  const endY = centerY + endPoint.getPosition().z * minimapScale
  minimapCtx.beginPath()
  minimapCtx.arc(endX, endY, 4, 0, Math.PI * 2)
  minimapCtx.fill()

  // 绘制玩家
  minimapCtx.fillStyle = '#0000ff'
  const playerX = centerX + player.getPosition().x * minimapScale
  const playerY = centerY + player.getPosition().z * minimapScale
  minimapCtx.beginPath()
  minimapCtx.arc(playerX, playerY, 5, 0, Math.PI * 2)
  minimapCtx.fill()

  // 绘制玩家朝向
  minimapCtx.strokeStyle = '#ffffff'
  minimapCtx.lineWidth = 2
  minimapCtx.beginPath()
  minimapCtx.moveTo(playerX, playerY)
  minimapCtx.lineTo(
    playerX + Math.sin(player.getEulerAngles().y * Math.PI / 180) * 8,
    playerY + Math.cos(player.getEulerAngles().y * Math.PI / 180) * 8
  )
  minimapCtx.stroke()
}

// 碰撞检测
function checkCollision(nextPos) {
  if (!player || obstacles.length === 0) return false
  
  const playerRadius = 0.5
  
  // 检测障碍物碰撞
  for (let i = 0; i < obstacles.length; i++) {
    const obs = obstacles[i]
    const obsMinX = obs.pos.x - obs.scale.x / 2
    const obsMaxX = obs.pos.x + obs.scale.x / 2
    const obsMinZ = obs.pos.z - obs.scale.z / 2
    const obsMaxZ = obs.pos.z + obs.scale.z / 2
    
    const playerMinX = nextPos.x - playerRadius
    const playerMaxX = nextPos.x + playerRadius
    const playerMinZ = nextPos.z - playerRadius
    const playerMaxZ = nextPos.z + playerRadius
    
    const xOverlap = (playerMaxX > obsMinX) && (playerMinX < obsMaxX)
    const zOverlap = (playerMaxZ > obsMinZ) && (playerMinZ < obsMaxZ)
    
    if (xOverlap && zOverlap) {
      return true
    }
  }
  
  // 检测地图边界
  const currentConfig = levelConfigs[currentLevel.value - 1]
  const mapHalfSize = currentConfig.groundSize / 2
  if (Math.abs(nextPos.x) > mapHalfSize - 1 || Math.abs(nextPos.z) > mapHalfSize - 1) {
    return true
  }
  
  return false
}

// 生成新关卡地图
function generateNewLevel() {
  obstacles = []
  
  // 清空现有场景
  if (app && app.root && app.root.children.length > 0) {
    const children = [...app.root.children]
    children.forEach(child => child.destroy())
  }

  // 获取当前关卡配置
  const config = levelConfigs[currentLevel.value - 1] || levelConfigs[0]
  
  // 设置场景背景色(白色)
  app.scene.background = new pc.Color(...config.sceneBgColor)

  // 1. 创建地面(每关不同颜色)
  const ground = new pc.Entity('ground')
  ground.addComponent('model', {
    type: 'box',
    castShadows: true
  })
  ground.setLocalScale(config.groundSize, 0.1, config.groundSize)
  ground.setLocalPosition(0, 0, 0)
  ground.model.material = createMaterial(...config.groundColor)
  app.root.addChild(ground)

  // 2. 生成障碍物(灰色系)
  for (let i = 0; i < config.obstacleCount; i++) {
    let posX, posZ
    let validPos = false
    while (!validPos) {
      posX = getRandomNum(-config.groundSize/2 + 2, config.groundSize/2 - 2)
      posZ = getRandomNum(-config.groundSize/2 + 2, config.groundSize/2 - 2)
      
      const distToStart = Math.hypot(posX - config.startPos.x, posZ - config.startPos.z)
      const distToEnd = Math.hypot(posX - config.endPos.x, posZ - config.endPos.z)
      
      if (distToStart > 3 && distToEnd > 3) {
        validPos = true
      }
    }
    
    const scaleX = getRandomNum(1, 4)
    const scaleZ = getRandomNum(1, 4)
    
    const obstacle = new pc.Entity(`obstacle-${i}`)
    obstacle.addComponent('model', {
      type: 'box',
      castShadows: true
    })
    obstacle.setLocalPosition(posX, scaleX/2, posZ)
    obstacle.setLocalScale(scaleX, scaleX, scaleZ)
    obstacle.model.material = createMaterial(...config.obstacleColor)
    app.root.addChild(obstacle)
    
    obstacles.push({
      pos: { x: posX, z: posZ },
      scale: { x: scaleX, z: scaleZ }
    })
  }

  // 3. 创建起点(红色)
  startPoint = new pc.Entity('start-point')
  startPoint.addComponent('model', {
    type: 'sphere',
    castShadows: true
  })
  startPoint.setLocalPosition(config.startPos)
  startPoint.setLocalScale(0.5, 0.5, 0.5)
  startPoint.model.material = createMaterial(1, 0, 0)
  app.root.addChild(startPoint)

  // 4. 创建终点(绿色)
  endPoint = new pc.Entity('end-point')
  endPoint.addComponent('model', {
    type: 'sphere',
    castShadows: true
  })
  endPoint.setLocalPosition(config.endPos)
  endPoint.setLocalScale(0.5, 0.5, 0.5)
  endPoint.model.material = createMaterial(0, 1, 0)
  app.root.addChild(endPoint)

  // 5. 创建玩家(蓝色)
  player = new pc.Entity('player')
  player.addComponent('model', {
    type: 'capsule',
    castShadows: true
  })
  player.setLocalPosition(config.startPos)
  player.setLocalScale(0.5, 1, 0.5)
  player.model.material = createMaterial(0, 0, 1)
  app.root.addChild(player)

  // 6. 创建摄像机
  cameraEntity = new pc.Entity('camera')
  cameraEntity.addComponent('camera', {
    clearColor: new pc.Color(...config.sceneBgColor),
    gammaCorrection: pc.GAMMA_SRGB,
    toneMapping: pc.TONEMAP_LINEAR
  })
  updateCameraPosition()
  cameraEntity.lookAt(player.getPosition())
  app.root.addChild(cameraEntity)
  
  // 绘制初始小地图
  drawMinimap()
}

// 更新摄像机位置
function updateCameraPosition() {
  if (!cameraEntity || !player) return
  
  const yawRad = cameraYaw * Math.PI / 180
  const pitchRad = cameraPitch * Math.PI / 180

  const x = Math.sin(yawRad) * Math.cos(pitchRad) * cameraDistance
  const z = Math.cos(yawRad) * Math.cos(pitchRad) * cameraDistance
  const y = Math.sin(pitchRad) * cameraDistance + 8

  const playerPos = player.getPosition()
  cameraEntity.setPosition(playerPos.x + x, playerPos.y + y, playerPos.z + z)
  cameraEntity.lookAt(playerPos)
}

// 随机数工具函数
function getRandomNum(min, max) {
  return Math.random() * (max - min) + min
}

// 创建材质
function createMaterial(r, g, b) {
  const material = new pc.StandardMaterial()
  material.diffuse.set(r, g, b)
  material.emissive.set(r * 0.2, g * 0.2, b * 0.2)
  material.update()
  return material
}

// 更新循环
function update(dt) {
  if (isWon.value || !app || !player || !endPoint || !cameraEntity) return

  const keyboard = app.keyboard
  
  // 1. WASD 控制摄像机旋转
  if (keyboard.isPressed(pc.KEY_A)) {
    cameraYaw += cameraRotateSpeed * dt
  }
  if (keyboard.isPressed(pc.KEY_D)) {
    cameraYaw -= cameraRotateSpeed * dt
  }
  if (keyboard.isPressed(pc.KEY_W)) {
    cameraPitch += cameraRotateSpeed * dt
    cameraPitch = Math.min(cameraPitch, 10)
  }
  if (keyboard.isPressed(pc.KEY_S)) {
    cameraPitch -= cameraRotateSpeed * dt
    cameraPitch = Math.max(cameraPitch, -60)
  }
  updateCameraPosition()

  // 2. 方向键控制角色移动
  const moveStep = moveSpeed * dt
  const currentPos = player.getPosition()
  let newPos = new pc.Vec3(currentPos.x, currentPos.y, currentPos.z)

  if (keyboard.isPressed(pc.KEY_UP)) {
    newPos.z -= moveStep
  }
  if (keyboard.isPressed(pc.KEY_DOWN)) {
    newPos.z += moveStep
  }
  if (keyboard.isPressed(pc.KEY_LEFT)) {
    newPos.x -= moveStep
  }
  if (keyboard.isPressed(pc.KEY_RIGHT)) {
    newPos.x += moveStep
  }

  // 碰撞检测:无碰撞才移动
  if (!checkCollision(newPos)) {
    player.setPosition(newPos)
  }

  // 3. 实时更新小地图
  drawMinimap()
  
  // 4. 检测是否到达终点(调大阈值,确保容易触发)
  const distanceToEnd = Math.hypot(
    player.getPosition().x - endPoint.getPosition().x,
    player.getPosition().z - endPoint.getPosition().z
  )
  // 阈值从1改为2.5,更容易触发通关
  if (distanceToEnd < 2.5) {
    isWon.value = true
    console.log('已到达终点,isWon:', isWon.value)
  }
}

// 生命周期
onMounted(async () => {
  await initPlayCanvas()
})

onUnmounted(() => {
  // 清理所有监听和资源
  if (app) {
    app.off('update', update)
    if (app.keyboard) {
      app.keyboard.off(pc.KEY_ENTER, goToNextLevel)
      app.keyboard.off(13, goToNextLevel)
    }
    window.removeEventListener('resize', () => {})
    app.destroy()
    app = null
  }
  
  // 移除全局键盘监听
  if (globalEnterHandler) {
    window.removeEventListener('keydown', globalEnterHandler)
    globalEnterHandler = null
  }
})
</script>

<style scoped>
.game-container {
  width: 100%;
  height: 100vh;
  position: relative;
  color: #333;
  overflow: hidden;
  font-family: Arial, sans-serif;
}

.canvas-container {
  width: 100%;
  height: 100%;
  position: relative;
}

#pc-canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: block;
  outline: none; /* 去掉聚焦后的边框 */
}

/* 小地图样式 */
.minimap-container {
  position: absolute;
  top: 20px;
  right: 20px;
  width: 200px;
  height: 200px;
  border: 2px solid #666;
  border-radius: 8px;
  z-index: 100;
  background: rgba(0, 0, 0, 0.5);
}

#minimap-canvas {
  width: 100%;
  height: 100%;
  display: block;
}

.game-info {
  position: absolute;
  top: 20px;
  left: 20px;
  background: rgba(255, 255, 255, 0.95);
  padding: 15px 20px;
  border-radius: 8px;
  z-index: 100;
  font-size: 14px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  border: 1px solid #eee;
}

.start-point {
  color: #e53935;
  font-weight: bold;
}

.end-point {
  color: #43a047;
  font-weight: bold;
}

.success {
  color: #f57c00;
  font-weight: bold;
  font-size: 16px;
  margin: 5px 0 0 0;
}

h2 {
  position: absolute;
  top: 10px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 100;
  background: rgba(255,255,255,0.9);
  padding: 8px 20px;
  border-radius: 20px;
  font-size: 18px;
  margin: 0;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>

5.3、./index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>3D地图巡视游戏</title>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"/>
  <script type="module" src="/src/main.js"></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>
相关推荐
新启航光学频率梳2 小时前
齿轮箱传动轴孔孔深光学3D轮廓测量-激光频率梳3D轮廓技术
科技·3d·制造
青稞社区.2 小时前
MIT&Harvard 最新提出 PAGE-4D:让 3D 模型“看懂“动态世界的统一框架
人工智能·3d
一只不会编程的猫4 小时前
Echart 3D环形图
前端·javascript·3d
于先生吖4 小时前
从源码到上线:Java 游戏陪玩系统的性能优化与安全加固
安全·游戏
云飞云共享云桌面4 小时前
广东某智能装备工厂8人共享一台服务器
大数据·运维·服务器·人工智能·3d·自动化·电脑
qq_283720054 小时前
WebGL 基础教程(十):从 0 到 1 吃透 MVP 矩阵,3D 旋转立方体手到擒来
3d·矩阵·webgl
驱动开发0074 小时前
[原创]硬件级别虚拟HID鼠标键盘,过游戏检测
游戏·计算机外设
桌面运维家4 小时前
Windows游戏鼠标DPI调校指南:精准定位与优化
windows·游戏·计算机外设
大江东去浪淘尽千古风流人物4 小时前
【claw】 OpenClaw 的架构设计探索
深度学习·算法·3d·机器人·slam