【Web】使用Vue3+PlayCanvas开发3D游戏(五)3D模型鼠标交互控制

作者:沙振宇日期:2026/3/23

文章目录

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

一、效果

在这里插入图片描述

二、简介

在《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(四)3D 障碍物躲避游戏 2 - 模型加载》中,我们完成了各类 3D 模型(车辆、人物、路障等)的加载与 WebSocket 数据驱动的障碍物渲染。但一个完整的 3D 交互游戏,核心体验之一是玩家对视角的掌控 —— 通过鼠标实现视角旋转、场景平移、滚轮缩放,以及双击快速恢复默认视角。本文将聚焦鼠标交互控制的实现,基于 Vue3+PlayCanvas 构建流畅的 3D 视角操控体系,让玩家能自由探索游戏场景。

三、知识点

3.1、核心交互需求分析

在 3D 障碍物躲避游戏中,鼠标交互需要满足以下核心场景:

  1. 左键拖动:围绕玩家角色旋转视角(水平 + 垂直),且限制垂直旋转范围防止视角翻转;
  2. 右键拖动:平移整个场景(本质是移动玩家角色,同步更新障碍物相对位置);
  3. 滚轮滚动:拉近 / 拉远视角(控制相机与角色的距离),限制缩放范围避免视角过近 / 过远;
  4. 左键双击:快速恢复默认视角(重置相机位置、角度,可选重置角色位置);
  5. 交互体验优化:鼠标样式切换(正常 / 拖拽中)、事件防穿透、窗口自适应等。

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

【Web】使用Vue3+PlayCanvas开发3D游戏(五)3D模型鼠标交互控制》 是转载文章,点击查看原文


相关推荐


pycharm创建桌面时间控件小程序
chushiyunen不懂代码的小白2026/3/15

文章目录 步骤 主要是为了走一遍python创建exe的流程。 步骤 1、新建一个项目,名称为:desktop_widget 2、创建一个python文件,名称为:desktop_widget.py,内容如下: import tkinter as tk from datetime import datetime class DesktopWidget: def __init__(self): self.root = tk.Tk()


Vue3开发 First Internship
午安~婉2026/3/6

#记录在2025.12-2026.3,从一个Vue新手到能独立开发项目的成长历程,包含大量实战中遇到的问题和解决方案。第一段实习总结。 #时间:2026.3.3 目录 一.项目环境与工具 二.Git版本控制实战 三.Vue3核心知识 四、路由与状态管理 五、CSS与布局技巧 六、性能优化 七、移动端开发 八、调试与部署 九、开发工具与插件 十、常见问题与解决方案 一.项目环境与工具 1.1 开发模式与生产模式的区别 开发流程:pnpm run dev→ vite


【分布式组件雪花ID】
老友記2026/2/26

分布式组件雪花ID 组成时钟回拨解决方案汇总方案一:等待后重试(阻塞等待)方案二:预留回拨位(占用序列号位)1. "预留回拨位"的核心思想2. 位分配对比图3. 具体工作场景模拟正常情况(时间向前走):发生时钟回拨(时间从1000跳回999): 4. 这种方案的优缺点5. 位运算代码示意(Java) 方案三:采用"未生成ID最大上限"自动漂移方案四:外部存储兜底(依赖Redis/ZooKeeper) 组成 雪花ID(Snowflake ID)的生成规则,核心


Linux camera驱动开发(真正需要做的linux驱动开发)
嵌入式-老费2026/2/18

【 声明:版权所有,欢迎转载,请勿用于商业用途。 联系信箱:feixiaoxing @163.com】         很多的soc厂家,在发布sdk的时候,就提供了很多的芯片驱动。这里面有推荐的ddr、norflash、nandflash、emmc、sdio wifi、eth phy、触摸芯片等等。如果不是特殊的需求,基本上使用厂家推荐的芯片、模块,就可以做功能开发了。但是还有一些场景,是需要自己去主动适配驱动的,尤其是增加功能和降低成本的时候。 1、国产芯片适配      


OpenClaw架构揭秘:178k stars的个人AI助手如何用Gateway模式统一控制12+通讯频道
iDao技术魔方2026/2/9

一句话简介:178k stars 的开源项目 OpenClaw,用一套 Gateway 架构同时接入了 WhatsApp、Telegram、Slack、Discord 等 12+ 通讯频道,还实现了 Canvas 可视化、全时语音、浏览器控制等高级功能。这篇文章将深度拆解它的架构设计,告诉你一个「个人 AI 助手」应该如何构建。 📋 目录 背景:为什么需要个人AI助手? 项目概览:178k stars的OpenClaw 核心架构:Gateway WebSocket控制平面 多频道接入:1


墨梅博客 1.3.0 发布与服务器数据备份教训 | 2026 年第 5 周草梅周报
草梅友仁2026/2/1

本文在 草梅友仁的博客 发布和更新,并在多个平台同步发布。如有更新,以博客上的版本为准。您也可以通过文末的 原文链接 查看最新版本。 前言 欢迎来到草梅周报!这是一个由草梅友仁基于 AI 整理的周报,旨在为您提供最新的博客更新、GitHub 动态、个人动态和其他周刊文章推荐等内容。 开源动态 本周依旧在开发 墨梅 (Momei) 中。 您可以前往 Demo 站试用:demo.momei.app/ 您可以通过邮箱 admin@example.com,密码momei123456登录演示用管理


RPC分布式通信(3)--RPC基础框架接口
陌路202026/1/22

一、MprpcApplication 核心职责 MprpcApplication是 RPC 框架的 “管家”,核心作用: 单例模式:全局唯一实例,避免重复初始化; 配置加载:解析 RPC 框架的配置文件(如服务器 IP、端口、日志路径、注册中心地址等); 框架初始化:启动时初始化日志、网络、注册中心等核心组件; 全局参数访问:提供接口获取配置参数(如获取服务器端口、注册中心地址); 框架销毁:程序退出时释放资源。 二、MprpcApplication 核心接


【计算机网络 | 第三篇】MAC地址与IP地址
YYYing.2026/1/14

目录 MAC地址 一、MAC地址的格式特征 二、MAC地址的获取 三、什么是ARP? 四、ARP缓存 五、RARP IP地址 一、为什么要有IP地址? 二、既然IP地址存在,那它的意义是什么? 三、那又如何表示呢? 1、IP地址的定义 2、IPv4地址的表示方法 2.1、IPv4地址的分类编址方法 2.2、IPv4地址的划分子网编址方法 2.2.1、为什么要划分子网? 2.2.2、怎么划分子网? 2.2.3、总结 2.3、IPv4地址的无分类编址方法 3、构


Rust 的 `PhantomData`:零成本把“语义信息”交给编译器
Pomelo_刘金2026/1/5

在写底层 Rust(尤其是 unsafe / 裸指针 / FFI)时,你会遇到一种常见矛盾: 运行时:你手里可能只有一个 *const T / *mut T / *mut c_void(比如外部库返回的句柄),结构体里并没有真正存放某个引用或某个类型的值。 编译期:你又希望编译器知道“我这个类型和某个生命周期/类型绑定”,从而帮你做借用检查、推导 Send/Sync、避免错误混用等。 std::marker::PhantomData<T> 就是为了解决这个问题而存在的工具。官方文档的核心定义


前端开发者使用 AI 的能力层级——从表面使用到工程化能力的真正分水岭
月亮有石头2025/12/28

很多前端开发者已经在“使用 AI”: 会问问题、会让 AI 写代码、甚至在 IDE 里和 AI 对话。 但如果这些使用方式 无法稳定地产出可运行、可验证、可回归的工程结果, 那么严格来说——其实还没有真正入门。 这篇文章想系统回答一个问题: 前端开发者“使用 AI”的能力,是有明确层级和分水岭的。 不是工具多不多,也不是模型新不新, 而是:你用 AI 的方式,决定了它在你工程体系里的角色。 把 AI 放进工程链路,用工程约束对抗幻觉,用验证与反馈逼近真实。 AI 工程化的本质,并不是让模型

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 XYZ博客