【Web】使用Vue3+PlayCanvas开发3D游戏(九)纹理视觉效果

文章目录

一、效果

  1. 视觉真实感:草地和道路从纯色变为带纹理的真实材质,法线纹理让草地有凹凸感,粗糙度纹理让道路的反光更符合物理规律;
  2. 无纹理拉伸:纹理平铺模式让纹理展示自然,细节清晰,不再出现模糊、拉伸的问题;
  3. 交互流畅性:小地图轨迹优化让模拟移动更平滑,与 3D 场景的视觉体验形成统一。

二、简介

在《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(八)模拟小地图实时移动》中,我们实现了小地图的轨迹模拟与实时移动效果,让 3D 场景的交互性和场景感知能力得到了提升。本次开发中,我们聚焦于视觉体验的优化,为场景中的草地和道路添加了专业的纹理效果(漫反射、法线、粗糙度),解决了纹理拉伸问题,并进一步优化了小地图的轨迹模拟逻辑,让 3D 游戏场景更贴近真实视觉效果。

在 3D 场景开发中,单纯依靠纯色材质构建地面和道路会显得非常单调,缺乏真实感。而直接使用纹理贴图时,若未做合理的平铺和 UV 缩放处理,会出现严重的纹理拉伸问题 ------ 纹理被无限制拉伸以适配模型尺寸,导致细节丢失、视觉效果失真。本次优化主要解决两个核心问题:

  1. 为草地和道路添加多维度纹理(漫反射、法线、粗糙度),提升材质真实感;
  2. 修复纹理拉伸问题,通过纹理平铺(repeat)和 UV 缩放实现自然的纹理展示效果。

三、核心实现步骤

3.1 纹理资源规划与加载

首先我们规划了草地和道路的纹理资源体系,针对不同材质特性设计了差异化的纹理组合:

  • 草地纹理:漫反射(diffuse)+ 法线(normal)+ 粗糙度(rough),模拟草地的颜色、凹凸质感和反光特性;
  • 道路纹理:沥青漫反射(diffuse)+ 粗糙度(rough),还原沥青路面的视觉和物理特性。

实现纹理加载的核心代码如下,通过 PlayCanvas 的 Asset 管理器批量加载纹理,并缓存供后续使用:

js 复制代码
/**
 * 加载纹理资源
 */
const loadTextures = async () => {
  return new Promise((resolve) => {
    // 加载草地纹理
    const grassDiffuseAsset = new pc.Asset('grassDiffuse', 'texture', { url: '/textures/grass/diffuse.jpg' });
    const grassNormalAsset = new pc.Asset('grassNormal', 'texture', { url: '/textures/grass/normal.jpg' });
    const grassRoughAsset = new pc.Asset('grassRough', 'texture', { url: '/textures/grass/rough.jpg' });
    
    // 加载道路纹理
    const asphaltDiffuseAsset = new pc.Asset('asphaltDiffuse', 'texture', { url: '/textures/asphalt/difuse.jpg' });
    const asphaltRoughAsset = new pc.Asset('asphaltRough', 'texture', { url: '/textures/asphalt/rough.jpg' });

    // 存储所有纹理资产
    const textureAssets = [
      grassDiffuseAsset, grassNormalAsset, grassRoughAsset,
      asphaltDiffuseAsset, asphaltRoughAsset
    ];

    // 添加到资产管理器
    textureAssets.forEach(asset => app.assets.add(asset));

    // 加载完成处理
    let loadedCount = 0;
    const onTextureLoaded = () => {
      loadedCount++;
      if (loadedCount === textureAssets.length) {
        // 缓存纹理引用
        grassDiffuseTexture = grassDiffuseAsset.resource;
        grassNormalTexture = grassNormalAsset.resource;
        grassRoughTexture = grassRoughAsset.resource;
        asphaltDiffuseTexture = asphaltDiffuseAsset.resource;
        asphaltRoughTexture = asphaltRoughAsset.resource;

        // 关键修复:开启纹理重复模式,解决拉伸
        if (grassDiffuseTexture) {
          grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
          grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (grassNormalTexture) {
          grassNormalTexture.addressU = pc.ADDRESS_REPEAT;
          grassNormalTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (grassRoughTexture) {
          grassRoughTexture.addressU = pc.ADDRESS_REPEAT;
          grassRoughTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (asphaltDiffuseTexture) {
          asphaltDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
          asphaltDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (asphaltRoughTexture) {
          asphaltRoughTexture.addressU = pc.ADDRESS_REPEAT;
          asphaltRoughTexture.addressV = pc.ADDRESS_REPEAT;
        }

        resolve();
      }
    };

    // 绑定加载事件
    textureAssets.forEach(asset => {
      asset.on('load', onTextureLoaded);
      asset.on('error', (err) => {
        console.warn(`纹理${asset.name}加载失败,使用默认颜色:`, err);
        onTextureLoaded(); // 继续流程
      });
      app.assets.load(asset);
    });
  });
};

3.2 解决纹理拉伸的关键配置

纹理拉伸的核心原因是纹理的寻址模式默认是 "拉伸(ADDRESS_CLAMP_TO_EDGE)",我们将其修改为 "重复(ADDRESS_REPEAT)",让纹理在模型表面平铺展示:

js 复制代码
// 开启纹理重复模式,解决拉伸
grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;

该配置让纹理在 U、V 两个方向上重复平铺,配合后续的 UV 缩放,可以控制纹理的平铺密度,避免纹理过大或过小。

3.3 小地图轨迹模拟优化

在纹理优化的同时,我们也优化了小地图的模拟移动轨迹数组,让轨迹点的分布更合理,模拟移动更平滑:

js 复制代码
// 定义模拟轨迹数组(优化后)
const latLngTrack = [
  { lat: 29.440775, lng: 106.521942 }, // 起点(中心位置)
  { lat: 29.440785, lng: 106.521952 },
  { lat: 29.440795, lng: 106.521962 },
  { lat: 29.440805, lng: 106.521972 },
  { lat: 29.440815, lng: 106.521982 },
  { lat: 29.440825, lng: 106.521992 },
  { lat: 29.440835, lng: 106.522002 },
  { lat: 29.440845, lng: 106.522012 },
  { lat: 29.440855, lng: 106.522022 },
  { lat: 29.440865, lng: 106.522032 },
  { lat: 29.440875, lng: 106.522042 },
  { lat: 29.440885, lng: 106.522052 },
  { lat: 29.440895, lng: 106.522062 },
  { lat: 29.440905, lng: 106.522072 },
  { lat: 29.440915, lng: 106.522082 },
];

优化后的轨迹点采用线性递增的经纬度偏移,模拟角色沿固定方向的移动,配合 300ms 的刷新间隔(UPDATE_INTERVAL),让小地图上的角色移动更流畅。

3.4 纹理与材质的融合应用

在创建地面和道路时,将加载的纹理绑定到材质的对应属性上,实现多维度的视觉效果:

  • 漫反射纹理(diffuseMap):控制基础颜色;
  • 法线纹理(normalMap):模拟表面凹凸,增强立体感;
  • 粗糙度纹理(roughnessMap):控制反光特性,模拟真实的物理材质。

核心逻辑示例:

js 复制代码
// 构建草地材质
const grassMaterial = new pc.StandardMaterial();
grassMaterial.diffuseMap = grassDiffuseTexture;
grassMaterial.normalMap = grassNormalTexture;
grassMaterial.roughnessMap = grassRoughTexture;
grassMaterial.useLighting = true;
grassMaterial.update();

// 构建道路材质
const asphaltMaterial = new pc.StandardMaterial();
asphaltMaterial.diffuseMap = asphaltDiffuseTexture;
asphaltMaterial.roughnessMap = asphaltRoughTexture;
asphaltMaterial.useLighting = true;
asphaltMaterial.update();

四、总结与后续规划

本次开发完成了 3D 场景纹理系统的核心优化,解决了视觉层面的关键问题,让场景更具真实感。后续我们将继续聚焦以下方向:

  • 优化纹理的 UV 缩放逻辑,精细化控制纹理平铺密度;
  • 为更多模型(如车辆、树木)添加纹理和材质优化;
  • 结合物理引擎,让纹理与碰撞、光影系统深度融合;
  • 优化纹理加载的性能,实现纹理的懒加载和缓存策略。

五、核心源码

html 复制代码
<template>
<div class="game-container">
    <canvas id="app-container" ref="canvasContainer"></canvas>
    <div class="game-info">
      当前角色位置:X: {{ playerX.toFixed(1) }} | 已加载障碍物:{{ obstacleCount }}
      <br>路宽:{{ ROAD_WIDTH }}m | 小车行驶速度:{{ TREE_SCROLL_SPEED }}m/s 
      <br>操作说明:左键旋转 | 右键平移 | 滚轮缩放 | 左键双击恢复视角
    </div>
    <div class="ros-image-panel">
      <h4>ROS实时画面 ({{ imageFps }} FPS)</h4>
      <img ref="rosImage" class="ros-image" alt="ROS 摄像头画面" />
    </div>
    <!-- 集成小地图组件 -->
    <MiniMapCircle 
      :centerLat="centerLat" 
      :centerLng="centerLng" 
      :currentLat="currentLat" 
      :currentLng="currentLng" 
      :scale="50"
    />
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import * as pc from 'playcanvas';
import MiniMapCircle from './MiniMapCircle.vue';

// 容器引用
const canvasContainer = ref(null);
const rosImage = ref(null);

// 地图中心经纬度
const centerLat = ref(29.440875)
const centerLng = ref(106.521942)
// 当前角色经纬度(模拟:基于3D X/Z坐标偏移)
const currentLat = ref(centerLat.value);
const currentLng = ref(centerLng.value);

// 定义模拟轨迹数组(可以根据实际需求调整坐标点和数量)
const latLngTrack = [
  { lat: 29.440775, lng: 106.521942 }, // 起点(中心位置)
  { lat: 29.440785, lng: 106.521952 },
  { lat: 29.440795, lng: 106.521962 },
  { lat: 29.440805, lng: 106.521972 },
  { lat: 29.440815, lng: 106.521982 },
  { lat: 29.440825, lng: 106.521992 },
  { lat: 29.440835, lng: 106.522002 },
  { lat: 29.440845, lng: 106.522012 },
  { lat: 29.440855, lng: 106.522022 },
  { lat: 29.440865, lng: 106.522032 },
  { lat: 29.440875, lng: 106.522042 },
  { lat: 29.440885, lng: 106.522052 },
  { lat: 29.440895, lng: 106.522062 },
  { lat: 29.440905, lng: 106.522072 },
  { lat: 29.440915, lng: 106.522082 },
];

// 轨迹控制变量
let pointIndex = 0;         // 当前走到第几个点
let mapTimer = null;        // 定时器
const UPDATE_INTERVAL = 300; // 刷新速度(越小移动越快)

// PlayCanvas核心实例
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;
let treeTemplate = null; // 新增:树模板

// 纹理资源缓存
let grassDiffuseTexture = null;
let grassNormalTexture = null;
let grassRoughTexture = null;
let asphaltDiffuseTexture = null;
let asphaltRoughTexture = null;

// 状态变量
const playerX = ref(0);
const obstacleCount = ref(0);
let obstacleEntities = new Map();
let obstacleRawData = new Map();
let treeEntities = []; // 新增:存储树实体
let treeOffsetZ = 0; // 新增:树木滚动偏移量

// WebSocket实例(纯原生,无Webviz依赖)
let obstacleWs = null;
let rosImageWs = null;

// 相机控制变量
let isMouseLeftDown = false;
let isMouseRightDown = false;
let lastMouseX = 0;
let lastMouseY = 0;
let cameraDistance = 10;
let cameraYaw = 0;
let cameraPitch = 20;
const DEFAULT_CAMERA_DISTANCE = 10;
const DEFAULT_CAMERA_YAW = 0;
const DEFAULT_CAMERA_PITCH = 20;

// 双击检测
let clickTimer = null;
let clickCount = 0;

// WebSocket重试配置(仅控制连接重试,无库检查)
const WS_MAX_RETRY = 5;
let obstacleWsRetryCount = 0;
let imageWsRetryCount = 0;

// ========== 图片流优化新增变量 ==========
const imageFps = ref(0);          // 显示当前帧率
let lastRenderTime = 0;           // 上次渲染时间
const RENDER_INTERVAL = 20;       // 渲染间隔(ms) - 20fps
let fpsCache = [];
const MAX_FPS_CACHE = 5; // 取最近5帧的平均帧率
let imageUrlPool = new Set();     // 追踪URL,批量释放

// ========== 核心配置:可调整常量 ==========
// 障碍物距离过滤配置(改为仅用道路范围过滤)
const OBSTACLE_MIN_DISTANCE = 0;  // 重置最小距离
const OBSTACLE_MAX_DISTANCE = 1000; // 重置最大距离
// 道路配置(修复颜色问题+可调整路宽)
const ROAD_WIDTH = 15;            // 道路宽度(单位:m)
const ROAD_LENGTH = 800;         // 道路长度(单位:m)
const ROAD_COLOR = new pc.Color(0.35, 0.35, 0.4); // 道路颜色(深灰色,固定)
const GROUND_COLOR = new pc.Color(0.2, 0.6, 0.2); // 路外地面颜色(绿色)
// 树木配置(新增滚动效果)
const TREE_SPACING = 50;          // 树木间隔距离
const TREE_OFFSET = ROAD_WIDTH / 2 + 5; // 树木离道路边缘的距离
const TREE_SCROLL_SPEED = 8;      // 树木滚动速度(m/s),后续可调整

// ========== 核心配置:新增模型比例锁定常量 ==========
const CAR_MODEL_SCALE = 0.4; // 轿车模型基础缩放(等比)
const CAR_ASPECT_RATIO = 1.8; // 轿车宽长比(根据实际模型调整,默认1.8:1)

// ====================== 工具函数 ======================
/**
 * 角度转弧度
 */
const degToRad = (degrees) => {
  return degrees * Math.PI / 180;
};

/**
 * 恢复默认视角
 */
const resetCameraView = () => {
  cameraDistance = DEFAULT_CAMERA_DISTANCE;
  cameraYaw = DEFAULT_CAMERA_YAW;
  cameraPitch = DEFAULT_CAMERA_PITCH;
  treeOffsetZ = 0; // 重置树木偏移
  
  if (player) {
    player.setPosition(0, 0.1, 0);
    playerX.value = 0;
  }
  
  updateCameraPosition();
  updateTreePositions(); // 刷新树木位置
  console.log('视角已恢复默认值');
};

/**
 * 优化版ROS图片流渲染(节流+内存优化)
 */
const renderRosImage = (data) => {
  const now = Date.now();
  
  // 节流控制:避免高频渲染
  if (now - lastRenderTime < RENDER_INTERVAL) {
    return;
  }
  
  const timeDiff = now - lastRenderTime;
  lastRenderTime = now;
  
  try {
    const blob = new Blob([data], { type: 'image/jpeg' });
    const imageUrl = URL.createObjectURL(blob);
    
    if (rosImage.value) {
      // 内存优化:释放旧URL
      if (rosImage.value.src && imageUrlPool.has(rosImage.value.src)) {
        URL.revokeObjectURL(rosImage.value.src);
        imageUrlPool.delete(rosImage.value.src);
      }
      rosImage.value.src = imageUrl;
      imageUrlPool.add(imageUrl);
      
      // 计算FPS(防除零)
      if (timeDiff > 0) {
        const currentFps = Math.round(1000 / timeDiff);
        fpsCache.push(currentFps);
        if (fpsCache.length > MAX_FPS_CACHE) fpsCache.shift();
        const avgFps = Math.round(fpsCache.reduce((sum, fps) => sum + fps, 0) / fpsCache.length);
        imageFps.value = Math.max(1, Math.min(60, avgFps)); // 限制FPS范围1-60
      } else {
        imageFps.value = imageFps.value || 20; // 兜底默认值
      }
    }
  } catch (error) {
    console.error('解析ROS图片失败:', error);
    imageFps.value = 0;
  }
};

/**
 * 批量释放所有图片URL
 */
const releaseAllImageUrls = () => {
  imageUrlPool.forEach(url => {
    try {
      URL.revokeObjectURL(url);
    } catch (e) {
      console.warn('释放URL失败:', e);
    }
  });
  imageUrlPool.clear();
};

// ====================== 纹理加载函数(新增) ======================
/**
 * 加载纹理资源
 */
const loadTextures = async () => {
  return new Promise((resolve) => {
    // 加载草地纹理
    const grassDiffuseAsset = new pc.Asset('grassDiffuse', 'texture', { url: '/textures/grass/diffuse.jpg' });
    const grassNormalAsset = new pc.Asset('grassNormal', 'texture', { url: '/textures/grass/normal.jpg' });
    const grassRoughAsset = new pc.Asset('grassRough', 'texture', { url: '/textures/grass/rough.jpg' });
    
    // 加载道路纹理
    const asphaltDiffuseAsset = new pc.Asset('asphaltDiffuse', 'texture', { url: '/textures/asphalt/difuse.jpg' });
    const asphaltRoughAsset = new pc.Asset('asphaltRough', 'texture', { url: '/textures/asphalt/rough.jpg' });

    // 存储所有纹理资产
    const textureAssets = [
      grassDiffuseAsset, grassNormalAsset, grassRoughAsset,
      asphaltDiffuseAsset, asphaltRoughAsset
    ];

    // 添加到资产管理器
    textureAssets.forEach(asset => app.assets.add(asset));

    // 加载完成处理
    let loadedCount = 0;
    const onTextureLoaded = () => {
      loadedCount++;
      if (loadedCount === textureAssets.length) {
        // 缓存纹理引用
        grassDiffuseTexture = grassDiffuseAsset.resource;
        grassNormalTexture = grassNormalAsset.resource;
        grassRoughTexture = grassRoughAsset.resource;
        asphaltDiffuseTexture = asphaltDiffuseAsset.resource;
        asphaltRoughTexture = asphaltRoughAsset.resource;

        // ✅ 关键修复:开启纹理重复模式,解决拉伸
        if (grassDiffuseTexture) {
          grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
          grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (grassNormalTexture) {
          grassNormalTexture.addressU = pc.ADDRESS_REPEAT;
          grassNormalTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (grassRoughTexture) {
          grassRoughTexture.addressU = pc.ADDRESS_REPEAT;
          grassRoughTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (asphaltDiffuseTexture) {
          asphaltDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
          asphaltDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (asphaltRoughTexture) {
          asphaltRoughTexture.addressU = pc.ADDRESS_REPEAT;
          asphaltRoughTexture.addressV = pc.ADDRESS_REPEAT;
        }

        resolve();
      }
    };

    // 绑定加载事件
    textureAssets.forEach(asset => {
      asset.on('load', onTextureLoaded);
      asset.on('error', (err) => {
        console.warn(`纹理${asset.name}加载失败,使用默认颜色:`, err);
        onTextureLoaded(); // 继续流程
      });
      app.assets.load(asset);
    });
  });
};

// ====================== ROS WebSocket 初始化 ======================
/**
 * 初始化障碍物数据WebSocket
 */
const initObstacleWebSocket = () => {
  obstacleWsRetryCount = 0;
  
  if (obstacleWs) {
    obstacleWs.close();
    obstacleWs = null;
  }

  const wsUrl = 'ws://10.27.9.81:9090/obstacle_objs';
  obstacleWs = new WebSocket(wsUrl);

  obstacleWs.onopen = () => {
    console.log('障碍物WebSocket连接成功');
    obstacleWsRetryCount = 0;
    obstacleWs.send(JSON.stringify({
      op: 'subscribe',
      topic: '/obstacle_objs'
    }));
  };

  obstacleWs.onmessage = (event) => {
    try {
      const data = JSON.parse(event.data);
      if (!data.msg || !Array.isArray(data.msg.objs)) {
        console.warn('障碍物数据格式错误:', data);
        return;
      }

      const obstacleList = data.msg.objs;
      const currentValidIds = new Set();

      obstacleList.forEach(item => {
        const obstacleData = {
          id: item.track_id,
          type: item.type,
          position: { 
            x: item.x, 
            z: item.y 
          },
          size: { 
            x: item.width,  
            y: item.height, 
            z: item.length  
          },
          heading: item.heading,
          isActive: true
        };
        currentValidIds.add(obstacleData.id);
        handleObstacleData(obstacleData);
      });

      cleanupInvalidObstacles(currentValidIds);
    } catch (error) {
      console.error('解析障碍物数据失败:', error);
    }
  };

  obstacleWs.onerror = (error) => {
    console.error('障碍物WebSocket错误:', error);
  };

  obstacleWs.onclose = (event) => {
    if (event.code !== 1000 && obstacleWsRetryCount < WS_MAX_RETRY) {
      obstacleWsRetryCount++;
      const retryDelay = 1000 * obstacleWsRetryCount;
      console.log(`障碍物WebSocket断开,${retryDelay/1000}秒后重试(${obstacleWsRetryCount}/${WS_MAX_RETRY})`);
      setTimeout(initObstacleWebSocket, retryDelay);
    } else {
      console.log('障碍物WebSocket正常关闭或达到最大重试次数');
    }
  };
};

/**
 * 初始化ROS图片流WebSocket
 */
const initRosImageWebSocket = () => {
  imageWsRetryCount = 0;
  
  if (rosImageWs) {
    rosImageWs.close();
    rosImageWs = null;
  }

  const wsUrl = 'ws://10.27.9.81:9090/usb_cam/image_raw/compressed';
  rosImageWs = new WebSocket(wsUrl);
  rosImageWs.binaryType = 'arraybuffer';

  rosImageWs.onopen = () => {
    console.log('ROS图片流WebSocket连接成功');
    imageWsRetryCount = 0;
    rosImageWs.send(JSON.stringify({
      op: 'subscribe',
      topic: '/usb_cam/image_raw/compressed'
    }));
  };

  rosImageWs.onmessage = (event) => {
    try {
      let rawData = event.data;
      if (rawData instanceof ArrayBuffer) {
        rawData = new TextDecoder().decode(rawData);
      }

      const rosMsg = JSON.parse(rawData);
      const base64Image = rosMsg.msg?.data;
      
      if (!base64Image) {
        console.warn('ROS图片数据为空(单帧):', rosMsg);
        return;
      }

      const binaryStr = atob(base64Image);
      const bytes = new Uint8Array(binaryStr.length);
      for (let i = 0; i < binaryStr.length; i++) {
        bytes[i] = binaryStr.charCodeAt(i);
      }
      
      renderRosImage(bytes.buffer);
    } catch (error) {
      console.error('处理ROS图片数据失败:', error);
    }
  };

  rosImageWs.onerror = (error) => {
    console.error('ROS图片流WebSocket错误:', error);
  };

  rosImageWs.onclose = (event) => {
    if (event.code !== 1000 && imageWsRetryCount < WS_MAX_RETRY) {
      imageWsRetryCount++;
      const retryDelay = 1000 * imageWsRetryCount;
      console.log(`图片流WebSocket断开,${retryDelay/1000}秒后重试(${imageWsRetryCount}/${WS_MAX_RETRY})`);
      setTimeout(initRosImageWebSocket, retryDelay);
    } else {
      console.log('图片流WebSocket正常关闭或达到最大重试次数');
      releaseAllImageUrls();
    }
  };
};

// ====================== 障碍物数据处理 ======================
/**
 * 映射障碍物类型
 */
const mapTypeToModel = (type) => {
  const typeMap = {
    0: 'car',
    1: 'truck',
    5: 'barrier',
    8: 'person',
  };
  return typeMap[type] || 'unknown';
};

/**
 * 清理无效障碍物
 */
const cleanupInvalidObstacles = (validIds) => {
  Array.from(obstacleEntities.keys()).forEach(id => {
    if (!validIds.has(id)) {
      removeObstacle(id);
    }
  });
};

/**
 * 处理障碍物数据(仅显示道路内的障碍物)
 */
const handleObstacleData = (obstacleData) => {
  if (!obstacleData.id) return;
  
  // 过滤重复数据
  const existingData = obstacleRawData.get(obstacleData.id);
  if (existingData) {
    const posEqual = existingData.position.x === obstacleData.position.x && 
                     existingData.position.z === obstacleData.position.z;
    const typeEqual = existingData.type === obstacleData.type;
    if (posEqual && typeEqual) return;
  }
  
  obstacleRawData.set(obstacleData.id, obstacleData);

  // ROS坐标系向右旋转90度转换
  const playerPos = player.getPosition();
  const rosX = obstacleData.position.x;
  const rosY = obstacleData.position.z;

  const rotatedX = rosY;
  const rotatedZ = -rosX;

  const relativeX = -rotatedX - playerPos.x;
  const relativeZ = rotatedZ - playerPos.z;

  // 仅检查是否在道路范围内(X轴),忽略距离过滤
  const halfRoadWidth = ROAD_WIDTH / 2;
  const isInRoad = Math.abs(relativeX) <= halfRoadWidth;
  
  if (obstacleData.isActive && isInRoad) {
    updateObstacle(obstacleData, relativeX, relativeZ);
  } else {
    removeObstacle(obstacleData.id);
  }
};

/**
 * 创建默认障碍物模型
 */
const createDefaultObstacle = (parent) => {
  const defaultEntity = new pc.Entity('default-obstacle');
  defaultEntity.addComponent('model', { type: 'box' });
  
  const mat = new pc.StandardMaterial();
  mat.diffuse = new pc.Color(1, 0, 0);
  mat.roughness = 0.8;
  mat.update();
  
  defaultEntity.model.material = mat;
  defaultEntity.setLocalScale(1, 1, 1);
  parent.addChild(defaultEntity);
};

/**
 * 更新/创建障碍物实体
 */
const updateObstacle = (data, relativeX, relativeZ) => {
  let obstacleEntity = obstacleEntities.get(data.id);

  if (!obstacleEntity) {
    obstacleEntity = new pc.Entity(`obstacle-${data.id}`);
    obstacleEntity.collisionType = data.type;
    
    const modelType = mapTypeToModel(data.type);
    let modelCreated = false;

    switch (modelType) {
      case 'car': 
        if (carTemplateBlue && carTemplateBlack) {
          // 关键修复2:克隆模板时保留原始等比缩放
          const carEntity = Math.random() > 0.5 ? carTemplateBlue.clone() : carTemplateBlack.clone();
          // 强制重置缩放为等比基础值,避免继承异常缩放
          carEntity.setLocalScale(CAR_MODEL_SCALE, CAR_MODEL_SCALE, CAR_MODEL_SCALE / CAR_ASPECT_RATIO);
          obstacleEntity.addChild(carEntity);
          modelCreated = true;
        }
        break;
      case 'truck': 
        if (truckTemplate) {
          makeTruck(obstacleEntity);
          modelCreated = true;
        }
        break;
      case 'person': 
        if (personTemplate) {
          makePerson(obstacleEntity);
          modelCreated = true;
        }
        break;
      case 'barrier':
        if (barrierTemplate) {
            makeBarrier(obstacleEntity);
            modelCreated = true;
        }
        break;
      case 'unknown': 
        createDefaultObstacle(obstacleEntity);
        modelCreated = true;
        break;
    }

    if (!modelCreated) {
      createDefaultObstacle(obstacleEntity);
    }

    app.root.addChild(obstacleEntity);
    obstacleEntities.set(data.id, obstacleEntity);
    obstacleCount.value = obstacleEntities.size;
  }

  if (!obstacleEntity) {
    console.warn(`障碍物${data.id}创建失败`);
    return;
  }

  // 设置位置和缩放
  if(data.type === 1) {
    obstacleEntity.setPosition(relativeX, 3, relativeZ);
  } else {
    obstacleEntity.setPosition(relativeX, 0.1, relativeZ);
  }
  if (data.size) {
    const modelType = mapTypeToModel(data.type);
    if (modelType !== 'car') { // 轿车跳过尺寸缩放,保持固定比例
      const scaleX = Math.max(0.1, data.size.x || 1);
      const scaleY = Math.max(0.1, data.size.y || 1);
      const scaleZ = Math.max(0.1, data.size.z || 1);
      if(data.type === 8) {
          obstacleEntity.setLocalScale(scaleX, scaleY, scaleZ);
      }
    } else {
      // 轿车强制重置为等比缩放,忽略异常尺寸
      obstacleEntity.children[0].setLocalScale(
        CAR_MODEL_SCALE, 
        CAR_MODEL_SCALE, 
        CAR_MODEL_SCALE / CAR_ASPECT_RATIO
      );
    }
  }

  // 航向角补偿
  if (data.heading) {
    const compensatedHeading = data.heading - 180;
    obstacleEntity.setLocalEulerAngles(0, -compensatedHeading, 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 initMouseControls = () => {
  const canvas = canvasContainer.value;
  if (!canvas) return; // 增加空值判断
  
  // 鼠标按下
  canvas.addEventListener('mousedown', (e) => {
    e.preventDefault();
    
    // 双击检测
    if (e.button === 0) {
      clickCount++;
      if (clickCount === 1) {
        clickTimer = setTimeout(() => {
          clickCount = 0;
        }, 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';
    }
  });

  // 鼠标松开
  window.addEventListener('mouseup', (e) => {
    if (e.button === 0 || e.button === 2) {
      isMouseLeftDown = false;
      isMouseRightDown = false;
      if (canvas) {
      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) {
      cameraYaw -= deltaX * 0.5;
      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);
      
      // 限制角色在道路内移动
      const halfRoadWidth = ROAD_WIDTH / 2;
      playerPos.x = Math.max(-halfRoadWidth, Math.min(halfRoadWidth, 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();
    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();
  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);
};

// ====================== 模型加载 ======================
/**
 * 加载角色模型
 */
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(0.5, 0.5, 0.5);
      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
        });
        
        // 关键修复1:等比缩放,锁定宽高比
        carEntity.setLocalScale(CAR_MODEL_SCALE, CAR_MODEL_SCALE, CAR_MODEL_SCALE / CAR_ASPECT_RATIO);

        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));
      console.log('轿车模型加载完成(等比缩放)');
      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(0.6, 0.6, 0.6);

      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.0, 1.0, 1.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.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 loadTreeModel = async () => {
  return new Promise((resolve, reject) => {
    const modelUrl = new URL('/download/tree/christmastree.glb', import.meta.url).href;
    const asset = new pc.Asset(
      'christmastree',
      'model',
      { url: modelUrl },
      { preload: true }
    );

    app.assets.add(asset);

    asset.on('load', () => {
      const treeEntity = new pc.Entity('tree-template');
      treeEntity.addComponent('model', {
        type: 'asset',
        asset: asset,
        castShadows: true,
        receiveShadows: true
      });
      
      // 调整模型旋转
      treeEntity.setLocalEulerAngles(-90, 0, 0);
      treeEntity.setLocalScale(1.6, 1.6, 1.6);

      // 手动设置材质颜色
      setTimeout(() => {
        if (treeEntity.model && treeEntity.model.meshInstances) {
          treeEntity.model.meshInstances.forEach((mi, index) => {
            if (mi.material) {
              // 强制启用材质渲染
              mi.material.useLighting = true;
              mi.material.diffuseMap = mi.material.diffuseMap || null;
              mi.material.specular = new pc.Color(0.1, 0.1, 0.1);
              mi.material.roughness = 0.4;
              mi.material.metalness = 0.0;

              // 区分树干和树枝,设置不同颜色
              if (index === 0) {
                // 树干:棕色系
                mi.material.diffuse = new pc.Color(0.4, 0.25, 0.1);
                mi.material.emissive = new pc.Color(0.05, 0.03, 0.01);
              } else {
                // 树枝:绿色系
                mi.material.diffuse = new pc.Color(0.15, 0.6, 0.2);
                mi.material.emissive = new pc.Color(0.02, 0.08, 0.03);
              }

              mi.material.update();
            }
          });
        }

        // 递归处理子节点的材质
        const updateChildMaterials = (entity) => {
          if (entity.children.length === 0) return;
          entity.children.forEach(child => {
            if (child.model && child.model.meshInstances) {
              child.model.meshInstances.forEach((mi, index) => {
                if (mi.material) {
                  mi.material.useLighting = true;
                  if (index === 0) {
                    mi.material.diffuse = new pc.Color(0.4, 0.25, 0.1);
                    mi.material.emissive = new pc.Color(0.05, 0.03, 0.01);
                  } else {
                    mi.material.diffuse = new pc.Color(0.15, 0.6, 0.2);
                    mi.material.emissive = new pc.Color(0.02, 0.08, 0.03);
                  }
                  mi.material.roughness = 0.4;
                  mi.material.metalness = 0.0;
                  mi.material.update();
                }
              });
            }
            updateChildMaterials(child);
          });
        };

        updateChildMaterials(treeEntity);
      }, 200);

      treeTemplate = treeEntity;
      resolve();
    });

    asset.on('error', (err) => {
      console.error('加载圣诞树模型失败:', err);
      alert(`模型加载失败:${asset.file.url}\n错误:${err.message}`);
      alert('树木模型加载失败,请检查文件路径和格式!');
      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, 2, 0); // 货车模型的第2个值之前为2,现在改模型后改为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 makeTree = (parent, position) => {
  if (!treeTemplate) return null; // 返回null避免后续报错
  const treeEntity = treeTemplate.clone();
  treeEntity.setPosition(position.x, position.y, position.z);
  parent.addChild(treeEntity);
  return treeEntity;
};

// ====================== 场景创建(核心修改) ======================
/**
 * 创建相机
 */
const createCamera = () => {
  camera = new pc.Entity('camera');
  camera.addComponent('camera', { clearColor: new pc.Color(0.8, 0.8, 0.9), fov: 75 });
  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,
    shadowBias: 0.001,
    shadowNormalBias: 0.1,
    enabled: true
  });
  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),
    enabled: true
  });
  app.root.addChild(ambient);
};

/**
 * 创建地面和道路(修复纹理拉伸 + 修复报错)
 */
const createGround = () => {
  if (!app) return;
  
  // 1. 草地
  const outerGround = new pc.Entity('outer-ground');
  outerGround.addComponent('model', { type: 'box' });
  outerGround.setLocalScale(400, 0.1, ROAD_LENGTH);
  outerGround.setPosition(0, -0.1, 0);

  const outerMat = new pc.StandardMaterial();
  if (grassDiffuseTexture) {
    outerMat.diffuseMap = grassDiffuseTexture;
    outerMat.normalMap = grassNormalTexture;
    outerMat.roughnessMap = grassRoughTexture;
    outerMat.diffuse = new pc.Color(1,1,1);

    // ✅ 安全调用 tiling
    if (outerMat.diffuseMapTiling) outerMat.diffuseMapTiling.set(40, 40);
    if (outerMat.normalMapTiling) outerMat.normalMapTiling.set(40, 40);
    if (outerMat.roughnessMapTiling) outerMat.roughnessMapTiling.set(40, 40);
  } else {
    outerMat.diffuse = GROUND_COLOR;
  }
  outerMat.roughness = 0.9;
  outerMat.useLighting = true;
  outerMat.update();
  outerGround.model.material = outerMat;
  app.root.addChild(outerGround);

  // 2. 道路
  const road = new pc.Entity('road');
  road.addComponent('model', { type: 'box' });
  road.setLocalScale(ROAD_WIDTH, 0.06, ROAD_LENGTH);
  road.setPosition(0, -0.05, 0);

  const roadMat = new pc.StandardMaterial();
  if (asphaltDiffuseTexture) {
    roadMat.diffuseMap = asphaltDiffuseTexture;
    roadMat.roughnessMap = asphaltRoughTexture;
    roadMat.diffuse = new pc.Color(1,1,1);

    // ✅ 安全调用 tiling
    if (roadMat.diffuseMapTiling) roadMat.diffuseMapTiling.set(6, 60);
    if (roadMat.roughnessMapTiling) roadMat.roughnessMapTiling.set(6, 60);
  } else {
    roadMat.diffuse = ROAD_COLOR;
  }
  roadMat.roughness = 0.8;
  roadMat.depthWrite = true;
  roadMat.useLighting = true;
  roadMat.update();
  road.model.material = roadMat;
  app.root.addChild(road);

  // 3. 道路白线
  const createRoadLine = (xOffset) => {
    const line = new pc.Entity('road-line');
    line.addComponent('model', { type: 'box' });
    line.setLocalScale(0.2, 0.07, ROAD_LENGTH);
    line.setPosition(xOffset, -0.04, 0);

    const lineMat = new pc.StandardMaterial();
    lineMat.diffuse = new pc.Color(1, 1, 1);
    lineMat.roughness = 0.5;
    lineMat.update();
    line.model.material = lineMat;
    app.root.addChild(line);
  };
  const halfRoadWidth = ROAD_WIDTH / 2;
  createRoadLine(halfRoadWidth - 0.1);
  createRoadLine(-halfRoadWidth + 0.1);

  createRoadTrees();
};

/**
 * 创建道路两侧的树木(支持滚动)
 */
const createRoadTrees = () => {
  if (!app || !treeTemplate) return;
  
  treeEntities.forEach(tree => {
    try {
      app.root.removeChild(tree);
      tree.destroy();
    } catch (e) {}
  });
  treeEntities = [];

  const startZ = -ROAD_LENGTH;
  const endZ = ROAD_LENGTH;

  for (let z = startZ; z <= endZ; z += TREE_SPACING) {
    const treeEntity = makeTree(app.root, { x: -TREE_OFFSET, y: 0, z });
    if (treeEntity) {
      treeEntity.originalZ = z;
      treeEntities.push(treeEntity);
    }
  }
  for (let z = startZ + TREE_SPACING/2; z <= endZ; z += TREE_SPACING) {
    const treeEntity = makeTree(app.root, { x: TREE_OFFSET, y: 0, z });
    if (treeEntity) {
      treeEntity.originalZ = z;
      treeEntities.push(treeEntity);
    }
  }
};
/**
 * 新增:更新树木位置实现滚动效果
 */
const updateTreePositions = () => {
  if (treeEntities.length === 0) return;
  
  const halfLength = ROAD_LENGTH / 2;
  
  treeEntities.forEach(tree => {
    try {
      const currentZ = tree.originalZ + treeOffsetZ;
      let newZ = currentZ;
      if (currentZ > halfLength) {
        newZ = currentZ - ROAD_LENGTH * 2;
      } else if (currentZ < -halfLength) {
        newZ = currentZ + ROAD_LENGTH * 2;
      }
      
      tree.setPosition(tree.getPosition().x, tree.getPosition().y, newZ);
    } catch (e) {}
  });
};

/**
 * 创建玩家
 */
const createPlayer = () => {
  player = new pc.Entity('player');
  makeDog(player);
  player.setPosition(0, 0.1, 0);
  app.root.addChild(player);
  playerX.value = 0;
};

// ====================== 键盘控制 + 树木滚动 ======================
const listenKeyboard = () => {
  if (!app) return;
  
  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();
    const halfRoadWidth = ROAD_WIDTH / 2;
    
    if (left) playerPos.x = Math.max(-halfRoadWidth, playerPos.x - speed * dt);
    if (right) playerPos.x = Math.min(halfRoadWidth, playerPos.x + speed * dt);
    
    player.setPosition(playerPos);
    playerX.value = playerPos.x;
    
    treeOffsetZ += TREE_SCROLL_SPEED * dt;
    updateTreePositions();
    updateCameraPosition();

    // 刷新障碍物位置
    Array.from(obstacleEntities.entries()).forEach(([id, entity]) => {
      const originalData = obstacleRawData.get(id) || {};
      if (originalData.position) {
        const rosX = originalData.position.x;
        const rosY = originalData.position.z;
        
        const rotatedX = rosY;
        const rotatedZ = -rosX;
        
        const relativeX = rotatedX - playerPos.x;
        const relativeZ = rotatedZ - playerPos.z;
        
        entity.setPosition(-relativeX, 0, relativeZ);
      }
    });
  });
};

// ====================== 应用初始化 ======================
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
  });

  // 场景兜底初始化
  if (!app.scene) app.scene = new pc.Scene(app.graphicsDevice);
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
  
  // 加载纹理
  await loadTextures();

  app.scene.background = new pc.Color(0.8, 0.8, 0.9);

  // 加载模型
  await Promise.all([
    loadDogModel(),
    loadCarModels(),
    loadTruckModel(),
    loadPersonModel(),
    loadBarrierModel(),
    loadTreeModel() // 加载树木模型
  ]);
  
  // 创建场景元素
  createCamera();
  
  // 创建灯光
  createLight();

  createGround(); // 调用修改后的道路创建函数(包含树木)
  createPlayer();
  
  // 初始化交互
  initMouseControls();
  listenKeyboard();
  
  // 初始化WebSocket
  initObstacleWebSocket();
  initRosImageWebSocket();

  app.start();
  // 启动经纬度模拟
  startMapSimulation();

  // 窗口自适应
  window.addEventListener('resize', () => {
    app?.resizeCanvas(window.innerWidth, window.innerHeight);
  });
};

// 只更新经纬度 → 只让小地图动
function updateMapLatLng() {
  // 循环取轨迹点
  pointIndex = (pointIndex + 1) % latLngTrack.length;
  const p = latLngTrack[pointIndex];

  currentLat.value = p.lat;
  currentLng.value = p.lng;
  centerLat.value = p.lat;
  centerLng.value = p.lng;
//   console.log("updateMapLatLng:", centerLat.value, centerLng.value)
}

// 启动小地图模拟(安全版,不影响主游戏)
function startMapSimulation() {
  if (mapTimer) clearInterval(mapTimer);
  mapTimer = setInterval(updateMapLatLng, UPDATE_INTERVAL);
  console.log("✅ 小地图经纬度模拟已启动");

  // 定时器循环更新经纬度
  // mapTimer = setInterval(() => {
  //   if (!latLngTrack || latLngTrack.length === 0) return;
    
  //   // 更新到下一个轨迹点
  //   pointIndex = (pointIndex + 1) % latLngTrack.length;
  //   const currentPoint = latLngTrack[pointIndex];
    
  //   // 更新当前经纬度(触发小地图组件更新)
  //   currentLat.value = currentPoint.lat;
  //   currentLng.value = currentPoint.lng;

  // }, UPDATE_INTERVAL);
}

// 清理定时器
function stopMapSimulation() {
  console.log("✅ 小地图经纬度模拟已清理定时器");
  if (mapTimer) {
    clearInterval(mapTimer);
    mapTimer = null;
  }
}

// ====================== 生命周期 ======================
onMounted(() => {
  initApp();
});

onUnmounted(() => {
  // 清理双击定时器
  clearTimeout(clickTimer);
  
  // 关闭WebSocket连接
  if (obstacleWs) {
    obstacleWs.close();
    obstacleWs = null;
  }
  
  if (rosImageWs) {
    rosImageWs.close();
    rosImageWs = null;
  }

  // 释放图片URL
  releaseAllImageUrls();

  // 停止经纬度模拟定时器
  stopMapSimulation();

  // 清理图片元素
  if (rosImage.value && rosImage.value.src) {
    URL.revokeObjectURL(rosImage.value.src);
    rosImage.value.src = '';
  }

  // 销毁PlayCanvas应用
  if (app) {
    app.stop();
    app.destroy();
    app = null;
  }

  // 清理障碍物
  obstacleEntities.forEach(entity => {
    app?.root.removeChild(entity);
    entity.destroy();
  });
  obstacleEntities.clear();
  obstacleRawData.clear();

  // 清理树木
  treeEntities.forEach(tree => {
    app?.root.removeChild(tree);
    tree.destroy();
  });
  treeEntities = [];

  // 清理事件监听
  const canvas = canvasContainer.value;
  if (canvas) {
    canvas.removeEventListener('mousedown', () => {});
    canvas.removeEventListener('wheel', () => {});
    canvas.removeEventListener('contextmenu', () => {});
    canvas.removeEventListener('mouseout', () => {});
  }
  window.removeEventListener('mouseup', () => {});
  window.removeEventListener('mousemove', () => {});
  window.removeEventListener('resize', () => {});
});
</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;
}

.ros-image-panel {
  position: absolute;
  top: 3px;
  left: 3px;
  background: rgba(255,255,255,0.92);
  padding: 10px;
  border-radius: 10px;
  z-index: 10;
}

.ros-image-panel h4 {
  margin: 0 0 5px 0;
  font-size: 14px;
  text-align: center;
}

.ros-image {
  width: 480px;
  height: 320px;
  object-fit: cover;
  border-radius: 5px;
  border: 1px solid #ccc;
  transition: opacity 0.1s ease-in-out;
}

:deep(.leaflet-container) {
  width: 320px;
  height: 320px;
  border-radius: 50%;
  overflow: hidden;
}
</style>

六、踩坑实录------纹理拉伸问题

6.1、问题解决

纹理拉伸、模糊、变形、很难看,修复纹理拉伸,最小改动:

  1. 纹理设置为 重复模式(REPEAT)
  2. 给地面 / 道路加 tiling 平铺系数(让纹理重复很多次,不再拉伸)
js 复制代码
        // 缓存纹理引用
        grassDiffuseTexture = grassDiffuseAsset.resource;
        grassNormalTexture = grassNormalAsset.resource;
        grassRoughTexture = grassRoughAsset.resource;
        asphaltDiffuseTexture = asphaltDiffuseAsset.resource;
        asphaltRoughTexture = asphaltRoughAsset.resource;
     
        // ✅ 关键修复:开启纹理重复模式,解决拉伸
        if (grassDiffuseTexture) {
          grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
          grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (grassNormalTexture) {
          grassNormalTexture.addressU = pc.ADDRESS_REPEAT;
          grassNormalTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (grassRoughTexture) {
          grassRoughTexture.addressU = pc.ADDRESS_REPEAT;
          grassRoughTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (asphaltDiffuseTexture) {
          asphaltDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
          asphaltDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
        }
        if (asphaltRoughTexture) {
          asphaltRoughTexture.addressU = pc.ADDRESS_REPEAT;
          asphaltRoughTexture.addressV = pc.ADDRESS_REPEAT;
        }

6.2、效果对比

平铺效果:

拉伸效果:

相关推荐
前端 贾公子3 小时前
uniapp中@input修改input内容不生效 | 过滤赋值无效 | 连续非法字符不更新的问题
开发语言·前端·javascript
写不来代码的草莓熊3 小时前
el-date-picker ,自定义输入数字自动转换显示yyyy-mm-dd HH:mm:ss格式 【仅双日历 datetimerange专用】
开发语言·前端·javascript
绺年3 小时前
关于 mac 使用ssh配置
前端
LDX前端校草3 小时前
verdaccio数据迁移
前端
炸炸鱼.3 小时前
LVS-DR 群集部署
前端·chrome·lvs
Ava的硅谷新视界3 小时前
TypeScript 中用判别联合类型替代 instanceof 检查
前端·javascript·typescript
ZC跨境爬虫3 小时前
海南大学交友平台开发实战 day9(头像上传存入 SQLite+BLOB 存储 + 前后端联调避坑全记录)
前端·数据库·python·sqlite
落魄江湖行4 小时前
基础篇六 Nuxt4 状态管理:useState 的正确用法
前端·vue.js·typescript·nuxt4
jerrywus4 小时前
手机控制 AI 编程?Paseo 让你随时随地跑 Claude Code / Codex
前端·agent·claude