文章目录
- 一、效果
- 二、简介
- 三、知识点
-
- 3.1、核心交互需求分析
- 3.2、基础准备:变量定义
- 3.3、工具函数封装
-
- 3.3.1、角度转弧度(原生实现)
- 3.3.2、相机位置更新函数
- 3.3.3、视角重置函数(双击触发)
- 3.3.1、角度转弧度(原生实现)
- 3.4、鼠标交互核心逻辑实现
-
- 3.4.1、初始化鼠标事件监听
- 3.4.2、关键逻辑说明
- 3.4.1、初始化鼠标事件监听
- 3.5、集成到初始化流程
- 3.6、生命周期清理
- 四、完整源码
一、效果

二、简介
在《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(四)3D 障碍物躲避游戏 2 - 模型加载》中,我们完成了各类 3D 模型(车辆、人物、路障等)的加载与 WebSocket 数据驱动的障碍物渲染。但一个完整的 3D 交互游戏,核心体验之一是玩家对视角的掌控 —— 通过鼠标实现视角旋转、场景平移、滚轮缩放,以及双击快速恢复默认视角。本文将聚焦鼠标交互控制的实现,基于 Vue3+PlayCanvas 构建流畅的 3D 视角操控体系,让玩家能自由探索游戏场景。
三、知识点
3.1、核心交互需求分析
在 3D 障碍物躲避游戏中,鼠标交互需要满足以下核心场景:
- 左键拖动:围绕玩家角色旋转视角(水平 + 垂直),且限制垂直旋转范围防止视角翻转;
- 右键拖动:平移整个场景(本质是移动玩家角色,同步更新障碍物相对位置);
- 滚轮滚动:拉近 / 拉远视角(控制相机与角色的距离),限制缩放范围避免视角过近 / 过远;
- 左键双击:快速恢复默认视角(重置相机位置、角度,可选重置角色位置);
- 交互体验优化:鼠标样式切换(正常 / 拖拽中)、事件防穿透、窗口自适应等。
3.2、基础准备:变量定义
首先在 Vue3 的JS中定义鼠标交互所需的核心变量,包括鼠标状态、相机参数、双击检测等:
1// 相机控制相关变量 2let isMouseLeftDown = false; // 鼠标左键是否按下 3let isMouseRightDown = false; // 鼠标右键是否按下 4let lastMouseX = 0; // 上一帧鼠标X坐标 5let lastMouseY = 0; // 上一帧鼠标Y坐标 6let cameraDistance = 8; // 相机距离角色的初始距离 7let cameraYaw = 0; // 相机水平旋转角度(绕Y轴) 8let cameraPitch = 20; // 相机垂直旋转角度(绕X轴) 9// 视角默认值(用于双击恢复) 10const DEFAULT_CAMERA_DISTANCE = 8; 11const DEFAULT_CAMERA_YAW = 0; 12const DEFAULT_CAMERA_PITCH = 20; 13// 双击检测变量 14let clickTimer = null; 15let clickCount = 0; 16
3.3、工具函数封装
3.3.1、角度转弧度(原生实现)
PlayCanvas 的角度计算依赖弧度,封装原生 JS 函数避免依赖引擎内置方法,提高代码兼容性:
1/** 2 * 角度转弧度(原生JS实现,不依赖PlayCanvas内置方法) 3 * @param {number} degrees 角度值 4 * @returns {number} 弧度值 5 */ 6const degToRad = (degrees) => { 7 return degrees * Math.PI / 180; 8}; 9
3.3.2、相机位置更新函数
所有鼠标交互最终都会修改相机参数,需封装统一的相机位置计算逻辑,基于球面坐标更新相机位置并朝向玩家角色:
1/** 2 * 更新相机位置 3 */ 4const updateCameraPosition = () => { 5 if (!camera || !player) return; 6 7 const playerPos = player.getPosition(); 8 9 // 转换为弧度 10 const yawRad = degToRad(cameraYaw); 11 const pitchRad = degToRad(cameraPitch); 12 13 // 计算相机的球面坐标(基于角色位置偏移) 14 const cameraX = playerPos.x + cameraDistance * Math.sin(yawRad) * Math.cos(pitchRad); 15 const cameraZ = playerPos.z + cameraDistance * Math.cos(yawRad) * Math.cos(pitchRad); 16 const cameraY = playerPos.y + 2 + cameraDistance * Math.sin(pitchRad); // 基础高度 + 垂直偏移 17 18 // 设置相机位置和朝向(始终看向角色) 19 camera.setPosition(cameraX, cameraY, cameraZ); 20 camera.lookAt(playerPos.x, playerPos.y + 1, playerPos.z); 21}; 22
3.3.3、视角重置函数(双击触发)
封装视角恢复逻辑,重置相机参数并可选重置角色位置,让玩家快速回到初始视角:
1/** 2 * 恢复视角到默认值 3 */ 4const resetCameraView = () => { 5 cameraDistance = DEFAULT_CAMERA_DISTANCE; 6 cameraYaw = DEFAULT_CAMERA_YAW; 7 cameraPitch = DEFAULT_CAMERA_PITCH; 8 // 可选:重置角色位置到初始点 9 if (player) { 10 player.setPosition(0, 1.0, 0); 11 playerX.value = 0; 12 } 13 // 立即更新相机位置 14 updateCameraPosition(); 15 console.log('视角已恢复默认值'); 16}; 17
3.4、鼠标交互核心逻辑实现
3.4.1、初始化鼠标事件监听
在initApp函数中调用initMouseControls初始化所有鼠标交互,绑定 canvas 的鼠标按下、松开、移动、滚轮等事件:
1const initMouseControls = () => { 2 const canvas = canvasContainer.value; 3 4 // 鼠标按下事件(包含双击检测) 5 canvas.addEventListener('mousedown', (e) => { 6 e.preventDefault(); // 阻止默认行为(如文本选中、滚动) 7 8 // 双击检测逻辑(仅左键) 9 if (e.button === 0) { 10 clickCount++; 11 if (clickCount === 1) { 12 clickTimer = setTimeout(() => { 13 clickCount = 0; // 300ms超时重置 14 }, 300); 15 } else if (clickCount === 2) { 16 clearTimeout(clickTimer); 17 clickCount = 0; 18 resetCameraView(); // 双击触发视角重置 19 return; // 阻止双击时触发左键旋转逻辑 20 } 21 } 22 23 // 普通左键/右键按下逻辑 24 if (e.button === 0 && clickCount === 1) { // 仅单次左键点击触发旋转 25 isMouseLeftDown = true; 26 lastMouseX = e.clientX; 27 lastMouseY = e.clientY; 28 canvas.style.cursor = 'grabbing'; // 切换鼠标样式 29 } else if (e.button === 2) { // 右键按下触发平移 30 isMouseRightDown = true; 31 lastMouseX = e.clientX; 32 lastMouseY = e.clientY; 33 canvas.style.cursor = 'grabbing'; 34 } 35 }); 36 37 // 鼠标松开事件(全局监听,防止鼠标移出canvas后无法松开) 38 window.addEventListener('mouseup', (e) => { 39 if (e.button === 0 || e.button === 2) { 40 isMouseLeftDown = false; 41 isMouseRightDown = false; 42 canvas.style.cursor = 'grab'; // 恢复鼠标样式 43 } 44 }); 45 46 // 鼠标移出canvas事件(清理状态) 47 canvas.addEventListener('mouseout', () => { 48 isMouseLeftDown = false; 49 isMouseRightDown = false; 50 canvas.style.cursor = 'grab'; 51 // 清理双击定时器 52 clearTimeout(clickTimer); 53 clickCount = 0; 54 }); 55 56 // 鼠标移动事件(核心:旋转/平移逻辑) 57 window.addEventListener('mousemove', (e) => { 58 if (!camera || !player) return; 59 60 const deltaX = e.clientX - lastMouseX; 61 const deltaY = e.clientY - lastMouseY; 62 63 if (isMouseLeftDown) { 64 // 左键旋转:水平旋转(Yaw)和垂直旋转(Pitch) 65 cameraYaw -= deltaX * 0.5; // 灵敏度系数 66 // 限制垂直旋转范围(0° ~ 80°,防止视角翻转) 67 cameraPitch = Math.max(0, Math.min(80, cameraPitch + deltaY * 0.5)); 68 updateCameraPosition(); // 实时更新相机位置 69 } else if (isMouseRightDown) { 70 // 右键平移:基于相机角度计算平移方向(更自然的体验) 71 const moveSpeed = 0.1; 72 let playerPos = player.getPosition(); 73 const yawRad = degToRad(cameraYaw); 74 // 结合相机旋转角度,计算X/Z轴平移量 75 playerPos.x -= deltaX * moveSpeed * Math.cos(yawRad) + deltaY * moveSpeed * Math.sin(yawRad); 76 playerPos.z += deltaY * moveSpeed * Math.cos(yawRad) - deltaX * moveSpeed * Math.sin(yawRad); 77 // 限制角色平移范围(防止移出地面) 78 playerPos.x = Math.max(-50, Math.min(50, playerPos.x)); 79 playerPos.z = Math.max(-100, Math.min(100, playerPos.z)); 80 player.setPosition(playerPos); 81 playerX.value = playerPos.x; // 更新角色位置显示 82 updateCameraPosition(); // 同步更新相机位置 83 } 84 85 // 更新上一帧鼠标坐标 86 lastMouseX = e.clientX; 87 lastMouseY = e.clientY; 88 }); 89 90 // 鼠标滚轮缩放(控制相机距离) 91 canvas.addEventListener('wheel', (e) => { 92 e.preventDefault(); // 阻止页面滚动 93 // 调整相机距离,限制范围 2~30 单位 94 cameraDistance = Math.max(2, Math.min(30, cameraDistance - e.deltaY * 0.01)); 95 updateCameraPosition(); 96 }); 97 98 // 阻止右键菜单弹出 99 canvas.addEventListener('contextmenu', (e) => e.preventDefault()); 100 101 // 初始化鼠标样式 102 canvas.style.cursor = 'grab'; 103}; 104
3.4.2、关键逻辑说明
- 双击检测:通过clickCount计数 +setTimeout超时(300ms)实现,双击时直接触发resetCameraView并阻止后续左键旋转逻辑;
- 左键旋转:通过修改cameraYaw(水平)和cameraPitch(垂直)实现视角旋转,且用Math.max/min限制垂直角度范围(0°~80°),避免视角翻转;
- 右键平移:不是直接移动相机,而是移动玩家角色,并基于相机当前旋转角度计算平移方向,让平移体验更符合玩家视角;
- 滚轮缩放:修改cameraDistance控制相机与角色的距离,限制 2~30 单位避免视角过近 / 过远;
- 样式与状态管理:鼠标按下 / 松开 / 移出时切换cursor样式,提升交互反馈;全局监听mouseup避免鼠标移出 canvas 后状态异常。
3.5、集成到初始化流程
将鼠标控制初始化函数initMouseControls添加到initApp的初始化流程中,确保在相机、角色创建后执行:
1const initApp = async () => { 2 await nextTick(); 3 if (!canvasContainer.value) return; 4 5 // 初始化 PlayCanvas 应用(省略原有代码) 6 // ... 7 8 // 加载所有模型(省略原有代码) 9 await Promise.all([ 10 loadDogModel(), 11 loadCarModels(), 12 loadTruckModel(), 13 loadPersonModel(), 14 loadBarrierModel() 15 ]); 16 17 createCamera(); 18 createLight(); 19 createGround(); 20 createPlayer(); 21 listenKeyboard(); 22 initWebSocket(); 23 initMouseControls(); // 初始化鼠标控制(新增) 24 25 // 窗口自适应(省略原有代码) 26 // ... 27}; 28
3.6、生命周期清理
在 Vue 组件卸载时,清理鼠标事件监听和双击定时器,避免内存泄漏:
1onUnmounted(() => { 2 // 清理双击定时器 3 clearTimeout(clickTimer); 4 5 // 关闭 WebSocket 连接(原有代码) 6 if (ws) { 7 ws.close(); 8 ws = null; 9 } 10 11 // 销毁 PlayCanvas 应用(原有代码) 12 if (app) { 13 app.stop(); 14 app.destroy(); 15 app = null; 16 } 17 18 // 清理障碍物实体(原有代码) 19 // ... 20 21 // 清理鼠标事件监听 22 const canvas = canvasContainer.value; 23 if (canvas) { 24 canvas.removeEventListener('mousedown', () => {}); 25 canvas.removeEventListener('wheel', () => {}); 26 canvas.removeEventListener('contextmenu', () => {}); 27 canvas.removeEventListener('mouseout', () => {}); 28 } 29 window.removeEventListener('mouseup', () => {}); 30 window.removeEventListener('mousemove', () => {}); 31}); 32
四、完整源码
1<template> 2 <div class="game-container"> 3 <canvas id="app-container" ref="canvasContainer"></canvas> 4 <!-- 游戏信息面板 --> 5 <div class="game-info"> 6 实时显示周围障碍物 7 <br>当前角色位置:X: {{ playerX.toFixed(1) }} | 已加载障碍物:{{ obstacleCount }} 8 <br>操作说明:左键旋转 | 右键平移 | 滚轮缩放 | 左键双击恢复视角 9 </div> 10 </div> 11</template> 12 13<script setup> 14import { ref, onMounted, onUnmounted, nextTick } from 'vue'; 15import * as pc from 'playcanvas'; 16 17// 容器 18const canvasContainer = ref(null); 19 20// 引擎实例 21let app = null; 22let camera = null; 23let player = null; 24 25// 模型模板 26let carTemplateBlue = null; // 蓝色轿车模板 27let carTemplateBlack = null; // 黑色轿车模板 28let truckTemplate = null; // 灰色货车模板 29let personTemplate = null; // 黄色人物模板 30let dogTemplate = null; // 狗模型模板 31let barrierTemplate = null; // 路障模型模板 32 33// 状态管理 34const playerX = ref(0); // 角色X轴位置 35const obstacleCount = ref(0); // 已加载障碍物数量 36let obstacleEntities = new Map(); // 存储障碍物实体 key: id, value: entity 37let obstacleRawData = new Map(); // 存储障碍物原始数据(替代localStorage) 38let ws = null; // WebSocket 实例 39 40// 相机控制相关变量 41let isMouseLeftDown = false; // 鼠标左键是否按下 42let isMouseRightDown = false; // 鼠标右键是否按下 43let lastMouseX = 0; // 上一帧鼠标X坐标 44let lastMouseY = 0; // 上一帧鼠标Y坐标 45let cameraDistance = 8; // 相机距离角色的初始距离 46let cameraYaw = 0; // 相机水平旋转角度(绕Y轴) 47let cameraPitch = 20; // 相机垂直旋转角度(绕X轴) 48// 视角默认值(新增) 49const DEFAULT_CAMERA_DISTANCE = 8; 50const DEFAULT_CAMERA_YAW = 0; 51const DEFAULT_CAMERA_PITCH = 20; 52// 双击检测变量(新增) 53let clickTimer = null; 54let clickCount = 0; 55 56// ====================== 工具函数 ====================== 57/** 58 * 角度转弧度(原生JS实现,不依赖PlayCanvas内置方法) 59 * @param {number} degrees 角度值 60 * @returns {number} 弧度值 61 */ 62const degToRad = (degrees) => { 63 return degrees * Math.PI / 180; 64}; 65 66/** 67 * 恢复视角到默认值(新增核心函数) 68 */ 69const resetCameraView = () => { 70 cameraDistance = DEFAULT_CAMERA_DISTANCE; 71 cameraYaw = DEFAULT_CAMERA_YAW; 72 cameraPitch = DEFAULT_CAMERA_PITCH; 73 // 重置角色位置到初始点(可选,根据需求决定是否开启) 74 if (player) { 75 player.setPosition(0, 1.0, 0); 76 playerX.value = 0; 77 } 78 // 立即更新相机位置 79 updateCameraPosition(); 80 console.log('视角已恢复默认值'); 81}; 82 83// ====================== 初始化 ====================== 84const initApp = async () => { 85 await nextTick(); 86 if (!canvasContainer.value) return; 87 88 // 初始化 PlayCanvas 应用 89 app = new pc.Application(canvasContainer.value, { 90 elementInput: new pc.ElementInput(canvasContainer.value), 91 mouse: new pc.Mouse(canvasContainer.value), 92 keyboard: new pc.Keyboard(window), 93 touch: new pc.TouchDevice(canvasContainer.value), 94 graphicsDeviceOptions: { webgl2: true, antialias: true, powerPreference: 'high-performance' }, 95 createCanvas: false 96 }); 97 98 app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); 99 app.setCanvasResolution(pc.RESOLUTION_AUTO); 100 app.start(); 101 app.scene.background = new pc.Color(0.8, 0.8, 0.9); 102 103 // 加载所有模型 104 await Promise.all([ 105 loadDogModel(), 106 loadCarModels(), 107 loadTruckModel(), 108 loadPersonModel(), 109 loadBarrierModel() 110 ]); 111 112 createCamera(); 113 createLight(); 114 createGround(); // 简化的地面 115 createPlayer(); // 创建玩家角色 116 listenKeyboard(); // 监听键盘控制 117 initWebSocket(); // 初始化 WebSocket 118 initMouseControls(); // 初始化鼠标控制 119 120 // 窗口自适应 121 window.addEventListener('resize', () => { 122 app?.resizeCanvas(window.innerWidth, window.innerHeight); 123 }); 124}; 125 126// ====================== 鼠标控制相关 ====================== 127const initMouseControls = () => { 128 const canvas = canvasContainer.value; 129 130 // 鼠标按下事件(包含双击检测) 131 canvas.addEventListener('mousedown', (e) => { 132 e.preventDefault(); 133 134 // 双击检测逻辑(新增核心) 135 if (e.button === 0) { // 仅检测左键双击 136 clickCount++; 137 if (clickCount === 1) { 138 clickTimer = setTimeout(() => { 139 clickCount = 0; // 超时重置点击计数 140 }, 300); // 300ms内的两次点击视为双击 141 } else if (clickCount === 2) { 142 clearTimeout(clickTimer); 143 clickCount = 0; 144 resetCameraView(); // 双击触发恢复视角 145 return; // 阻止双击时触发左键按下的旋转逻辑 146 } 147 } 148 149 // 普通左键/右键按下逻辑 150 if (e.button === 0 && clickCount === 1) { // 仅单次点击时触发旋转 151 isMouseLeftDown = true; 152 lastMouseX = e.clientX; 153 lastMouseY = e.clientY; 154 canvas.style.cursor = 'grabbing'; 155 } else if (e.button === 2) { // 右键 156 isMouseRightDown = true; 157 lastMouseX = e.clientX; 158 lastMouseY = e.clientY; 159 canvas.style.cursor = 'grabbing'; 160 } 161 }); 162 163 // 鼠标松开事件 164 window.addEventListener('mouseup', (e) => { 165 if (e.button === 0 || e.button === 2) { 166 isMouseLeftDown = false; 167 isMouseRightDown = false; 168 canvas.style.cursor = 'grab'; 169 } 170 }); 171 172 // 鼠标移出事件 173 canvas.addEventListener('mouseout', () => { 174 isMouseLeftDown = false; 175 isMouseRightDown = false; 176 canvas.style.cursor = 'grab'; 177 // 移出时重置双击检测(新增) 178 clearTimeout(clickTimer); 179 clickCount = 0; 180 }); 181 182 // 鼠标移动事件 183 window.addEventListener('mousemove', (e) => { 184 if (!camera || !player) return; 185 186 const deltaX = e.clientX - lastMouseX; 187 const deltaY = e.clientY - lastMouseY; 188 189 if (isMouseLeftDown) { 190 // 左键旋转:水平旋转(Yaw)和垂直旋转(Pitch) 191 cameraYaw -= deltaX * 0.5; 192 // 限制垂直旋转范围(0° 到 80°,防止视角翻转) 193 cameraPitch = Math.max(0, Math.min(80, cameraPitch + deltaY * 0.5)); 194 updateCameraPosition(); 195 } else if (isMouseRightDown) { 196 // 右键平移:调整角色位置(模拟场景平移) 197 const moveSpeed = 0.1; 198 let playerPos = player.getPosition(); 199 // 基于相机旋转角度计算平移方向(更自然的平移体验) 200 const yawRad = degToRad(cameraYaw); 201 playerPos.x -= deltaX * moveSpeed * Math.cos(yawRad) + deltaY * moveSpeed * Math.sin(yawRad); 202 playerPos.z += deltaY * moveSpeed * Math.cos(yawRad) - deltaX * moveSpeed * Math.sin(yawRad); 203 // 限制平移范围 204 playerPos.x = Math.max(-50, Math.min(50, playerPos.x)); 205 playerPos.z = Math.max(-100, Math.min(100, playerPos.z)); 206 player.setPosition(playerPos); 207 playerX.value = playerPos.x; 208 updateCameraPosition(); 209 } 210 211 lastMouseX = e.clientX; 212 lastMouseY = e.clientY; 213 }); 214 215 // 鼠标滚轮缩放 216 canvas.addEventListener('wheel', (e) => { 217 e.preventDefault(); 218 // 调整相机距离(缩放),限制范围 2~30 单位 219 cameraDistance = Math.max(2, Math.min(30, cameraDistance - e.deltaY * 0.01)); 220 updateCameraPosition(); 221 }); 222 223 // 阻止右键菜单 224 canvas.addEventListener('contextmenu', (e) => e.preventDefault()); 225 226 // 初始化鼠标样式 227 canvas.style.cursor = 'grab'; 228}; 229 230/** 231 * 更新相机位置 232 */ 233const updateCameraPosition = () => { 234 if (!camera || !player) return; 235 236 const playerPos = player.getPosition(); 237 238 // 使用自定义的degToRad函数,不依赖PlayCanvas内置方法 239 const yawRad = degToRad(cameraYaw); 240 const pitchRad = degToRad(cameraPitch); 241 242 // 计算相机的球面坐标 243 const cameraX = playerPos.x + cameraDistance * Math.sin(yawRad) * Math.cos(pitchRad); 244 const cameraZ = playerPos.z + cameraDistance * Math.cos(yawRad) * Math.cos(pitchRad); 245 const cameraY = playerPos.y + 2 + cameraDistance * Math.sin(pitchRad); // 基础高度 + 垂直偏移 246 247 // 设置相机位置和朝向 248 camera.setPosition(cameraX, cameraY, cameraZ); 249 camera.lookAt(playerPos.x, playerPos.y + 1, playerPos.z); 250}; 251 252// ====================== WebSocket 相关 ====================== 253const initWebSocket = () => { 254 // 替换为你的 WebSocket 服务地址 255 const wsUrl = 'ws://localhost:8080/obstacle'; 256 ws = new WebSocket(wsUrl); 257 258 ws.onopen = () => { 259 console.log('WebSocket 连接成功'); 260 }; 261 262 ws.onmessage = (event) => { 263 try { 264 const data = JSON.parse(event.data); 265 console.log('解析障碍物数据成功:', data); 266 handleObstacleData(data); 267 } catch (error) { 268 console.error('解析障碍物数据失败:', error); 269 } 270 }; 271 272 ws.onerror = (error) => { 273 console.error('WebSocket 错误:', error); 274 }; 275 276 ws.onclose = () => { 277 console.log('WebSocket 连接关闭,5秒后重连'); 278 setTimeout(initWebSocket, 5000); 279 }; 280}; 281 282/** 283 * 处理WebSocket接收的障碍物数据 284 */ 285const handleObstacleData = (obstacleData) => { 286 if (!obstacleData.id || !obstacleData.type) return; 287 288 // 存储原始数据(替代localStorage) 289 obstacleRawData.set(obstacleData.id, obstacleData); 290 291 // 计算相对角色的位置 (角色为参考点) 292 const playerPos = player.getPosition(); 293 const relativeX = obstacleData.position.x - playerPos.x; 294 const relativeZ = obstacleData.position.z - playerPos.z; 295 296 // 只显示角色周围一定范围内的障碍物 297 const maxDistance = 50; // 最大显示距离 298 const isInRange = Math.hypot(relativeX, relativeZ) <= maxDistance; 299 300 if (obstacleData.isActive && isInRange) { 301 updateObstacle(obstacleData, relativeX, relativeZ); 302 } else { 303 removeObstacle(obstacleData.id); 304 } 305}; 306 307/** 308 * 更新或创建障碍物实体 309 */ 310const updateObstacle = (data, relativeX, relativeZ) => { 311 let obstacleEntity = obstacleEntities.get(data.id); 312 313 // 不存在则创建新实体 314 if (!obstacleEntity) { 315 obstacleEntity = new pc.Entity([`obstacle-${data.id}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.id.md)); 316 obstacleEntity.collisionType = data.type; 317 318 // 根据类型创建模型 319 switch (data.type) { 320 case 'car': 321 Math.random() > 0.5 ? makeCar(obstacleEntity) : makeCarBlack(obstacleEntity); 322 break; 323 case 'truck': makeTruck(obstacleEntity); break; 324 case 'person': makePerson(obstacleEntity); break; 325 case 'barrier': makeBarrier(obstacleEntity); break; 326 default: return; 327 } 328 329 app.root.addChild(obstacleEntity); 330 obstacleEntities.set(data.id, obstacleEntity); 331 obstacleCount.value = obstacleEntities.size; 332 } 333 334 // 设置位置、缩放和旋转 335 obstacleEntity.setPosition(relativeX, 0, relativeZ); 336 // 应用自定义大小 337 if (data.size) { 338 obstacleEntity.setLocalScale(data.size.x, data.size.y, data.size.z); 339 } 340 // 应用航向 341 if (data.heading) { 342 obstacleEntity.setLocalEulerAngles(0, data.heading, 0); 343 } 344 obstacleEntity.visible = true; 345}; 346 347/** 348 * 移除障碍物实体 349 */ 350const removeObstacle = (id) => { 351 const obstacleEntity = obstacleEntities.get(id); 352 if (obstacleEntity) { 353 app.root.removeChild(obstacleEntity); 354 obstacleEntity.destroy(); 355 obstacleEntities.delete(id); 356 obstacleRawData.delete(id); // 同步删除原始数据 357 obstacleCount.value = obstacleEntities.size; 358 } 359}; 360 361// ====================== 模型加载相关 ====================== 362// 加载角色模型 (玩家角色) 363const loadDogModel = async () => { 364 return new Promise((resolve, reject) => { 365 const modelUrl = new URL('/download/car/car.glb', import.meta.url).href; 366 const asset = new pc.Asset('dog', 'model', { url: modelUrl }, { preload: true }); 367 368 app.assets.add(asset); 369 asset.on('load', () => { 370 const dogEntity = new pc.Entity('dog-template'); 371 dogEntity.addComponent('model', { 372 type: 'asset', asset: asset, castShadows: true, receiveShadows: true 373 }); 374 dogEntity.setLocalScale(1.0, 1.0, 1.0); 375 dogEntity.setLocalEulerAngles(-90, -180, 0); 376 377 // 材质设置 378 setTimeout(() => { 379 const updateMaterials = (entity) => { 380 if (entity.model?.meshInstances) { 381 entity.model.meshInstances.forEach(mi => { 382 if (mi.material) { 383 mi.material.useLighting = true; 384 mi.material.roughness = 0.5; 385 mi.material.metalness = 0.1; 386 mi.material.update(); 387 } 388 }); 389 } 390 entity.children.forEach(updateMaterials); 391 }; 392 updateMaterials(dogEntity); 393 }, 200); 394 395 dogTemplate = dogEntity; 396 resolve(); 397 }); 398 399 asset.on('error', (err) => { 400 console.error('加载狗模型失败:', err); 401 reject(err); 402 }); 403 app.assets.load(asset); 404 }); 405}; 406 407// 加载轿车模型 408const loadCarModels = async () => { 409 return new Promise((resolve, reject) => { 410 const modelUrl = new URL('/download/car/car2.glb', import.meta.url).href; 411 const asset = new pc.Asset('car', 'model', { url: modelUrl }, { preload: true }); 412 413 app.assets.add(asset); 414 asset.on('load', () => { 415 // 蓝色轿车 416 const createCar = (colorName, diffuseColor) => { 417 const carEntity = new pc.Entity(`car-${colorName}`); 418 carEntity.addComponent('model', { 419 type: 'asset', asset: asset, castShadows: true, receiveShadows: true 420 }); 421 carEntity.setLocalScale(0.6, 0.6, 0.6); 422 423 setTimeout(() => { 424 const updateMaterial = (entity) => { 425 if (entity.model?.meshInstances) { 426 entity.model.meshInstances.forEach(mi => { 427 const mat = mi.material.clone(); 428 mat.useLighting = true; 429 mat.diffuse = diffuseColor; 430 mat.specular = new pc.Color(0.2, 0.2, 0.2); 431 mat.roughness = 0.3; 432 mat.metalness = 0.7; 433 mat.update(); 434 mi.material = mat; 435 }); 436 } 437 entity.children.forEach(updateMaterial); 438 }; 439 updateMaterial(carEntity); 440 }, 200); 441 return carEntity; 442 }; 443 444 carTemplateBlue = createCar('blue', new pc.Color(0.1, 0.3, 0.8)); 445 carTemplateBlack = createCar('black', new pc.Color(0.1, 0.1, 0.1)); 446 resolve(); 447 }); 448 449 asset.on('error', (err) => { 450 console.error('加载轿车模型失败:', err); 451 reject(err); 452 }); 453 app.assets.load(asset); 454 }); 455}; 456 457// 加载货车模型 458const loadTruckModel = async () => { 459 return new Promise((resolve, reject) => { 460 const modelUrl = new URL('/download/truck/truck2.glb', import.meta.url).href; 461 const asset = new pc.Asset('truck', 'model', { url: modelUrl }, { preload: true }); 462 463 app.assets.add(asset); 464 asset.on('load', () => { 465 const truckEntity = new pc.Entity('truck-template'); 466 truckEntity.addComponent('model', { 467 type: 'asset', asset: asset, castShadows: true, receiveShadows: true 468 }); 469 truckEntity.setLocalEulerAngles(-90, 0, 0); 470 truckEntity.setLocalScale(1.4, 1.4, 1.4); 471 472 setTimeout(() => { 473 const updateMaterial = (entity) => { 474 if (entity.model?.meshInstances) { 475 entity.model.meshInstances.forEach(mi => { 476 mi.material.useLighting = true; 477 mi.material.diffuse = new pc.Color(0.3, 0.3, 0.3); 478 mi.material.update(); 479 }); 480 } 481 entity.children.forEach(updateMaterial); 482 }; 483 updateMaterial(truckEntity); 484 }, 200); 485 486 truckTemplate = truckEntity; 487 resolve(); 488 }); 489 490 asset.on('error', (err) => { 491 console.error('加载货车模型失败:', err); 492 reject(err); 493 }); 494 app.assets.load(asset); 495 }); 496}; 497 498// 加载人物模型 499const loadPersonModel = async () => { 500 return new Promise((resolve, reject) => { 501 const modelUrl = new URL('/download/person/person.glb', import.meta.url).href; 502 const asset = new pc.Asset('person', 'model', { url: modelUrl }, { preload: true }); 503 504 app.assets.add(asset); 505 asset.on('load', () => { 506 const personEntity = new pc.Entity('person-template'); 507 personEntity.addComponent('model', { 508 type: 'asset', asset: asset, castShadows: true, receiveShadows: true 509 }); 510 personEntity.setLocalScale(1.1, 1.1, 1.1); 511 512 setTimeout(() => { 513 const updateMaterial = (entity) => { 514 if (entity.model?.meshInstances) { 515 entity.model.meshInstances.forEach(mi => { 516 mi.material.useLighting = true; 517 mi.material.diffuse = new pc.Color(0.9, 0.7, 0.1); 518 mi.material.update(); 519 }); 520 } 521 entity.children.forEach(updateMaterial); 522 }; 523 updateMaterial(personEntity); 524 }, 200); 525 526 personTemplate = personEntity; 527 resolve(); 528 }); 529 530 asset.on('error', (err) => { 531 console.error('加载人物模型失败:', err); 532 reject(err); 533 }); 534 app.assets.load(asset); 535 }); 536}; 537 538// 加载路障模型 539const loadBarrierModel = async () => { 540 return new Promise((resolve, reject) => { 541 const modelUrl = new URL('/download/barrier/barrier.glb', import.meta.url).href; 542 const asset = new pc.Asset('barrier', 'model', { url: modelUrl }, { preload: true }); 543 544 app.assets.add(asset); 545 asset.on('load', () => { 546 const barrierEntity = new pc.Entity('barrier-template'); 547 barrierEntity.addComponent('model', { 548 type: 'asset', asset: asset, castShadows: true, receiveShadows: true 549 }); 550 barrierEntity.setLocalScale(0.5, 0.5, 0.3); 551 barrierEntity.setLocalEulerAngles(0, 90, 0); 552 553 setTimeout(() => { 554 const updateMaterial = (entity) => { 555 if (entity.model?.meshInstances) { 556 entity.model.meshInstances.forEach(mi => { 557 mi.material.useLighting = true; 558 mi.material.diffuse = new pc.Color(0.4, 0.25, 0.1); 559 mi.material.update(); 560 }); 561 } 562 entity.children.forEach(updateMaterial); 563 }; 564 updateMaterial(barrierEntity); 565 }, 200); 566 567 barrierTemplate = barrierEntity; 568 resolve(); 569 }); 570 571 asset.on('error', (err) => { 572 console.error('加载路障模型失败:', err); 573 reject(err); 574 }); 575 app.assets.load(asset); 576 }); 577}; 578 579// ====================== 模型创建方法 ====================== 580const makeDog = (parent) => { 581 if (!dogTemplate) return; 582 const dogEntity = dogTemplate.clone(); 583 dogEntity.setPosition(0, 0, 0); 584 parent.addChild(dogEntity); 585}; 586 587const makeCar = (parent) => { 588 if (!carTemplateBlue) return; 589 const carEntity = carTemplateBlue.clone(); 590 carEntity.setPosition(0, 0, 0); 591 parent.addChild(carEntity); 592}; 593 594const makeCarBlack = (parent) => { 595 if (!carTemplateBlack) return; 596 const carEntity = carTemplateBlack.clone(); 597 carEntity.setPosition(0, 0, 0); 598 parent.addChild(carEntity); 599}; 600 601const makeTruck = (parent) => { 602 if (!truckTemplate) return; 603 const truckEntity = truckTemplate.clone(); 604 truckEntity.setPosition(0, 5, 0); 605 parent.addChild(truckEntity); 606}; 607 608const makePerson = (parent) => { 609 if (!personTemplate) return; 610 const personEntity = personTemplate.clone(); 611 personEntity.setPosition(0, 0, 0); 612 parent.addChild(personEntity); 613}; 614 615const makeBarrier = (parent) => { 616 if (!barrierTemplate) return; 617 const barrierEntity = barrierTemplate.clone(); 618 barrierEntity.setPosition(0, 0, 0); 619 parent.addChild(barrierEntity); 620}; 621 622// ====================== 场景创建 ====================== 623// 第三人称相机 624const createCamera = () => { 625 camera = new pc.Entity('camera'); 626 camera.addComponent('camera', { clearColor: new pc.Color(0.8, 0.8, 0.9), fov: 75 }); 627 // 初始位置由 updateCameraPosition 计算 628 updateCameraPosition(); 629 app.root.addChild(camera); 630}; 631 632// 灯光 633const createLight = () => { 634 // 方向光 635 const sun = new pc.Entity(); 636 sun.addComponent('light', { 637 type: 'directional', 638 color: new pc.Color(1, 1, 0.95), 639 intensity: 3, 640 castShadows: true, 641 shadowResolution: 2048 642 }); 643 sun.setEulerAngles(50, 30, 0); 644 app.root.addChild(sun); 645 646 // 环境光 647 const ambient = new pc.Entity(); 648 ambient.addComponent('light', { 649 type: 'ambient', 650 intensity: 1.2, 651 color: new pc.Color(1, 1, 1) 652 }); 653 app.root.addChild(ambient); 654}; 655 656// 简化地面 657const createGround = () => { 658 const ground = new pc.Entity('ground'); 659 ground.addComponent('model', { type: 'box' }); 660 ground.setLocalScale(200, 0.1, 800); // 扩大地面适配平移 661 ground.setPosition(0, -0.1, 0); 662 663 const gmat = new pc.StandardMaterial(); 664 gmat.diffuse = new pc.Color(0.28, 0.28, 0.32); 665 gmat.roughness = 0.9; 666 gmat.update(); 667 ground.model.material = gmat; 668 app.root.addChild(ground); 669}; 670 671// 创建玩家角色 672const createPlayer = () => { 673 player = new pc.Entity('player'); 674 makeDog(player); 675 player.setPosition(0, 1.0, 0); 676 app.root.addChild(player); 677 playerX.value = 0; 678}; 679 680// ====================== 键盘控制 ====================== 681const listenKeyboard = () => { 682 let left = false, right = false; 683 const speed = 14; 684 685 app.keyboard.on('keydown', e => { 686 if (e.key === pc.KEY_LEFT) left = true; 687 if (e.key === pc.KEY_RIGHT) right = true; 688 }); 689 690 app.keyboard.on('keyup', e => { 691 if (e.key === pc.KEY_LEFT) left = false; 692 if (e.key === pc.KEY_RIGHT) right = false; 693 }); 694 695 app.on('update', dt => { 696 if (!player) return; 697 698 // 更新角色位置 699 let playerPos = player.getPosition(); 700 if (left) playerPos.x = Math.max(-50, playerPos.x - speed * dt); 701 if (right) playerPos.x = Math.min(50, playerPos.x + speed * dt); 702 player.setPosition(playerPos); 703 playerX.value = playerPos.x; 704 705 // 更新相机位置(同步角色移动) 706 updateCameraPosition(); 707 708 // 刷新所有障碍物的相对位置 709 Array.from(obstacleEntities.entries()).forEach(([id, entity]) => { 710 const originalData = obstacleRawData.get(id) || {}; 711 if (originalData.position) { 712 const relativeX = originalData.position.x - playerPos.x; 713 const relativeZ = originalData.position.z - playerPos.z; 714 entity.setPosition(relativeX, 0, relativeZ); 715 } 716 }); 717 }); 718}; 719 720// ====================== 生命周期 ====================== 721onMounted(() => initApp()); 722 723onUnmounted(() => { 724 // 清理双击定时器(新增) 725 clearTimeout(clickTimer); 726 727 // 关闭 WebSocket 连接 728 if (ws) { 729 ws.close(); 730 ws = null; 731 } 732 733 // 销毁 PlayCanvas 应用 734 if (app) { 735 app.stop(); 736 app.destroy(); 737 app = null; 738 } 739 740 // 清理障碍物实体和原始数据 741 obstacleEntities.forEach(entity => { 742 app?.root.removeChild(entity); 743 entity.destroy(); 744 }); 745 obstacleEntities.clear(); 746 obstacleRawData.clear(); 747 748 // 清理鼠标事件监听 749 const canvas = canvasContainer.value; 750 if (canvas) { 751 canvas.removeEventListener('mousedown', () => {}); 752 canvas.removeEventListener('wheel', () => {}); 753 canvas.removeEventListener('contextmenu', () => {}); 754 canvas.removeEventListener('mouseout', () => {}); 755 } 756 window.removeEventListener('mouseup', () => {}); 757 window.removeEventListener('mousemove', () => {}); 758}); 759</script> 760 761<style scoped> 762.game-container { 763 width: 100vw; 764 height: 100vh; 765 overflow: hidden; 766 position: relative; 767} 768#app-container { 769 width: 100%; 770 height: 100%; 771 display: block; 772 cursor: grab; 773} 774.game-info { 775 position: absolute; 776 top: 20px; 777 left: 50%; 778 transform: translateX(-50%); 779 background: rgba(255,255,255,0.92); 780 padding: 10px 20px; 781 border-radius: 10px; 782 z-index: 10; 783 font-size: 17px; 784 text-align: center; 785 user-select: none; 786} 787</style> 788
