【Web】使用Vue3+PlayCanvas开发3D游戏(二)3D 地图自由巡视闯关游戏

【Web】使用Vue3+PlayCanvas开发3D游戏(二)3D 地图自由巡视闯关游戏

文章目录

一、效果

在这里插入图片描述

二、简介

在上一篇博客《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(一)3D 立方体交互式游戏》中,我们初步掌握了 PlayCanvas 的基础使用、Vue3 与 PlayCanvas 的结合方式,以及简单 3D 交互的实现逻辑。本文将在此基础上,进一步拓展开发难度,实现一款具备多关卡地图、角色移动、摄像机控制、小地图、闯关重置等核心功能的 3D 地图自由巡视闯关游戏,完整覆盖从基础交互到游戏化场景的开发全流程。

三、环境

  • OS:Windows11
  • Browser:Google
  • Node:v24.14.0
  • NPM:11.9.0
  • Vue:3.5.25
  • Vite:7.3.1

四、步骤

4.1、项目部署

创建项目和安装PlayCanvas我就不赘述了,想知道的,参考我写的上一篇《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(一)3D 立方体交互式游戏》文章

4.2、游戏功能规划

本次开发的 3D 地图闯关游戏需实现以下核心功能:

  1. 基础交互:方向键(↑↓←→)控制角色移动,WASD 键控制摄像机旋转;
  2. 视觉定制:自定义场景背景、地面、障碍物颜色(障碍物灰色、背景白色、每关地面差异化);
  3. 关卡系统:至少 3 张差异化地图,到达终点后按回车键进入下一关;
  4. 辅助功能:小地图实时显示角色、起点、终点、障碍物位置;
  5. 碰撞检测:角色与障碍物、地图边界碰撞,防止穿模;
  6. 通关判定:角色到达终点触发通关提示,支持回车重置关卡。

4.3、脚本逻辑(Script Setup)

脚本部分是游戏的核心,包含 PlayCanvas 初始化、关卡配置、角色移动、摄像机控制、碰撞检测、小地图绘制、通关判定等关键逻辑。

<scriptsetup>import{ ref, onMounted, onUnmounted, nextTick }from'vue'import*as pc from'playcanvas'// 游戏状态管理const currentLevel =ref(1)// 当前关卡const isWon =ref(false)// 是否通关let app =null// PlayCanvas应用实例let player =null// 玩家实体let startPoint =null// 起点实体let endPoint =null// 终点实体let cameraEntity =null// 摄像机实体let globalEnterHandler =null// 全局回车监听(兜底)// 基础配置const moveSpeed =8// 角色移动速度const cameraRotateSpeed =60// 摄像机旋转速度(度/秒)let cameraYaw =0// 摄像机水平旋转角(Y轴)let cameraPitch =-30// 摄像机垂直旋转角(X轴)const cameraDistance =15// 摄像机到玩家的距离// 小地图相关let minimapCanvas =nulllet minimapCtx =nullconst minimapScale =5// 小地图缩放比例let obstacles =[]// 障碍物信息(碰撞+小地图)// ========== 1. 多关卡配置 ==========const levelConfigs =[// 第1关:20x20地面,3个障碍物,绿色地面{groundSize:20,startPos:newpc.Vec3(-8,0.5,-8),endPos:newpc.Vec3(8,0.5,8),obstacleCount:3,groundColor:[0.3,0.7,0.3],// 绿色地面obstacleColor:[0.5,0.5,0.5],// 中灰色障碍物sceneBgColor:[1,1,1]// 白色背景},// 第2关:25x25地面,5个障碍物,青绿色地面{groundSize:25,startPos:newpc.Vec3(-10,0.5,0),endPos:newpc.Vec3(10,0.5,0),obstacleCount:5,groundColor:[0.3,0.7,0.7],// 青绿色地面obstacleColor:[0.6,0.6,0.6],// 浅灰色障碍物sceneBgColor:[1,1,1]},// 第3关:30x30地面,7个障碍物,黄绿色地面{groundSize:30,startPos:newpc.Vec3(0,0.5,-12),endPos:newpc.Vec3(0,0.5,12),obstacleCount:7,groundColor:[0.7,0.7,0.3],// 黄绿色地面obstacleColor:[0.4,0.4,0.4],// 深灰色障碍物sceneBgColor:[1,1,1]}]// ========== 2. 核心方法 ==========/** * 下一关逻辑(抽离为独立方法,便于复用) */constgoToNextLevel=()=>{if(isWon.value){ console.log('进入下一关,当前关卡:', currentLevel.value)// 关卡循环(超过配置数回到第一关) currentLevel.value = currentLevel.value % levelConfigs.length +1 isWon.value =falsegenerateNewLevel()// 重新聚焦画布,确保键盘事件生效 document.getElementById('pc-canvas').focus()}}/** * 初始化PlayCanvas应用 */asyncfunctioninitPlayCanvas(){try{awaitnextTick()const canvas = document.getElementById('pc-canvas')if(!canvas){ console.error('Canvas元素未找到!')return}// 关键:确保Canvas获取焦点(解决键盘事件不触发) canvas.focus() canvas.addEventListener('click',()=> canvas.focus())// 初始化小地图initMinimap()// 1. 创建图形设备(禁用物理引擎,降低依赖)const graphicsDevice =newpc.WebglGraphicsDevice(canvas,{antialias:true,powerPreference:"high-performance"}) graphicsDevice.maxPixelRatio = window.devicePixelRatio // 2. 创建PlayCanvas应用实例 app =newpc.Application(canvas,{graphicsDevice: graphicsDevice,mouse:newpc.Mouse(canvas),keyboard:newpc.Keyboard(window),createCanvas:false,physics:false// 禁用物理引擎,纯数学碰撞检测})// 3. 启动应用await app.start()// 4. 配置场景if(!app.scene) app.scene =newpc.Scene(app.graphicsDevice) app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW) app.setCanvasResolution(pc.RESOLUTION_AUTO)// 生成第一关地图generateNewLevel()// 注册帧更新循环 app.on('update', update)// ========== 键盘监听(双重保障) ==========// 1. PlayCanvas内置监听 app.keyboard.off(pc.KEY_ENTER, goToNextLevel) app.keyboard.on(pc.KEY_ENTER, goToNextLevel)// 数字码兜底(兼容不同PlayCanvas版本) app.keyboard.off(13, goToNextLevel) app.keyboard.on(13, goToNextLevel)// 2. 全局键盘监听(防止内置监听失效)globalEnterHandler=(e)=>{if(e.key ==='Enter'|| e.keyCode ===13){ e.preventDefault()// 阻止默认行为(如页面刷新)goToNextLevel()}} window.addEventListener('keydown', globalEnterHandler)// 窗口大小适配 window.addEventListener('resize',()=>{if(app && app.graphicsDevice){ app.resizeCanvas(window.innerWidth, window.innerHeight) app.graphicsDevice.resize(window.innerWidth, window.innerHeight)}}) app.resizeCanvas(window.innerWidth, window.innerHeight)}catch(error){ console.error('PlayCanvas初始化失败:', error)}}/** * 初始化小地图 */functioninitMinimap(){ minimapCanvas = document.getElementById('minimap-canvas') minimapCtx = minimapCanvas.getContext('2d')}/** * 绘制小地图 */functiondrawMinimap(){if(!minimapCtx ||!player ||!startPoint ||!endPoint)return// 清空小地图 minimapCtx.clearRect(0,0, minimapCanvas.width, minimapCanvas.height) minimapCtx.fillStyle ='#222' minimapCtx.fillRect(0,0, minimapCanvas.width, minimapCanvas.height)const centerX = minimapCanvas.width /2const centerY = minimapCanvas.height /2// 绘制障碍物 minimapCtx.fillStyle ='#666' obstacles.forEach(obs=>{const x = centerX + obs.pos.x * minimapScale const y = centerY + obs.pos.z * minimapScale const w = obs.scale.x * minimapScale const h = obs.scale.z * minimapScale minimapCtx.fillRect(x - w/2, y - h/2, w, h)})// 绘制起点(红色) minimapCtx.fillStyle ='#ff0000'const startX = centerX + startPoint.getPosition().x * minimapScale const startY = centerY + startPoint.getPosition().z * minimapScale minimapCtx.beginPath() minimapCtx.arc(startX, startY,4,0, Math.PI*2) minimapCtx.fill()// 绘制终点(绿色) minimapCtx.fillStyle ='#00ff00'const endX = centerX + endPoint.getPosition().x * minimapScale const endY = centerY + endPoint.getPosition().z * minimapScale minimapCtx.beginPath() minimapCtx.arc(endX, endY,4,0, Math.PI*2) minimapCtx.fill()// 绘制玩家(蓝色) minimapCtx.fillStyle ='#0000ff'const playerX = centerX + player.getPosition().x * minimapScale const playerY = centerY + player.getPosition().z * minimapScale minimapCtx.beginPath() minimapCtx.arc(playerX, playerY,5,0, Math.PI*2) minimapCtx.fill()// 绘制玩家朝向 minimapCtx.strokeStyle ='#ffffff' minimapCtx.lineWidth =2 minimapCtx.beginPath() minimapCtx.moveTo(playerX, playerY) minimapCtx.lineTo( playerX + Math.sin(player.getEulerAngles().y * Math.PI/180)*8, playerY + Math.cos(player.getEulerAngles().y * Math.PI/180)*8) minimapCtx.stroke()}/** * 碰撞检测(纯数学计算,无物理引擎依赖) * @param {pc.Vec3} nextPos 玩家下一步位置 * @returns {boolean} 是否碰撞 */functioncheckCollision(nextPos){if(!player || obstacles.length ===0)returnfalseconst playerRadius =0.5// 玩家碰撞半径// 1. 检测与障碍物碰撞for(let i =0; i < obstacles.length; i++){const obs = obstacles[i]const obsMinX = obs.pos.x - obs.scale.x /2const obsMaxX = obs.pos.x + obs.scale.x /2const obsMinZ = obs.pos.z - obs.scale.z /2const obsMaxZ = obs.pos.z + obs.scale.z /2const playerMinX = nextPos.x - playerRadius const playerMaxX = nextPos.x + playerRadius const playerMinZ = nextPos.z - playerRadius const playerMaxZ = nextPos.z + playerRadius const xOverlap =(playerMaxX > obsMinX)&&(playerMinX < obsMaxX)const zOverlap =(playerMaxZ > obsMinZ)&&(playerMinZ < obsMaxZ)if(xOverlap && zOverlap)returntrue}// 2. 检测与地图边界碰撞const currentConfig = levelConfigs[currentLevel.value -1]const mapHalfSize = currentConfig.groundSize /2if(Math.abs(nextPos.x)> mapHalfSize -1|| Math.abs(nextPos.z)> mapHalfSize -1){returntrue}returnfalse}/** * 生成新关卡地图 */functiongenerateNewLevel(){ obstacles =[]// 清空现有场景if(app && app.root && app.root.children.length >0){const children =[...app.root.children] children.forEach(child=> child.destroy())}// 获取当前关卡配置const config = levelConfigs[currentLevel.value -1]|| levelConfigs[0]// 设置场景背景色(白色) app.scene.background =newpc.Color(...config.sceneBgColor)// 1. 创建地面(每关不同颜色)const ground =newpc.Entity('ground') ground.addComponent('model',{type:'box',castShadows:true}) ground.setLocalScale(config.groundSize,0.1, config.groundSize) ground.setLocalPosition(0,0,0) ground.model.material =createMaterial(...config.groundColor) app.root.addChild(ground)// 2. 生成障碍物(灰色系)for(let i =0; i < config.obstacleCount; i++){// 随机位置(避开起点/终点)let posX, posZ let validPos =falsewhile(!validPos){ posX =getRandomNum(-config.groundSize/2+2, config.groundSize/2-2) posZ =getRandomNum(-config.groundSize/2+2, config.groundSize/2-2)const distToStart = Math.hypot(posX - config.startPos.x, posZ - config.startPos.z)const distToEnd = Math.hypot(posX - config.endPos.x, posZ - config.endPos.z)if(distToStart >3&& distToEnd >3) validPos =true}// 随机尺寸const scaleX =getRandomNum(1,4)const scaleZ =getRandomNum(1,4)const obstacle =newpc.Entity(`obstacle-${i}`) obstacle.addComponent('model',{type:'box',castShadows:true}) obstacle.setLocalPosition(posX, scaleX/2, posZ) obstacle.setLocalScale(scaleX, scaleX, scaleZ) obstacle.model.material =createMaterial(...config.obstacleColor) app.root.addChild(obstacle)// 存储障碍物信息(用于碰撞+小地图) obstacles.push({pos:{x: posX,z: posZ },scale:{x: scaleX,z: scaleZ }})}// 3. 创建起点(红色球体) startPoint =newpc.Entity('start-point') startPoint.addComponent('model',{type:'sphere',castShadows:true}) startPoint.setLocalPosition(config.startPos) startPoint.setLocalScale(0.5,0.5,0.5) startPoint.model.material =createMaterial(1,0,0) app.root.addChild(startPoint)// 4. 创建终点(绿色球体) endPoint =newpc.Entity('end-point') endPoint.addComponent('model',{type:'sphere',castShadows:true}) endPoint.setLocalPosition(config.endPos) endPoint.setLocalScale(0.5,0.5,0.5) endPoint.model.material =createMaterial(0,1,0) app.root.addChild(endPoint)// 5. 创建玩家(蓝色胶囊体) player =newpc.Entity('player') player.addComponent('model',{type:'capsule',castShadows:true}) player.setLocalPosition(config.startPos) player.setLocalScale(0.5,1,0.5) player.model.material =createMaterial(0,0,1) app.root.addChild(player)// 6. 创建摄像机 cameraEntity =newpc.Entity('camera') cameraEntity.addComponent('camera',{clearColor:newpc.Color(...config.sceneBgColor),gammaCorrection: pc.GAMMA_SRGB,toneMapping: pc.TONEMAP_LINEAR})updateCameraPosition() cameraEntity.lookAt(player.getPosition()) app.root.addChild(cameraEntity)// 绘制初始小地图drawMinimap()}/** * 更新摄像机位置(绕玩家旋转) */functionupdateCameraPosition(){if(!cameraEntity ||!player)returnconst yawRad = cameraYaw * Math.PI/180const pitchRad = cameraPitch * Math.PI/180// 球面坐标转笛卡尔坐标,计算摄像机位置const x = Math.sin(yawRad)* Math.cos(pitchRad)* cameraDistance const z = Math.cos(yawRad)* Math.cos(pitchRad)* cameraDistance const y = Math.sin(pitchRad)* cameraDistance +8const playerPos = player.getPosition() cameraEntity.setPosition(playerPos.x + x, playerPos.y + y, playerPos.z + z) cameraEntity.lookAt(playerPos)}/** * 随机数工具函数 * @param {number} min 最小值 * @param {number} max 最大值 * @returns {number} 随机数 */functiongetRandomNum(min, max){return Math.random()*(max - min)+ min }/** * 创建材质 * @param {number} r 红(0-1) * @param {number} g 绿(0-1) * @param {number} b 蓝(0-1) * @returns {pc.StandardMaterial} 材质实例 */functioncreateMaterial(r, g, b){const material =newpc.StandardMaterial() material.diffuse.set(r, g, b) material.emissive.set(r *0.2, g *0.2, b *0.2)// 轻微自发光,增强视觉 material.update()return material }/** * 帧更新循环(核心交互逻辑) * @param {number} dt 帧间隔时间 */functionupdate(dt){if(isWon.value ||!app ||!player ||!endPoint ||!cameraEntity)returnconst keyboard = app.keyboard // 1. WASD控制摄像机旋转if(keyboard.isPressed(pc.KEY_A)) cameraYaw += cameraRotateSpeed * dt if(keyboard.isPressed(pc.KEY_D)) cameraYaw -= cameraRotateSpeed * dt if(keyboard.isPressed(pc.KEY_W)){ cameraPitch += cameraRotateSpeed * dt cameraPitch = Math.min(cameraPitch,10)// 限制最大仰角}if(keyboard.isPressed(pc.KEY_S)){ cameraPitch -= cameraRotateSpeed * dt cameraPitch = Math.max(cameraPitch,-60)// 限制最大俯角}updateCameraPosition()// 2. 方向键控制角色移动const moveStep = moveSpeed * dt const currentPos = player.getPosition()let newPos =newpc.Vec3(currentPos.x, currentPos.y, currentPos.z)if(keyboard.isPressed(pc.KEY_UP)) newPos.z -= moveStep if(keyboard.isPressed(pc.KEY_DOWN)) newPos.z += moveStep if(keyboard.isPressed(pc.KEY_LEFT)) newPos.x -= moveStep if(keyboard.isPressed(pc.KEY_RIGHT)) newPos.x += moveStep // 无碰撞则移动if(!checkCollision(newPos)) player.setPosition(newPos)// 3. 实时更新小地图drawMinimap()// 4. 通关判定(调大阈值,更容易触发)const distanceToEnd = Math.hypot( player.getPosition().x - endPoint.getPosition().x, player.getPosition().z - endPoint.getPosition().z )if(distanceToEnd <2.5){ isWon.value =true console.log('已到达终点,isWon:', isWon.value)}}// ========== 3. 生命周期 ==========onMounted(async()=>{awaitinitPlayCanvas()})onUnmounted(()=>{// 清理PlayCanvas资源if(app){ app.off('update', update)if(app.keyboard){ app.keyboard.off(pc.KEY_ENTER, goToNextLevel) app.keyboard.off(13, goToNextLevel)} window.removeEventListener('resize',()=>{}) app.destroy() app =null}// 清理全局键盘监听if(globalEnterHandler){ window.removeEventListener('keydown', globalEnterHandler) globalEnterHandler =null}})</script>

4.4、功能解析

4.4.1、多关卡配置与生成

通过levelConfigs数组定义每关的差异化参数(地面尺寸、起点 / 终点位置、障碍物数量、颜色等),generateNewLevel函数根据当前关卡配置动态生成场景,实现关卡的差异化展示。

4.4.2、角色移动与碰撞检测

  • 角色移动:通过监听方向键输入,计算玩家下一步位置,结合checkCollision函数判断是否碰撞,无碰撞则更新玩家位置;
  • 碰撞检测:纯数学计算实现(无需物理引擎),检测玩家与障碍物、地图边界的重叠,避免穿模问题。

4.4.3、摄像机控制

摄像机采用 “绕玩家旋转” 的第三人称视角,通过 WASD 键控制水平 / 垂直旋转角,结合球面坐标转笛卡尔坐标的公式,实时更新摄像机位置,保证视角始终跟随玩家。

4.4.4、小地图实现

基于 2D Canvas 绘制小地图,核心逻辑:

  • 以小地图中心为原点,将 3D 世界坐标转换为 2D 画布坐标;
  • 分别绘制障碍物、起点、终点、玩家及玩家朝向,实现 3D 场景的 2D 缩略展示。

4.4.5、回车键通关重置

  • 双重键盘监听:同时使用 PlayCanvas 内置监听和全局keydown监听,解决回车键不触发的问题;
  • Canvas 焦点处理:初始化和关卡切换时强制聚焦 Canvas,确保键盘事件能被捕获;
  • 关卡循环:超过配置的关卡数后自动回到第一关,实现无限循环闯关。

五、完整源码

5.1、./App.vue

<template><divid="app"><GameCanvas/></div></template><scriptsetup>import GameCanvas from'./components/GameCanvas.vue'</script><style>*{margin: 0;padding: 0;box-sizing: border-box;}html, body, #app{width: 100%;height: 100%;overflow: hidden;}</style>

5.2、./components/GameCanvas.vue

<template><divclass="game-container"><h2>3D地图巡视游戏 - 第{{ currentLevel }}关</h2><divid="application-container"class="canvas-container"><canvasid="pc-canvas"tabindex="1"></canvas><divclass="minimap-container"><canvasid="minimap-canvas"width="200"height="200"></canvas></div></div><divclass="game-info"><p>控制方式:↑↓←→ 方向键移动角色 | WASD 键旋转摄像机</p><p>目标:从<spanclass="start-point">A点(红色)</span>走到<spanclass="end-point">B点(绿色)</span></p><pv-if="isWon"class="success">🎉 恭喜过关!按回车键进入下一关</p></div></div></template><scriptsetup>import{ ref, onMounted, onUnmounted, nextTick }from'vue'import*as pc from'playcanvas'// 游戏状态const currentLevel =ref(1)const isWon =ref(false)let app =nulllet player =nulllet startPoint =nulllet endPoint =nulllet cameraEntity =nulllet moveSpeed =8// 角色移动速度let cameraRotateSpeed =60// 摄像机旋转速度(度/秒)let cameraYaw =0// 水平旋转角(绕Y轴)let cameraPitch =-30// 垂直旋转角(绕X轴)let cameraDistance =15// 摄像机到玩家的距离// 全局键盘监听函数(用于兜底)let globalEnterHandler =null// 多关卡配置(3+1关,差异化地面颜色)const levelConfigs =[// 第1关{groundSize:20,startPos:newpc.Vec3(-8,0.5,-8),endPos:newpc.Vec3(8,0.5,8),obstacleCount:3,groundColor:[0.3,0.7,0.3],// 绿色地面obstacleColor:[0.5,0.5,0.5],// 中灰色障碍物sceneBgColor:[1,1,1]// 白色背景},// 第2关{groundSize:25,startPos:newpc.Vec3(-10,0.5,0),endPos:newpc.Vec3(10,0.5,0),obstacleCount:5,groundColor:[0.3,0.7,0.7],// 青绿色地面obstacleColor:[0.6,0.6,0.6],// 浅灰色障碍物sceneBgColor:[1,1,1]// 白色背景},// 第3关{groundSize:30,startPos:newpc.Vec3(0,0.5,-12),endPos:newpc.Vec3(0,0.5,12),obstacleCount:7,groundColor:[0.7,0.7,0.3],// 黄绿色地面obstacleColor:[0.4,0.4,0.4],// 深灰色障碍物sceneBgColor:[1,1,1]// 白色背景},// 第4关{groundSize:35,startPos:newpc.Vec3(-12,0.5,10),endPos:newpc.Vec3(12,0.5,-10),obstacleCount:8,groundColor:[0.7,0.3,0.7],// 紫色地面obstacleColor:[0.55,0.55,0.55],// 中灰色障碍物sceneBgColor:[1,1,1]// 白色背景}]// 小地图相关let minimapCanvas =nulllet minimapCtx =nulllet minimapScale =5let obstacles =[]// 存储障碍物信息(用于碰撞+小地图)// 下一关核心逻辑constgoToNextLevel=()=>{if(isWon.value){ console.log('进入下一关,当前关卡:', currentLevel.value)// 循环关卡(超过配置数回到第一关) currentLevel.value = currentLevel.value % levelConfigs.length +1 isWon.value =falsegenerateNewLevel()// 重新聚焦Canvas,确保后续操作正常 document.getElementById('pc-canvas').focus()}}// 初始化PlayCanvas应用asyncfunctioninitPlayCanvas(){try{awaitnextTick()const canvas = document.getElementById('pc-canvas')if(!canvas){ console.error('Canvas元素未找到!')return}// 关键:确保Canvas获取焦点(解决键盘事件不触发) canvas.focus() canvas.addEventListener('click',()=> canvas.focus())// 初始化小地图initMinimap()// 1. 创建图形设备const graphicsDevice =newpc.WebglGraphicsDevice(canvas,{antialias:true,powerPreference:"high-performance"}) graphicsDevice.maxPixelRatio = window.devicePixelRatio // 2. 创建应用实例(禁用物理引擎) app =newpc.Application(canvas,{graphicsDevice: graphicsDevice,mouse:newpc.Mouse(canvas),keyboard:newpc.Keyboard(window),createCanvas:false,physics:false})// 3. 启动应用await app.start()// 4. 配置场景if(!app.scene){ app.scene =newpc.Scene(app.graphicsDevice)} app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW) app.setCanvasResolution(pc.RESOLUTION_AUTO)// 生成第一关地图generateNewLevel()// 注册更新循环 app.on('update', update)// ========== 修复:键盘监听(双重保障) ==========// 1. PlayCanvas内置键盘监听(优先) app.keyboard.off(pc.KEY_ENTER, goToNextLevel)// 先移除旧监听 app.keyboard.on(pc.KEY_ENTER, goToNextLevel) app.keyboard.off(13, goToNextLevel)// 数字码兜底 app.keyboard.on(13, goToNextLevel)// 2. 全局键盘监听(兜底,防止PlayCanvas监听失效)globalEnterHandler=(e)=>{if(e.key ==='Enter'|| e.keyCode ===13){ e.preventDefault()// 阻止默认行为(如页面刷新)goToNextLevel()}} window.addEventListener('keydown', globalEnterHandler)// 窗口大小适配 window.addEventListener('resize',()=>{if(app && app.graphicsDevice){ app.resizeCanvas(window.innerWidth, window.innerHeight) app.graphicsDevice.resize(window.innerWidth, window.innerHeight)}}) app.resizeCanvas(window.innerWidth, window.innerHeight)}catch(error){ console.error('PlayCanvas初始化失败:', error)}}// 初始化小地图functioninitMinimap(){ minimapCanvas = document.getElementById('minimap-canvas') minimapCtx = minimapCanvas.getContext('2d')}// 绘制小地图functiondrawMinimap(){if(!minimapCtx ||!player ||!startPoint ||!endPoint)return minimapCtx.clearRect(0,0, minimapCanvas.width, minimapCanvas.height) minimapCtx.fillStyle ='#222' minimapCtx.fillRect(0,0, minimapCanvas.width, minimapCanvas.height)const centerX = minimapCanvas.width /2const centerY = minimapCanvas.height /2// 绘制障碍物 minimapCtx.fillStyle ='#666' obstacles.forEach(obs=>{const x = centerX + obs.pos.x * minimapScale const y = centerY + obs.pos.z * minimapScale const w = obs.scale.x * minimapScale const h = obs.scale.z * minimapScale minimapCtx.fillRect(x - w/2, y - h/2, w, h)})// 绘制起点 minimapCtx.fillStyle ='#ff0000'const startX = centerX + startPoint.getPosition().x * minimapScale const startY = centerY + startPoint.getPosition().z * minimapScale minimapCtx.beginPath() minimapCtx.arc(startX, startY,4,0, Math.PI*2) minimapCtx.fill()// 绘制终点 minimapCtx.fillStyle ='#00ff00'const endX = centerX + endPoint.getPosition().x * minimapScale const endY = centerY + endPoint.getPosition().z * minimapScale minimapCtx.beginPath() minimapCtx.arc(endX, endY,4,0, Math.PI*2) minimapCtx.fill()// 绘制玩家 minimapCtx.fillStyle ='#0000ff'const playerX = centerX + player.getPosition().x * minimapScale const playerY = centerY + player.getPosition().z * minimapScale minimapCtx.beginPath() minimapCtx.arc(playerX, playerY,5,0, Math.PI*2) minimapCtx.fill()// 绘制玩家朝向 minimapCtx.strokeStyle ='#ffffff' minimapCtx.lineWidth =2 minimapCtx.beginPath() minimapCtx.moveTo(playerX, playerY) minimapCtx.lineTo( playerX + Math.sin(player.getEulerAngles().y * Math.PI/180)*8, playerY + Math.cos(player.getEulerAngles().y * Math.PI/180)*8) minimapCtx.stroke()}// 碰撞检测functioncheckCollision(nextPos){if(!player || obstacles.length ===0)returnfalseconst playerRadius =0.5// 检测障碍物碰撞for(let i =0; i < obstacles.length; i++){const obs = obstacles[i]const obsMinX = obs.pos.x - obs.scale.x /2const obsMaxX = obs.pos.x + obs.scale.x /2const obsMinZ = obs.pos.z - obs.scale.z /2const obsMaxZ = obs.pos.z + obs.scale.z /2const playerMinX = nextPos.x - playerRadius const playerMaxX = nextPos.x + playerRadius const playerMinZ = nextPos.z - playerRadius const playerMaxZ = nextPos.z + playerRadius const xOverlap =(playerMaxX > obsMinX)&&(playerMinX < obsMaxX)const zOverlap =(playerMaxZ > obsMinZ)&&(playerMinZ < obsMaxZ)if(xOverlap && zOverlap){returntrue}}// 检测地图边界const currentConfig = levelConfigs[currentLevel.value -1]const mapHalfSize = currentConfig.groundSize /2if(Math.abs(nextPos.x)> mapHalfSize -1|| Math.abs(nextPos.z)> mapHalfSize -1){returntrue}returnfalse}// 生成新关卡地图functiongenerateNewLevel(){ obstacles =[]// 清空现有场景if(app && app.root && app.root.children.length >0){const children =[...app.root.children] children.forEach(child=> child.destroy())}// 获取当前关卡配置const config = levelConfigs[currentLevel.value -1]|| levelConfigs[0]// 设置场景背景色(白色) app.scene.background =newpc.Color(...config.sceneBgColor)// 1. 创建地面(每关不同颜色)const ground =newpc.Entity('ground') ground.addComponent('model',{type:'box',castShadows:true}) ground.setLocalScale(config.groundSize,0.1, config.groundSize) ground.setLocalPosition(0,0,0) ground.model.material =createMaterial(...config.groundColor) app.root.addChild(ground)// 2. 生成障碍物(灰色系)for(let i =0; i < config.obstacleCount; i++){let posX, posZ let validPos =falsewhile(!validPos){ posX =getRandomNum(-config.groundSize/2+2, config.groundSize/2-2) posZ =getRandomNum(-config.groundSize/2+2, config.groundSize/2-2)const distToStart = Math.hypot(posX - config.startPos.x, posZ - config.startPos.z)const distToEnd = Math.hypot(posX - config.endPos.x, posZ - config.endPos.z)if(distToStart >3&& distToEnd >3){ validPos =true}}const scaleX =getRandomNum(1,4)const scaleZ =getRandomNum(1,4)const obstacle =newpc.Entity(`obstacle-${i}`) obstacle.addComponent('model',{type:'box',castShadows:true}) obstacle.setLocalPosition(posX, scaleX/2, posZ) obstacle.setLocalScale(scaleX, scaleX, scaleZ) obstacle.model.material =createMaterial(...config.obstacleColor) app.root.addChild(obstacle) obstacles.push({pos:{x: posX,z: posZ },scale:{x: scaleX,z: scaleZ }})}// 3. 创建起点(红色) startPoint =newpc.Entity('start-point') startPoint.addComponent('model',{type:'sphere',castShadows:true}) startPoint.setLocalPosition(config.startPos) startPoint.setLocalScale(0.5,0.5,0.5) startPoint.model.material =createMaterial(1,0,0) app.root.addChild(startPoint)// 4. 创建终点(绿色) endPoint =newpc.Entity('end-point') endPoint.addComponent('model',{type:'sphere',castShadows:true}) endPoint.setLocalPosition(config.endPos) endPoint.setLocalScale(0.5,0.5,0.5) endPoint.model.material =createMaterial(0,1,0) app.root.addChild(endPoint)// 5. 创建玩家(蓝色) player =newpc.Entity('player') player.addComponent('model',{type:'capsule',castShadows:true}) player.setLocalPosition(config.startPos) player.setLocalScale(0.5,1,0.5) player.model.material =createMaterial(0,0,1) app.root.addChild(player)// 6. 创建摄像机 cameraEntity =newpc.Entity('camera') cameraEntity.addComponent('camera',{clearColor:newpc.Color(...config.sceneBgColor),gammaCorrection: pc.GAMMA_SRGB,toneMapping: pc.TONEMAP_LINEAR})updateCameraPosition() cameraEntity.lookAt(player.getPosition()) app.root.addChild(cameraEntity)// 绘制初始小地图drawMinimap()}// 更新摄像机位置functionupdateCameraPosition(){if(!cameraEntity ||!player)returnconst yawRad = cameraYaw * Math.PI/180const pitchRad = cameraPitch * Math.PI/180const x = Math.sin(yawRad)* Math.cos(pitchRad)* cameraDistance const z = Math.cos(yawRad)* Math.cos(pitchRad)* cameraDistance const y = Math.sin(pitchRad)* cameraDistance +8const playerPos = player.getPosition() cameraEntity.setPosition(playerPos.x + x, playerPos.y + y, playerPos.z + z) cameraEntity.lookAt(playerPos)}// 随机数工具函数functiongetRandomNum(min, max){return Math.random()*(max - min)+ min }// 创建材质functioncreateMaterial(r, g, b){const material =newpc.StandardMaterial() material.diffuse.set(r, g, b) material.emissive.set(r *0.2, g *0.2, b *0.2) material.update()return material }// 更新循环functionupdate(dt){if(isWon.value ||!app ||!player ||!endPoint ||!cameraEntity)returnconst keyboard = app.keyboard // 1. WASD 控制摄像机旋转if(keyboard.isPressed(pc.KEY_A)){ cameraYaw += cameraRotateSpeed * dt }if(keyboard.isPressed(pc.KEY_D)){ cameraYaw -= cameraRotateSpeed * dt }if(keyboard.isPressed(pc.KEY_W)){ cameraPitch += cameraRotateSpeed * dt cameraPitch = Math.min(cameraPitch,10)}if(keyboard.isPressed(pc.KEY_S)){ cameraPitch -= cameraRotateSpeed * dt cameraPitch = Math.max(cameraPitch,-60)}updateCameraPosition()// 2. 方向键控制角色移动const moveStep = moveSpeed * dt const currentPos = player.getPosition()let newPos =newpc.Vec3(currentPos.x, currentPos.y, currentPos.z)if(keyboard.isPressed(pc.KEY_UP)){ newPos.z -= moveStep }if(keyboard.isPressed(pc.KEY_DOWN)){ newPos.z += moveStep }if(keyboard.isPressed(pc.KEY_LEFT)){ newPos.x -= moveStep }if(keyboard.isPressed(pc.KEY_RIGHT)){ newPos.x += moveStep }// 碰撞检测:无碰撞才移动if(!checkCollision(newPos)){ player.setPosition(newPos)}// 3. 实时更新小地图drawMinimap()// 4. 检测是否到达终点(调大阈值,确保容易触发)const distanceToEnd = Math.hypot( player.getPosition().x - endPoint.getPosition().x, player.getPosition().z - endPoint.getPosition().z )// 阈值从1改为2.5,更容易触发通关if(distanceToEnd <2.5){ isWon.value =true console.log('已到达终点,isWon:', isWon.value)}}// 生命周期onMounted(async()=>{awaitinitPlayCanvas()})onUnmounted(()=>{// 清理所有监听和资源if(app){ app.off('update', update)if(app.keyboard){ app.keyboard.off(pc.KEY_ENTER, goToNextLevel) app.keyboard.off(13, goToNextLevel)} window.removeEventListener('resize',()=>{}) app.destroy() app =null}// 移除全局键盘监听if(globalEnterHandler){ window.removeEventListener('keydown', globalEnterHandler) globalEnterHandler =null}})</script><stylescoped>.game-container{width: 100%;height: 100vh;position: relative;color: #333;overflow: hidden;font-family: Arial, sans-serif;}.canvas-container{width: 100%;height: 100%;position: relative;}#pc-canvas{position: absolute;top: 0;left: 0;width: 100%;height: 100%;display: block;outline: none;/* 去掉聚焦后的边框 */}/* 小地图样式 */.minimap-container{position: absolute;top: 20px;right: 20px;width: 200px;height: 200px;border: 2px solid #666;border-radius: 8px;z-index: 100;background:rgba(0, 0, 0, 0.5);}#minimap-canvas{width: 100%;height: 100%;display: block;}.game-info{position: absolute;top: 20px;left: 20px;background:rgba(255, 255, 255, 0.95);padding: 15px 20px;border-radius: 8px;z-index: 100;font-size: 14px;box-shadow: 0 2px 10px rgba(0,0,0,0.1);border: 1px solid #eee;}.start-point{color: #e53935;font-weight: bold;}.end-point{color: #43a047;font-weight: bold;}.success{color: #f57c00;font-weight: bold;font-size: 16px;margin: 5px 0 0 0;}h2{position: absolute;top: 10px;left: 50%;transform:translateX(-50%);z-index: 100;background:rgba(255,255,255,0.9);padding: 8px 20px;border-radius: 20px;font-size: 18px;margin: 0;box-shadow: 0 2px 8px rgba(0,0,0,0.1);}</style>

5.3、./index.html

<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"/><metaname="viewport"content="width=device-width, initial-scale=1.0"/><title>3D地图巡视游戏</title><linkrel="stylesheet"href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"/><scripttype="module"src="/src/main.js"></script></head><body><divid="app"></div></body></html>

Read more

前端html2canvas使用场景详解

html2canvas 是前端常用的 “DOM 转图片” 库,核心是将页面 DOM 节点渲染为 Canvas,再转为图片(Base64 或 Blob)。以下是 9 种核心使用场景的详细教程,包含代码示例、参数配置、问题解决,覆盖日常开发需求。 一、基础使用:将指定 DOM 转为 Base64 图片 适用于简单场景(如生成证书、截图分享),无需复杂配置。 1. 安装与引入 # npm 安装 npm install html2canvas --save javascript // 模块化项目引入(Vue/React/Angular) import html2canvas from 'html2canvas'

深度解析KBQA常用数据集:WebQSP与CWQ

深度解析KBQA常用数据集:WebQSP与CWQ 一、引言 知识图谱问答(KBQA)是自然语言处理领域的关键任务,其核心挑战在于将自然语言问题转换为可执行的逻辑形式(如SPARQL查询)并从知识图谱中获取答案。WebQSP和CWQ是当前KBQA研究中最具代表性的两个数据集,分别覆盖了从多跳到复杂组合性问题的全场景。本文将从数据形式、标注特点、核心挑战等维度对两者进行深度解析,并对比其在KBQA研究中的定位与价值。 二、WebQSP数据集:多跳推理的基石 2.1 数据集概况 * 全称:WebQuestionsSP(扩展自WebQuestions) * 来源:基于Freebase知识图谱构建,由Berant等人于2013年提出,后经扩展支持多跳推理。 * 规模:训练集约4,700条,测试集约2,000条。 * 问题类型:多跳关系推理(最多4跳),需结合实体、关系和约束条件。 2.2 数据形式详解(基于WebQSP-train实例深度解析) WebQSP的每条数据以JSON格式组织,包含从原始问题到逻辑形式、推理路径、答案的完整标注。以下结合WebQTrn-0实例(关于

深度解析 WebMCP:让网页成为 AI 智能体的工具库

深度解析 WebMCP:让网页成为 AI 智能体的工具库

深度解析 WebMCP:让网页成为 AI 智能体的工具库 * 深度解析 WebMCP:让网页成为 AI 智能体的工具库 * 前言 * 什么是 WebMCP? * 类比理解 * 为什么要用 WebMCP? * 1. 现有方案的局限性 * 2. WebMCP 的核心优势 * WebMCP 核心概念解析 * 1. 工具(Tools) * 2. 代理(Agent) * 3. 人类在环(Human-in-the-Loop) * 典型使用场景 * 场景一:创意设计助手 * 场景二:智能购物 * 场景三:代码审查 * WebMCP vs 现有方案对比 * 与 MCP 的关系 * 技术架构浅析 * 注册工具的基本模式 * 调用链 * 安全考量 * 1.

cann-recipes-train 仓库深度解读:昇腾平台下 DeepSeek-R1 与 Qwen2.5 强化学习训练优化实践

cann-recipes-train 仓库深度解读:昇腾平台下 DeepSeek-R1 与 Qwen2.5 强化学习训练优化实践

cann-recipes-train 仓库深度解读:昇腾平台下 DeepSeek-R1 与 Qwen2.5 强化学习训练优化实践 前言 自 DeepSeek-R1 发布以来,大模型的强化学习(RL)训练掀起了新一轮的技术热潮。各大厂商与开源社区纷纷投入实践,持续探索更高效的 RL 训练体系。本文将基于 cann-recipes-train 仓库,解读两个实践样例:DeepSeek-R1 的 RL 训练优化实践样例、基于 verl 框架的 Qwen2.5 强化学习实践样例 cann-recipes-train 仓库全景解析:昇腾训练优化的"实战底座" 大模型训练拼效率的阶段,CANN 直接帮我们搞定了底层异构硬件适配、资源调度这些麻烦事,不用再从零研究 GPU 和 NPU 怎么协同,现有模型代码也不用大改就能对接,训