文章目录
- 一、效果
- 二、简介
- 三、环境
- 四、步骤
-
- 4.1、项目部署
- 4.2、游戏功能规划
- [4.3、脚本逻辑(Script Setup)](#4.3、脚本逻辑(Script Setup))
- 4.4、功能解析
- 五、完整源码
一、效果

二、简介
在上一篇博客《【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 地图闯关游戏需实现以下核心功能:
- 基础交互:方向键(↑↓←→)控制角色移动,WASD 键控制摄像机旋转;
- 视觉定制:自定义场景背景、地面、障碍物颜色(障碍物灰色、背景白色、每关地面差异化);
- 关卡系统:至少 3 张差异化地图,到达终点后按回车键进入下一关;
- 辅助功能:小地图实时显示角色、起点、终点、障碍物位置;
- 碰撞检测:角色与障碍物、地图边界碰撞,防止穿模;
- 通关判定:角色到达终点触发通关提示,支持回车重置关卡。
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>