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

文章目录

一、效果

二、简介

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

三、知识点

3.1、核心交互需求分析

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

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

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>
相关推荐
山科智能信息处理实验室19 小时前
ACM MM 2024 | GeoFormer:基于三平面(Tri-Plane)与Transformer的高维点云补全原理解析
深度学习·3d
HyperAI超神经19 小时前
基于2.5万临床数据,斯坦福大学发布首个原生3D腹部CT视觉语言模型,Merlin在752类任务中全面领先
人工智能·深度学习·神经网络·机器学习·3d·语言模型·cpu
新启航半导体有限公司1 天前
电子触摸屏玻璃加工转型:新启航激光方案比水刀省 3 道工序,良率提升 8%
科技·3d·制造
cy_cy0021 天前
当历史遇见光影:投影创新点亮文化艺术展览
科技·3d·人机交互·交互·软件构建
深念Y2 天前
鼠标键盘按键失灵维修 微动和滚轮
计算机外设·键盘·鼠标·电子·维修·电子维修·
Struart_R2 天前
Spann3R、MUSt3R、CUT3R、TTT3R论文解读
人工智能·计算机视觉·3d·三维重建·前馈模型
twe77582582 天前
电镀液应用中的技术支持体系:如何优化工艺并解决常见问题
科技·3d·制造·动画
老李的森林2 天前
机械--3D打印切片软件Cura与Simplify3D
3d
zhooyu3 天前
二维坐标转三维坐标的实现原理
c++·3d·opengl