文章目录
一、效果

二、简介
在《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(四)3D 障碍物躲避游戏 2 - 模型加载》中,我们完成了各类 3D 模型(车辆、人物、路障等)的加载与 WebSocket 数据驱动的障碍物渲染。但一个完整的 3D 交互游戏,核心体验之一是玩家对视角的掌控 ------ 通过鼠标实现视角旋转、场景平移、滚轮缩放,以及双击快速恢复默认视角。本文将聚焦鼠标交互控制的实现,基于 Vue3+PlayCanvas 构建流畅的 3D 视角操控体系,让玩家能自由探索游戏场景。
三、知识点
3.1、核心交互需求分析
在 3D 障碍物躲避游戏中,鼠标交互需要满足以下核心场景:
- 左键拖动:围绕玩家角色旋转视角(水平 + 垂直),且限制垂直旋转范围防止视角翻转;
- 右键拖动:平移整个场景(本质是移动玩家角色,同步更新障碍物相对位置);
- 滚轮滚动:拉近 / 拉远视角(控制相机与角色的距离),限制缩放范围避免视角过近 / 过远;
- 左键双击:快速恢复默认视角(重置相机位置、角度,可选重置角色位置);
- 交互体验优化:鼠标样式切换(正常 / 拖拽中)、事件防穿透、窗口自适应等。
3.2、基础准备:变量定义
首先在 Vue3 的JS中定义鼠标交互所需的核心变量,包括鼠标状态、相机参数、双击检测等:
js
// 相机控制相关变量
let isMouseLeftDown = false; // 鼠标左键是否按下
let isMouseRightDown = false; // 鼠标右键是否按下
let lastMouseX = 0; // 上一帧鼠标X坐标
let lastMouseY = 0; // 上一帧鼠标Y坐标
let cameraDistance = 8; // 相机距离角色的初始距离
let cameraYaw = 0; // 相机水平旋转角度(绕Y轴)
let cameraPitch = 20; // 相机垂直旋转角度(绕X轴)
// 视角默认值(用于双击恢复)
const DEFAULT_CAMERA_DISTANCE = 8;
const DEFAULT_CAMERA_YAW = 0;
const DEFAULT_CAMERA_PITCH = 20;
// 双击检测变量
let clickTimer = null;
let clickCount = 0;
3.3、工具函数封装
3.3.1、角度转弧度(原生实现)
PlayCanvas 的角度计算依赖弧度,封装原生 JS 函数避免依赖引擎内置方法,提高代码兼容性:
js
/**
* 角度转弧度(原生JS实现,不依赖PlayCanvas内置方法)
* @param {number} degrees 角度值
* @returns {number} 弧度值
*/
const degToRad = (degrees) => {
return degrees * Math.PI / 180;
};
3.3.2、相机位置更新函数
所有鼠标交互最终都会修改相机参数,需封装统一的相机位置计算逻辑,基于球面坐标更新相机位置并朝向玩家角色:
js
/**
* 更新相机位置
*/
const updateCameraPosition = () => {
if (!camera || !player) return;
const playerPos = player.getPosition();
// 转换为弧度
const yawRad = degToRad(cameraYaw);
const pitchRad = degToRad(cameraPitch);
// 计算相机的球面坐标(基于角色位置偏移)
const cameraX = playerPos.x + cameraDistance * Math.sin(yawRad) * Math.cos(pitchRad);
const cameraZ = playerPos.z + cameraDistance * Math.cos(yawRad) * Math.cos(pitchRad);
const cameraY = playerPos.y + 2 + cameraDistance * Math.sin(pitchRad); // 基础高度 + 垂直偏移
// 设置相机位置和朝向(始终看向角色)
camera.setPosition(cameraX, cameraY, cameraZ);
camera.lookAt(playerPos.x, playerPos.y + 1, playerPos.z);
};
3.3.3、视角重置函数(双击触发)
封装视角恢复逻辑,重置相机参数并可选重置角色位置,让玩家快速回到初始视角:
js
/**
* 恢复视角到默认值
*/
const resetCameraView = () => {
cameraDistance = DEFAULT_CAMERA_DISTANCE;
cameraYaw = DEFAULT_CAMERA_YAW;
cameraPitch = DEFAULT_CAMERA_PITCH;
// 可选:重置角色位置到初始点
if (player) {
player.setPosition(0, 1.0, 0);
playerX.value = 0;
}
// 立即更新相机位置
updateCameraPosition();
console.log('视角已恢复默认值');
};
3.4、鼠标交互核心逻辑实现
3.4.1、初始化鼠标事件监听
在initApp函数中调用initMouseControls初始化所有鼠标交互,绑定 canvas 的鼠标按下、松开、移动、滚轮等事件:
js
const initMouseControls = () => {
const canvas = canvasContainer.value;
// 鼠标按下事件(包含双击检测)
canvas.addEventListener('mousedown', (e) => {
e.preventDefault(); // 阻止默认行为(如文本选中、滚动)
// 双击检测逻辑(仅左键)
if (e.button === 0) {
clickCount++;
if (clickCount === 1) {
clickTimer = setTimeout(() => {
clickCount = 0; // 300ms超时重置
}, 300);
} else if (clickCount === 2) {
clearTimeout(clickTimer);
clickCount = 0;
resetCameraView(); // 双击触发视角重置
return; // 阻止双击时触发左键旋转逻辑
}
}
// 普通左键/右键按下逻辑
if (e.button === 0 && clickCount === 1) { // 仅单次左键点击触发旋转
isMouseLeftDown = true;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing'; // 切换鼠标样式
} else if (e.button === 2) { // 右键按下触发平移
isMouseRightDown = true;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing';
}
});
// 鼠标松开事件(全局监听,防止鼠标移出canvas后无法松开)
window.addEventListener('mouseup', (e) => {
if (e.button === 0 || e.button === 2) {
isMouseLeftDown = false;
isMouseRightDown = false;
canvas.style.cursor = 'grab'; // 恢复鼠标样式
}
});
// 鼠标移出canvas事件(清理状态)
canvas.addEventListener('mouseout', () => {
isMouseLeftDown = false;
isMouseRightDown = false;
canvas.style.cursor = 'grab';
// 清理双击定时器
clearTimeout(clickTimer);
clickCount = 0;
});
// 鼠标移动事件(核心:旋转/平移逻辑)
window.addEventListener('mousemove', (e) => {
if (!camera || !player) return;
const deltaX = e.clientX - lastMouseX;
const deltaY = e.clientY - lastMouseY;
if (isMouseLeftDown) {
// 左键旋转:水平旋转(Yaw)和垂直旋转(Pitch)
cameraYaw -= deltaX * 0.5; // 灵敏度系数
// 限制垂直旋转范围(0° ~ 80°,防止视角翻转)
cameraPitch = Math.max(0, Math.min(80, cameraPitch + deltaY * 0.5));
updateCameraPosition(); // 实时更新相机位置
} else if (isMouseRightDown) {
// 右键平移:基于相机角度计算平移方向(更自然的体验)
const moveSpeed = 0.1;
let playerPos = player.getPosition();
const yawRad = degToRad(cameraYaw);
// 结合相机旋转角度,计算X/Z轴平移量
playerPos.x -= deltaX * moveSpeed * Math.cos(yawRad) + deltaY * moveSpeed * Math.sin(yawRad);
playerPos.z += deltaY * moveSpeed * Math.cos(yawRad) - deltaX * moveSpeed * Math.sin(yawRad);
// 限制角色平移范围(防止移出地面)
playerPos.x = Math.max(-50, Math.min(50, playerPos.x));
playerPos.z = Math.max(-100, Math.min(100, playerPos.z));
player.setPosition(playerPos);
playerX.value = playerPos.x; // 更新角色位置显示
updateCameraPosition(); // 同步更新相机位置
}
// 更新上一帧鼠标坐标
lastMouseX = e.clientX;
lastMouseY = e.clientY;
});
// 鼠标滚轮缩放(控制相机距离)
canvas.addEventListener('wheel', (e) => {
e.preventDefault(); // 阻止页面滚动
// 调整相机距离,限制范围 2~30 单位
cameraDistance = Math.max(2, Math.min(30, cameraDistance - e.deltaY * 0.01));
updateCameraPosition();
});
// 阻止右键菜单弹出
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// 初始化鼠标样式
canvas.style.cursor = 'grab';
};
3.4.2、关键逻辑说明
- 双击检测:通过clickCount计数 +setTimeout超时(300ms)实现,双击时直接触发resetCameraView并阻止后续左键旋转逻辑;
- 左键旋转:通过修改cameraYaw(水平)和cameraPitch(垂直)实现视角旋转,且用Math.max/min限制垂直角度范围(0°~80°),避免视角翻转;
- 右键平移:不是直接移动相机,而是移动玩家角色,并基于相机当前旋转角度计算平移方向,让平移体验更符合玩家视角;
- 滚轮缩放:修改cameraDistance控制相机与角色的距离,限制 2~30 单位避免视角过近 / 过远;
- 样式与状态管理:鼠标按下 / 松开 / 移出时切换cursor样式,提升交互反馈;全局监听mouseup避免鼠标移出 canvas 后状态异常。
3.5、集成到初始化流程
将鼠标控制初始化函数initMouseControls添加到initApp的初始化流程中,确保在相机、角色创建后执行:
js
const initApp = async () => {
await nextTick();
if (!canvasContainer.value) return;
// 初始化 PlayCanvas 应用(省略原有代码)
// ...
// 加载所有模型(省略原有代码)
await Promise.all([
loadDogModel(),
loadCarModels(),
loadTruckModel(),
loadPersonModel(),
loadBarrierModel()
]);
createCamera();
createLight();
createGround();
createPlayer();
listenKeyboard();
initWebSocket();
initMouseControls(); // 初始化鼠标控制(新增)
// 窗口自适应(省略原有代码)
// ...
};
3.6、生命周期清理
在 Vue 组件卸载时,清理鼠标事件监听和双击定时器,避免内存泄漏:
js
onUnmounted(() => {
// 清理双击定时器
clearTimeout(clickTimer);
// 关闭 WebSocket 连接(原有代码)
if (ws) {
ws.close();
ws = null;
}
// 销毁 PlayCanvas 应用(原有代码)
if (app) {
app.stop();
app.destroy();
app = null;
}
// 清理障碍物实体(原有代码)
// ...
// 清理鼠标事件监听
const canvas = canvasContainer.value;
if (canvas) {
canvas.removeEventListener('mousedown', () => {});
canvas.removeEventListener('wheel', () => {});
canvas.removeEventListener('contextmenu', () => {});
canvas.removeEventListener('mouseout', () => {});
}
window.removeEventListener('mouseup', () => {});
window.removeEventListener('mousemove', () => {});
});
四、完整源码
html
<template>
<div class="game-container">
<canvas id="app-container" ref="canvasContainer"></canvas>
<!-- 游戏信息面板 -->
<div class="game-info">
实时显示周围障碍物
<br>当前角色位置:X: {{ playerX.toFixed(1) }} | 已加载障碍物:{{ obstacleCount }}
<br>操作说明:左键旋转 | 右键平移 | 滚轮缩放 | 左键双击恢复视角
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import * as pc from 'playcanvas';
// 容器
const canvasContainer = ref(null);
// 引擎实例
let app = null;
let camera = null;
let player = null;
// 模型模板
let carTemplateBlue = null; // 蓝色轿车模板
let carTemplateBlack = null; // 黑色轿车模板
let truckTemplate = null; // 灰色货车模板
let personTemplate = null; // 黄色人物模板
let dogTemplate = null; // 狗模型模板
let barrierTemplate = null; // 路障模型模板
// 状态管理
const playerX = ref(0); // 角色X轴位置
const obstacleCount = ref(0); // 已加载障碍物数量
let obstacleEntities = new Map(); // 存储障碍物实体 key: id, value: entity
let obstacleRawData = new Map(); // 存储障碍物原始数据(替代localStorage)
let ws = null; // WebSocket 实例
// 相机控制相关变量
let isMouseLeftDown = false; // 鼠标左键是否按下
let isMouseRightDown = false; // 鼠标右键是否按下
let lastMouseX = 0; // 上一帧鼠标X坐标
let lastMouseY = 0; // 上一帧鼠标Y坐标
let cameraDistance = 8; // 相机距离角色的初始距离
let cameraYaw = 0; // 相机水平旋转角度(绕Y轴)
let cameraPitch = 20; // 相机垂直旋转角度(绕X轴)
// 视角默认值(新增)
const DEFAULT_CAMERA_DISTANCE = 8;
const DEFAULT_CAMERA_YAW = 0;
const DEFAULT_CAMERA_PITCH = 20;
// 双击检测变量(新增)
let clickTimer = null;
let clickCount = 0;
// ====================== 工具函数 ======================
/**
* 角度转弧度(原生JS实现,不依赖PlayCanvas内置方法)
* @param {number} degrees 角度值
* @returns {number} 弧度值
*/
const degToRad = (degrees) => {
return degrees * Math.PI / 180;
};
/**
* 恢复视角到默认值(新增核心函数)
*/
const resetCameraView = () => {
cameraDistance = DEFAULT_CAMERA_DISTANCE;
cameraYaw = DEFAULT_CAMERA_YAW;
cameraPitch = DEFAULT_CAMERA_PITCH;
// 重置角色位置到初始点(可选,根据需求决定是否开启)
if (player) {
player.setPosition(0, 1.0, 0);
playerX.value = 0;
}
// 立即更新相机位置
updateCameraPosition();
console.log('视角已恢复默认值');
};
// ====================== 初始化 ======================
const initApp = async () => {
await nextTick();
if (!canvasContainer.value) return;
// 初始化 PlayCanvas 应用
app = new pc.Application(canvasContainer.value, {
elementInput: new pc.ElementInput(canvasContainer.value),
mouse: new pc.Mouse(canvasContainer.value),
keyboard: new pc.Keyboard(window),
touch: new pc.TouchDevice(canvasContainer.value),
graphicsDeviceOptions: { webgl2: true, antialias: true, powerPreference: 'high-performance' },
createCanvas: false
});
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.start();
app.scene.background = new pc.Color(0.8, 0.8, 0.9);
// 加载所有模型
await Promise.all([
loadDogModel(),
loadCarModels(),
loadTruckModel(),
loadPersonModel(),
loadBarrierModel()
]);
createCamera();
createLight();
createGround(); // 简化的地面
createPlayer(); // 创建玩家角色
listenKeyboard(); // 监听键盘控制
initWebSocket(); // 初始化 WebSocket
initMouseControls(); // 初始化鼠标控制
// 窗口自适应
window.addEventListener('resize', () => {
app?.resizeCanvas(window.innerWidth, window.innerHeight);
});
};
// ====================== 鼠标控制相关 ======================
const initMouseControls = () => {
const canvas = canvasContainer.value;
// 鼠标按下事件(包含双击检测)
canvas.addEventListener('mousedown', (e) => {
e.preventDefault();
// 双击检测逻辑(新增核心)
if (e.button === 0) { // 仅检测左键双击
clickCount++;
if (clickCount === 1) {
clickTimer = setTimeout(() => {
clickCount = 0; // 超时重置点击计数
}, 300); // 300ms内的两次点击视为双击
} else if (clickCount === 2) {
clearTimeout(clickTimer);
clickCount = 0;
resetCameraView(); // 双击触发恢复视角
return; // 阻止双击时触发左键按下的旋转逻辑
}
}
// 普通左键/右键按下逻辑
if (e.button === 0 && clickCount === 1) { // 仅单次点击时触发旋转
isMouseLeftDown = true;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing';
} else if (e.button === 2) { // 右键
isMouseRightDown = true;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing';
}
});
// 鼠标松开事件
window.addEventListener('mouseup', (e) => {
if (e.button === 0 || e.button === 2) {
isMouseLeftDown = false;
isMouseRightDown = false;
canvas.style.cursor = 'grab';
}
});
// 鼠标移出事件
canvas.addEventListener('mouseout', () => {
isMouseLeftDown = false;
isMouseRightDown = false;
canvas.style.cursor = 'grab';
// 移出时重置双击检测(新增)
clearTimeout(clickTimer);
clickCount = 0;
});
// 鼠标移动事件
window.addEventListener('mousemove', (e) => {
if (!camera || !player) return;
const deltaX = e.clientX - lastMouseX;
const deltaY = e.clientY - lastMouseY;
if (isMouseLeftDown) {
// 左键旋转:水平旋转(Yaw)和垂直旋转(Pitch)
cameraYaw -= deltaX * 0.5;
// 限制垂直旋转范围(0° 到 80°,防止视角翻转)
cameraPitch = Math.max(0, Math.min(80, cameraPitch + deltaY * 0.5));
updateCameraPosition();
} else if (isMouseRightDown) {
// 右键平移:调整角色位置(模拟场景平移)
const moveSpeed = 0.1;
let playerPos = player.getPosition();
// 基于相机旋转角度计算平移方向(更自然的平移体验)
const yawRad = degToRad(cameraYaw);
playerPos.x -= deltaX * moveSpeed * Math.cos(yawRad) + deltaY * moveSpeed * Math.sin(yawRad);
playerPos.z += deltaY * moveSpeed * Math.cos(yawRad) - deltaX * moveSpeed * Math.sin(yawRad);
// 限制平移范围
playerPos.x = Math.max(-50, Math.min(50, playerPos.x));
playerPos.z = Math.max(-100, Math.min(100, playerPos.z));
player.setPosition(playerPos);
playerX.value = playerPos.x;
updateCameraPosition();
}
lastMouseX = e.clientX;
lastMouseY = e.clientY;
});
// 鼠标滚轮缩放
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
// 调整相机距离(缩放),限制范围 2~30 单位
cameraDistance = Math.max(2, Math.min(30, cameraDistance - e.deltaY * 0.01));
updateCameraPosition();
});
// 阻止右键菜单
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// 初始化鼠标样式
canvas.style.cursor = 'grab';
};
/**
* 更新相机位置
*/
const updateCameraPosition = () => {
if (!camera || !player) return;
const playerPos = player.getPosition();
// 使用自定义的degToRad函数,不依赖PlayCanvas内置方法
const yawRad = degToRad(cameraYaw);
const pitchRad = degToRad(cameraPitch);
// 计算相机的球面坐标
const cameraX = playerPos.x + cameraDistance * Math.sin(yawRad) * Math.cos(pitchRad);
const cameraZ = playerPos.z + cameraDistance * Math.cos(yawRad) * Math.cos(pitchRad);
const cameraY = playerPos.y + 2 + cameraDistance * Math.sin(pitchRad); // 基础高度 + 垂直偏移
// 设置相机位置和朝向
camera.setPosition(cameraX, cameraY, cameraZ);
camera.lookAt(playerPos.x, playerPos.y + 1, playerPos.z);
};
// ====================== WebSocket 相关 ======================
const initWebSocket = () => {
// 替换为你的 WebSocket 服务地址
const wsUrl = 'ws://localhost:8080/obstacle';
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket 连接成功');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('解析障碍物数据成功:', data);
handleObstacleData(data);
} catch (error) {
console.error('解析障碍物数据失败:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
ws.onclose = () => {
console.log('WebSocket 连接关闭,5秒后重连');
setTimeout(initWebSocket, 5000);
};
};
/**
* 处理WebSocket接收的障碍物数据
*/
const handleObstacleData = (obstacleData) => {
if (!obstacleData.id || !obstacleData.type) return;
// 存储原始数据(替代localStorage)
obstacleRawData.set(obstacleData.id, obstacleData);
// 计算相对角色的位置 (角色为参考点)
const playerPos = player.getPosition();
const relativeX = obstacleData.position.x - playerPos.x;
const relativeZ = obstacleData.position.z - playerPos.z;
// 只显示角色周围一定范围内的障碍物
const maxDistance = 50; // 最大显示距离
const isInRange = Math.hypot(relativeX, relativeZ) <= maxDistance;
if (obstacleData.isActive && isInRange) {
updateObstacle(obstacleData, relativeX, relativeZ);
} else {
removeObstacle(obstacleData.id);
}
};
/**
* 更新或创建障碍物实体
*/
const updateObstacle = (data, relativeX, relativeZ) => {
let obstacleEntity = obstacleEntities.get(data.id);
// 不存在则创建新实体
if (!obstacleEntity) {
obstacleEntity = new pc.Entity(`obstacle-${data.id}`);
obstacleEntity.collisionType = data.type;
// 根据类型创建模型
switch (data.type) {
case 'car':
Math.random() > 0.5 ? makeCar(obstacleEntity) : makeCarBlack(obstacleEntity);
break;
case 'truck': makeTruck(obstacleEntity); break;
case 'person': makePerson(obstacleEntity); break;
case 'barrier': makeBarrier(obstacleEntity); break;
default: return;
}
app.root.addChild(obstacleEntity);
obstacleEntities.set(data.id, obstacleEntity);
obstacleCount.value = obstacleEntities.size;
}
// 设置位置、缩放和旋转
obstacleEntity.setPosition(relativeX, 0, relativeZ);
// 应用自定义大小
if (data.size) {
obstacleEntity.setLocalScale(data.size.x, data.size.y, data.size.z);
}
// 应用航向
if (data.heading) {
obstacleEntity.setLocalEulerAngles(0, data.heading, 0);
}
obstacleEntity.visible = true;
};
/**
* 移除障碍物实体
*/
const removeObstacle = (id) => {
const obstacleEntity = obstacleEntities.get(id);
if (obstacleEntity) {
app.root.removeChild(obstacleEntity);
obstacleEntity.destroy();
obstacleEntities.delete(id);
obstacleRawData.delete(id); // 同步删除原始数据
obstacleCount.value = obstacleEntities.size;
}
};
// ====================== 模型加载相关 ======================
// 加载角色模型 (玩家角色)
const loadDogModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/car/car.glb', import.meta.url).href;
const asset = new pc.Asset('dog', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const dogEntity = new pc.Entity('dog-template');
dogEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
dogEntity.setLocalScale(1.0, 1.0, 1.0);
dogEntity.setLocalEulerAngles(-90, -180, 0);
// 材质设置
setTimeout(() => {
const updateMaterials = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
if (mi.material) {
mi.material.useLighting = true;
mi.material.roughness = 0.5;
mi.material.metalness = 0.1;
mi.material.update();
}
});
}
entity.children.forEach(updateMaterials);
};
updateMaterials(dogEntity);
}, 200);
dogTemplate = dogEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载狗模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
// 加载轿车模型
const loadCarModels = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/car/car2.glb', import.meta.url).href;
const asset = new pc.Asset('car', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
// 蓝色轿车
const createCar = (colorName, diffuseColor) => {
const carEntity = new pc.Entity(`car-${colorName}`);
carEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
carEntity.setLocalScale(0.6, 0.6, 0.6);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
const mat = mi.material.clone();
mat.useLighting = true;
mat.diffuse = diffuseColor;
mat.specular = new pc.Color(0.2, 0.2, 0.2);
mat.roughness = 0.3;
mat.metalness = 0.7;
mat.update();
mi.material = mat;
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(carEntity);
}, 200);
return carEntity;
};
carTemplateBlue = createCar('blue', new pc.Color(0.1, 0.3, 0.8));
carTemplateBlack = createCar('black', new pc.Color(0.1, 0.1, 0.1));
resolve();
});
asset.on('error', (err) => {
console.error('加载轿车模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
// 加载货车模型
const loadTruckModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/truck/truck2.glb', import.meta.url).href;
const asset = new pc.Asset('truck', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const truckEntity = new pc.Entity('truck-template');
truckEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
truckEntity.setLocalEulerAngles(-90, 0, 0);
truckEntity.setLocalScale(1.4, 1.4, 1.4);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
mi.material.useLighting = true;
mi.material.diffuse = new pc.Color(0.3, 0.3, 0.3);
mi.material.update();
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(truckEntity);
}, 200);
truckTemplate = truckEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载货车模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
// 加载人物模型
const loadPersonModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/person/person.glb', import.meta.url).href;
const asset = new pc.Asset('person', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const personEntity = new pc.Entity('person-template');
personEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
personEntity.setLocalScale(1.1, 1.1, 1.1);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
mi.material.useLighting = true;
mi.material.diffuse = new pc.Color(0.9, 0.7, 0.1);
mi.material.update();
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(personEntity);
}, 200);
personTemplate = personEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载人物模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
// 加载路障模型
const loadBarrierModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/barrier/barrier.glb', import.meta.url).href;
const asset = new pc.Asset('barrier', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const barrierEntity = new pc.Entity('barrier-template');
barrierEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
barrierEntity.setLocalScale(0.5, 0.5, 0.3);
barrierEntity.setLocalEulerAngles(0, 90, 0);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
mi.material.useLighting = true;
mi.material.diffuse = new pc.Color(0.4, 0.25, 0.1);
mi.material.update();
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(barrierEntity);
}, 200);
barrierTemplate = barrierEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载路障模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
// ====================== 模型创建方法 ======================
const makeDog = (parent) => {
if (!dogTemplate) return;
const dogEntity = dogTemplate.clone();
dogEntity.setPosition(0, 0, 0);
parent.addChild(dogEntity);
};
const makeCar = (parent) => {
if (!carTemplateBlue) return;
const carEntity = carTemplateBlue.clone();
carEntity.setPosition(0, 0, 0);
parent.addChild(carEntity);
};
const makeCarBlack = (parent) => {
if (!carTemplateBlack) return;
const carEntity = carTemplateBlack.clone();
carEntity.setPosition(0, 0, 0);
parent.addChild(carEntity);
};
const makeTruck = (parent) => {
if (!truckTemplate) return;
const truckEntity = truckTemplate.clone();
truckEntity.setPosition(0, 5, 0);
parent.addChild(truckEntity);
};
const makePerson = (parent) => {
if (!personTemplate) return;
const personEntity = personTemplate.clone();
personEntity.setPosition(0, 0, 0);
parent.addChild(personEntity);
};
const makeBarrier = (parent) => {
if (!barrierTemplate) return;
const barrierEntity = barrierTemplate.clone();
barrierEntity.setPosition(0, 0, 0);
parent.addChild(barrierEntity);
};
// ====================== 场景创建 ======================
// 第三人称相机
const createCamera = () => {
camera = new pc.Entity('camera');
camera.addComponent('camera', { clearColor: new pc.Color(0.8, 0.8, 0.9), fov: 75 });
// 初始位置由 updateCameraPosition 计算
updateCameraPosition();
app.root.addChild(camera);
};
// 灯光
const createLight = () => {
// 方向光
const sun = new pc.Entity();
sun.addComponent('light', {
type: 'directional',
color: new pc.Color(1, 1, 0.95),
intensity: 3,
castShadows: true,
shadowResolution: 2048
});
sun.setEulerAngles(50, 30, 0);
app.root.addChild(sun);
// 环境光
const ambient = new pc.Entity();
ambient.addComponent('light', {
type: 'ambient',
intensity: 1.2,
color: new pc.Color(1, 1, 1)
});
app.root.addChild(ambient);
};
// 简化地面
const createGround = () => {
const ground = new pc.Entity('ground');
ground.addComponent('model', { type: 'box' });
ground.setLocalScale(200, 0.1, 800); // 扩大地面适配平移
ground.setPosition(0, -0.1, 0);
const gmat = new pc.StandardMaterial();
gmat.diffuse = new pc.Color(0.28, 0.28, 0.32);
gmat.roughness = 0.9;
gmat.update();
ground.model.material = gmat;
app.root.addChild(ground);
};
// 创建玩家角色
const createPlayer = () => {
player = new pc.Entity('player');
makeDog(player);
player.setPosition(0, 1.0, 0);
app.root.addChild(player);
playerX.value = 0;
};
// ====================== 键盘控制 ======================
const listenKeyboard = () => {
let left = false, right = false;
const speed = 14;
app.keyboard.on('keydown', e => {
if (e.key === pc.KEY_LEFT) left = true;
if (e.key === pc.KEY_RIGHT) right = true;
});
app.keyboard.on('keyup', e => {
if (e.key === pc.KEY_LEFT) left = false;
if (e.key === pc.KEY_RIGHT) right = false;
});
app.on('update', dt => {
if (!player) return;
// 更新角色位置
let playerPos = player.getPosition();
if (left) playerPos.x = Math.max(-50, playerPos.x - speed * dt);
if (right) playerPos.x = Math.min(50, playerPos.x + speed * dt);
player.setPosition(playerPos);
playerX.value = playerPos.x;
// 更新相机位置(同步角色移动)
updateCameraPosition();
// 刷新所有障碍物的相对位置
Array.from(obstacleEntities.entries()).forEach(([id, entity]) => {
const originalData = obstacleRawData.get(id) || {};
if (originalData.position) {
const relativeX = originalData.position.x - playerPos.x;
const relativeZ = originalData.position.z - playerPos.z;
entity.setPosition(relativeX, 0, relativeZ);
}
});
});
};
// ====================== 生命周期 ======================
onMounted(() => initApp());
onUnmounted(() => {
// 清理双击定时器(新增)
clearTimeout(clickTimer);
// 关闭 WebSocket 连接
if (ws) {
ws.close();
ws = null;
}
// 销毁 PlayCanvas 应用
if (app) {
app.stop();
app.destroy();
app = null;
}
// 清理障碍物实体和原始数据
obstacleEntities.forEach(entity => {
app?.root.removeChild(entity);
entity.destroy();
});
obstacleEntities.clear();
obstacleRawData.clear();
// 清理鼠标事件监听
const canvas = canvasContainer.value;
if (canvas) {
canvas.removeEventListener('mousedown', () => {});
canvas.removeEventListener('wheel', () => {});
canvas.removeEventListener('contextmenu', () => {});
canvas.removeEventListener('mouseout', () => {});
}
window.removeEventListener('mouseup', () => {});
window.removeEventListener('mousemove', () => {});
});
</script>
<style scoped>
.game-container {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
#app-container {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
.game-info {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(255,255,255,0.92);
padding: 10px 20px;
border-radius: 10px;
z-index: 10;
font-size: 17px;
text-align: center;
user-select: none;
}
</style>