稀土掘金技术社区

我又写出了被 Three.js 官推转发的项目?!(源码分享)

0.👁️前置条件

🎉 🎉 好久不见,亲爱的小伙伴们!欢迎来到这个充满童趣的技术分享!在儿童节即将到来之际,我特别准备了这篇轻松有趣的 Three.js 实战指南。

🎮 你将学到什么?

  • Three.js 基础应用
  • 3D 定制化资源获取技巧
  • 简易 Three.js 游戏开发思路

💡 无需担心难度!本文专为儿童节设计,技术内容简单易懂,只要掌握Three.js 基础用法就能轻松上手!

1.🚀Page 预览

🎂 又一年儿童节将至,看着日历上跳动的数字,忽然意识到自己早已不是那个会收到节日礼物的小朋友了。现在的我,日复一日地在公司里:

  • 敲着后台管理系统的CURD
  • 调试着微信小程序的接口
  • 对着需求文档发呆
Image

「⚠️ 系统警报:检测到开发者快乐指数跌破警戒线!」

「🚀 正在注入童趣补丁...」

「🎮 强制启动儿童节特别模式....」

画面预览

小心飞驰的车辆,收集道具,创造你的最高纪录!

Image
Image

新手指南

  • PC 端:使用方向键或 WASD 控制小鸡移动
  • 手机端:滑动屏幕即可轻松操控(适配完美!)
  • 游玩的新手指南您可以在点击右上角的暂停按钮看到,并且您可以再此为自己的角色取名,以便可以在排行榜上找到自己
Image

相关地址

  • 🖥️ PC 端正式版(建议使用魔法上网)[1] : cross-road-eight.vercel.app/[2]
  • 🛠️ PC 调试模式(开发者专用,请勿作弊哦)[3]: cross-road-eight.vercel.app/#debug[4]
  • 👨‍💻 GitHub 源码(欢迎 Star⭐)[5] :github.com/hexianWeb/C…[6]

另外当前小游戏已经国际知名小游戏平台收录[7]:synthgamer.com/game/34[8] ,此网站不需要魔法。

Image 此外如果您正在使用手机浏览这篇文章,我非常建议您直接点击进入游玩,因为这个游戏我已经做了移动端适配。滑动将会以最符合操纵直觉的操控方式呈现在您面前

Image (小提示: 游戏会记录你的最佳成绩,在死亡时将成绩发送至排行榜单!快来挑战好友吧!🐣💨)

本着开源精神与诚实创作的原则,我必须说明:本项目的核心机制灵感来源于业界前辈的智慧结晶。特别感谢:「Hunor Borbely」 的优秀教程:《Crossy Road 风格游戏开发指南》[9]

参考内容有:

  • 游戏元数据 (metadata) 的结构设计
  • 角色与车辆的碰撞检测逻辑

(指路原作者) Image

🌟 官方认可

很荣幸这个项目同样获得了 Three.js 官推的转发认可:

Image

( Tips: 站在巨人的肩膀上,我们才能看得更远!🚀)

2.🎮 Three.js 游戏三要素

我之前在 2025 年了,我不允许有前端不会用 Trae 让页面 Hero Section 变得高级!!!(Threejs)[10] 提到过 Three.js三要素,他们分别是场景、相机以及渲染器。

什么是游戏开发三要素?

而在 Three.js 游戏开发中,我们同样有三大核心架构:

要素
作用
类比现实
「Scene」
游戏世界的 3D 环境
就像游乐场的场地
「Game UI」
用户界面和交互层
相当于游乐场的指示牌和售票处
「Metadata」
游戏数据和逻辑
类似游乐场的运营规则和游客数据

为什么这很重要?

理解这三个要素的关系至关重要:

  1. 「Scene」 负责 "演什么" - 处理 3D 模型、光照、物理效果
  2. 「UI」 负责 "怎么看" - 控制分数显示、菜单系统
  3. 「Metadata」 负责 "怎么玩" - 管理游戏状态、得分规则、角色属性
Image

3.📦资源获取

3D Model 资源生成

在😮😮😮 我写出了被 Threejs 官推转发的项目🚀✨?![11] 文章中我曾写过 「3D 客制化资源的获取与处理」,这里我不做过多赘述,只做简单举例

就比小鸡模型的获取我先通过 awesome-gpt4o-images[12] 给的提示参考图以及相应的提示语生成一张背景单一,结构合理的 2.5D 小鸡图片,

Image

我这里将参考图奉上,您有空也可以试试这些神奇的提示词

Image

随后再将图片导入 AI 3D generation 平台,我比较喜欢的是 hyper3D[13]。 过 30 秒即可生成对应的模型。看起来还不错。

Image

游戏背景音乐资源获取

游戏背景音乐则是使用 Suno AI 进行生成的。老实说我觉得还挺好听的。

但是如果想要获取真正的游戏背景音乐以及各种游戏音效可以访问 Opengameart 的音乐板块[15]

❝

输入风格描述:"8-bit retro game music with cheerful melody"

❞
Image

如果对于这块教程还有疑惑可以点击这里,在前一篇文章中我有着详细的介绍。

AI 工具让独立开发者也能拥有专业级美术资源, 随着 AI 数字资产的获取门槛降低,我相信未来对于我们网站开发人员来说真正的上限应该是「视觉想象力」与「数字美感素养」。

4.🤖metadata & 基础场景搭建

现在让我们来看一看原版的小鸡过马路游戏场景

Image

现在的首要目的是分析出当前场景所需的 metadata, 在不考虑场景自生成的情况下利用metadata在 Threejs中搭建出游戏的基本场景。

  • 首先我们可以看到图中小鸡不断前进,场景会由数个不同的 “行” 拼接而成,他们可能是 “草地“,也可能是” 柏油路“
  • 然后每个行上都有着相应的物体,"草地" 上会出现高矮不一的 "树木",而 "柏油路" 上会出现行驶方向向左或者向右的汽车

那么metadata应该如何更好的囊括这些信息呢?我是这样做的:

Image

现在我们先采用静态metadata来构建初始游戏场景,通过模块化的设计实现草地、道路、树木和车辆的动态生成。

静态 metadata 结构

const metadata = [  // 第一行   {    type: 'forest',    trees: [      { tileIndex: -7, type: 'tree01' },      { tileIndex: -3, type: 'tree02' },    ],  },  // 第二行   {    type: 'road',    direction: true,    speed: 1,    vehicles: [      {        initialTileIndex: 12,        type: 'car04',      },      {        initialTileIndex: 2,        type: 'car08',      },      {        initialTileIndex: -2,        type: 'car01',      },    ],  },]

地形生成

而铺设路面的函数就较为简单,就是将传入的mesh在scene中排成一排,随后根据当前行数为 metadata中的所属行的数组下标对其位置在进行调整。

export default class Grass {  constructor(scene, object3d, rowIndex = 0) {    this.scene = scene    this.object3d = object3d    this.rowIndex = rowIndex    this.tiles = []    this.createGrassRow()  }
  // 生成一行草地  createGrassRow() {    // 获取 tile 资源(假设资源名为 'grass',如有不同请调整)    const tileResource = this.object3d    tileResource.scene.updateMatrixWorld()    if (!tileResource) {      console.warn('未找到 grass 资源')      return    }   // 生成16个连续的草地瓦片    for (let i = 0; i < 16; i++) {      // 计算当前tile的地图下标      const tileIndex = MIN_TILE_INDEX + i      // 克隆tile模型      const tileMesh = tileResource.scene.clone()      // 设置tile在世界坐标中的位置      tileMesh.position.set(tileIndex, 0, this.rowIndex) // 将 tileMesh 沿着 X 轴排成一排,随后根据 this.rowIndex 调整 Z 轴位置      // 添加到场景      this.scene.add(tileMesh)      // 存储tile对象      this.tiles.push(tileMesh)    }  }}

路面行生成也是同理,这里就不反复贴类似功能的代码了。随后在场景中根据 metadata生成对应类实例

this.metadata.forEach((rowData) => {      this.rowIndex++      // 如果是森林行,添加树      if (rowData && rowData.type === 'forest') {        // 先生成草地        this.addGrassRow(this.rowIndex)      }      if (rowData && rowData.type === 'road') {        this.addRoadRow(this.rowIndex)      }})

  // 添加一行草地  addGrassRow(rowIndex = 0) {    const grass = new Grass(this.scene, this.resources.items.grass, rowIndex)    this.grassRows.push(grass)    this.tiles.push(...grass.tiles)  }
  // 添加一行道路  addRoadRow(rowIndex = 0) {    const road = new Road(this.scene, this.resources, rowIndex)    this.roadRows.push(road)  }

我们就能得到场景如图 (行上的数字对应了当前行对应的 TileIndex)

Image

动态元素生成

现在我们需要向森林行和道路行上添加对应的物体,这些物体并不是固定的某一行有多少多少个,而是根据在metadata相对应的物体数组决定,森林行根据tree数组添加对应的树木,道路类根据vehicles添加对应的车辆。

树木生成

就比如树木数组

    trees: [      { tileIndex: -7, type: 'tree01' },      { tileIndex: -3, type: 'tree02' },    ],

他就分别代表

  • 模型名为tree01的树木模型在tileIndex位置为 -7 的位置。
  • 模型名为tree02的树木模型在tileIndex位置为 -3 的位置。

(ps: 我对单个路面块再建模软件中进行过预处理,确保他们引入后长度大小刚好为 1m,所以后续tileIndex会和position的 X 轴对应)

export default class Tree {  /**   * @param {THREE.Scene} scene - threejs场景   * @param {object} resources - 资源加载器实例   * @param {Array} trees - 当前行的树木数组,每项包含tileIndex和type   * @param {number} rowIndex - 当前行的z坐标   */  constructor(scene, resources, trees, rowIndex = 0) {    this.scene = scene    this.resources = resources    this.trees = trees    this.rowIndex = rowIndex    this.treeMeshes = []    this.addTrees()  }
  // 添加所有树木到当前行  addTrees() {    this.trees.forEach((treeData) => {      const { tileIndex, type } = treeData      // 获取对应类型的树模型      const treeResource = this.resources.items[type]      // 克隆树模型      const treeMesh = treeResource.scene.clone()      // 设置树的位置(x轴为tileIndex,z轴为rowIndex)      treeMesh.position.set(tileIndex, 0.2, this.rowIndex)      // 添加到场景      this.scene.add(treeMesh)      // 存储树对象,便于后续移除      this.treeMeshes.push(treeMesh)    })  }}

车辆生成

车辆类相比树木类需要多一层 “调整车辆方向逻辑”,这不仅需要代码配合,还需要对静态资源进行预处理,确保所有车辆朝向一致。

  {    type: 'road',    direction: true,    speed: 1,    vehicles: [      {        initialTileIndex: 12,        type: 'car04',      },      {        initialTileIndex: 2,        type: 'car08',      },      {        initialTileIndex: -2,        type: 'car01',      },    ],  },
export default class Car {  /**   * @param {THREE.Scene} scene - threejs场景   * @param {object} resources - 资源加载器实例   * @param {Array} vehicles - 当前行的车辆数组,每项包含 initialTileIndex 和 type   * @param {number} rowIndex - 当前行的z坐标   * @param {boolean} direction - 车辆方向,true 向右,false 向左   * @param {number} speed - 车辆速度   */  constructor(scene, resources, vehicles, rowIndex = 0, direction = false, speed = 1) {    this.experience = new Experience()    this.scene = scene    this.resources = resources    this.time = this.experience.time    this.vehicles = vehicles    this.rowIndex = rowIndex    this.direction = direction    this.speed = speed    this.timeMultiplier = 1    this.carMeshes = []    this.addCars()  }
  // 添加所有车辆到当前行  addCars() {    this.vehicles.forEach((carData, _idx) => {      const { initialTileIndex, type } = carData      // 获取对应类型的车辆模型      const carResource = this.resources.items[type]      if (!carResource) {        console.warn(`未找到资源: ${type}`)        return      }      // 克隆车辆模型      const carMesh = carResource.scene.clone()      carMesh.scale.set(0.5, 0.5, 0.5)      // 递归设置所有 mesh 可投射阴影      carMesh.traverse((child) => {        if (child.isMesh) {          child.castShadow = true // 车辆产生阴影        }      })      // 设置车辆位置(x轴为tileIndex*4,z轴为rowIndex)      carMesh.position.set(initialTileIndex, 0.35, this.rowIndex)      // 设置车辆朝向      if (this.direction) {        carMesh.rotation.y = 0 // 向右      }      else {        carMesh.rotation.y = Math.PI // 向左      }      // 添加到场景      this.scene.add(carMesh)      // 存储车辆对象,便于后续移除和动画      this.carMeshes.push(carMesh)    })  }}

场景组装搭建

随后在前面遍历metadata的地方将tree & car的生成函数以同样方式调用

    this.metadata.forEach((rowData) => {      this.rowIndex++      // 如果是森林行,添加树      if (rowData && rowData.type === 'forest') {        // 先生成草地        this.addGrassRow(this.rowIndex)        this.addTreeRow(rowData.trees, this.rowIndex)      }      if (rowData && rowData.type === 'road') {        this.addRoadRow(this.rowIndex)        this.addCarRow(rowData.vehicles, this.rowIndex, rowData.direction, rowData.speed)      }    })  }
  // 添加一行树  addTreeRow(trees, rowIndex) {    const treeRow = new Tree(this.scene, this.resources, trees, rowIndex)    this.treeRows.push(treeRow)  }
  // 添加一行车辆  addCarRow(vehicles, rowIndex = 0, direction = false, speed = 1) {    const carRow = new Car(this.scene, this.resources, vehicles, rowIndex, direction, speed)    this.carRows.push(carRow)    // 新增:记录每行车辆mesh    this.carMeshDict[rowIndex] = carRow.getCarMeshes()  }
Image

最后我们只需要在给车辆增加移动效果,让车辆随着requestAnimationFrame更新不断更新mesh的位移, 向指定方向direction移动即可。记得别忘了超出边界要及时重置车辆位置哦。

  // 更新车辆位置(可用于动画)  update() {    // 获取全局已用时间,单位ms,转为秒    const t = this.time.elapsed * 0.03 * this.timeMultiplier    this.carMeshes.forEach((car, idx) => {      // 车辆移动方向      const dir = this.direction ? 1 : -1      // 边界判断与循环 重置车辆位置      if (dir === 1 && car.position.x > CAR_BOUNDARY_MAX) {        car.position.x = CAR_BOUNDARY_MIN      }      else if (dir === -1 && car.position.x < CAR_BOUNDARY_MIN) {        car.position.x = CAR_BOUNDARY_MAX      }
      car.position.x += dir * this.speed * this.time.delta * 1 / 60 * 0.23 * this.timeMultiplier
      // === 车身抖动:模拟不平路面 ===      // 抖动参数      const shake = this.carShakeParams[idx]      // 叠加两组不同频率的正弦波,幅度小      const freq1 = 2.5      const amp1 = 0.02      const freq2 = 4.3      const amp2 = 0.01      // 计算抖动偏移      const offsetY = Math.sin(t * freq1 + shake.phase) * amp1 + Math.cos(t * freq2 + shake.phase * 1.3) * amp2      car.position.y = shake.baseY + offsetY    })  }

(最后效果图) Image

这个我们就已经完成了根据 metadata生成对应的场景内容的功能,现在您可以为用户生成一个较为简单的初始场景,虽然我们后面会利用随机生成的metadata数据生成地形,但相信我,我曾经生成过除去出生点后面 6、7 行都是马路。毕竟玩家刚复活下一步就要开始过马路不是一个很好的游戏体验。

Image

5.🐤角色移动与场景生成

引入角色

首先,我们需要将小鸡模型添加到场景中并进行适当调整:

  // 加载并放置小鸡模型  initChicken() {    // 获取 instance 资源    const chickenResource = this.resources.items.bigChicken    if (!chickenResource) {      console.warn('未找到 instance 资源')      return    }    // 克隆模型,避免资源污染    this.instance = chickenResource.scene.clone()    // 显示阴影    this.instance.traverse((child) => {      if (child instanceof THREE.Mesh) {        child.castShadow = true      }    })    // 只设置 y 方向初始高度    this.instance.position.set(0, 0.22, 0)    // 设置初始等比例缩放    this.instance.scale.set(this.scale, this.scale, this.scale)    // 添加到 agentGroup    this.agentGroup.add(this.instance)  }

角色移动

随后需要对小鸡加入按键监听以及移动控制,在之前的项目😮😮😮 我写出了被 Threejs 官推转发的项目🚀✨?!中,我们已经探讨过角色移动的基本原理。这个项目我沿用了当时的角色按键移动响应和转向逻辑,在这里就不炒冷饭去解释_为什么使用 event.code_ 或者_角色转向 BUG_。 「直接重点解释当前项目和上一个项目移动的不同点:引入入了移动队列 (movesQueue) 机制」

首先我们来看在游戏中正常移动是什么样的。

Image

可以看到小鸡在场景中每次都是「每次按键都对应一个完整的移动单位,不会因按键时间长短影响移动距离」。这就是为什么我们相较于前作的移动逻辑多了一个movesQueue,movesQueue的作用是作为移动指令队列,存储待执行的移动方向(如 forward、left)。用户可以连续输入多个移动指令,使动画未完成,用户的后续输入也会被记录。避免了突然变向等不自然现象。

以下是 this.movesQueue 的监听逻辑

  listenKeyboard() {    window.addEventListener('keydown', (event) => {      if (this.experience.isPaused) {        return      }      let move = null      switch (event.code) {        case 'ArrowUp':        case 'KeyW':          move = 'forward'          break        case 'ArrowDown':        case 'KeyS':          move = 'backward'          break        case 'ArrowLeft':        case 'KeyA':          move = 'left'          break        case 'ArrowRight':        case 'KeyD':          move = 'right'          break        default:          break      }      // 只在首次按下时 push      if (move && !this.pressedKeys.has(event.code)) {        this.movesQueue.push(move)        this.pressedKeys.add(event.code)      }    })    window.addEventListener('keyup', (event) => {      this.pressedKeys.delete(event.code)    })  }

随后this.movesQueue 会在requestAnimationFrame中进行相应指令的执行和释放。这里有一个具体的流程图如下:

(移动流程图)

Image

核心逻辑可简化为三个步骤:

  1. 根据指令计算目标格子
  2. 执行动画插值
  3. 移除已完成的指令

以下是核心代码片段

  update() {    if (!this.instance)      return    if (!this.movesQueue.length)      return
    // 计算下一步目标格子    if (!this.isMoving) {      const dir = this.movesQueue[0]      this.targetTile = { ...this.currentTile }      switch (dir) {        case 'forward':          this.targetTile.z -= 1          break        case 'backward':          this.targetTile.z += 1          break        case 'left':          this.targetTile.x -= 1          break        case 'right':          this.targetTile.x += 1          break      }
      // 先设置旋转,让小鸡朝向尝试方向      this.startRot = this.instance.rotation.y      this.endRot = getTargetRotation(dir)
      // 启动移动      this.isMoving = true      this.moveClock.start()      // 记录起始位置      this.startPos = {        x: this.currentTile.x * this.stepLength,        z: this.currentTile.z * this.stepLength,      }      this.endPos = {        x: this.targetTile.x * this.stepLength,        z: this.targetTile.z * this.stepLength,      }    }
    // 步进动画    const stepTime = this.isSpeedUp ? SPEEDUP_STEP_TIME : NORMAL_STEP_TIME // 根据加速状态调整步进时长    const progress = Math.min(1, this.moveClock.getElapsedTime() / stepTime)    this.setPosition(progress)    this.setRotation(progress)
    // 步进结束    if (progress >= 1) {      this.stepCompleted()      this.moveClock.stop()      this.isMoving = false      // 移除已完成的指令      this.movesQueue.shift()    }  }

角色现在是可以移动了,但是当玩家移动到场景边界时会出现...

Image

下面我们需要解决场景生成问题?不能一次生成太多,也不能让用户看到场景边界!

场景生成

我们需要实现动态地形生成来解决这个问题,主要考虑两个关键点:

  1. 「生成时机」:何时触发地形扩展?
  2. 「生成方式」:如何生成新的地形?

生成时机判定

让我们来看一张图

Image

这里我将「刷新时机可以被认定为当用户距离地图板边小于一定距离时触发」,用户的位置我们很容易获取到,而地图板边呢?

我们可以将this.metadata.length作为地图板边距离,毕竟在scene里,一个row的Z轴长度就为 1

  // 检查玩家距离地图末尾距离,自动扩展  checkAndExtendMap(userZ) {    // userZ 为玩家当前 z 坐标(负数,越小越远)    const remainRows = this.metadata.length - Math.abs(userZ) //距离板边的距离    if (remainRows < GENERATION_COUNT) {      // TODO: 扩充地形    }  }

地形生成

地形生成逻辑如下,而对于generateMetaRows的逻辑我不想过多赘述,使用AI将现有的metadata数据贴入上下文,大概半分钟就能生成这样一个函数。

  // 扩展地图,生成并渲染 N 个新行  extendMap(N = 10) {    const startRowIndex = this.metadata.length    const newRows = generateMetaRows( N)    this.metadata.push(...newRows)
    // 渲染新行    newRows.forEach((rowData) => {      this.rowIndex++      if (rowData.type === 'forest') {        this.addGrassRow(this.rowIndex)        this.addTreeRow(rowData.trees, this.rowIndex)      }      if (rowData.type === 'road') {        this.addRoadRow(this.rowIndex)        this.addCarRow(rowData.vehicles, this.rowIndex, rowData.direction, rowData.speed)      }    })  }
  // 检查玩家距离地图末尾距离,自动扩展  checkAndExtendMap(userZ) {    // userZ 为玩家当前 z 坐标(负数,越小越远)    const remainRows = this.metadata.length - Math.abs(userZ)    if (remainRows < GENERATION_COUNT) {      this.extendMap(GENERATION_COUNT)    }  }

最后在让我们进入游戏中试试

(这里特地把视角调远) Image

6.🚗用户碰撞检测

当前问题:无所不能的小鸡!!

目前游戏完全没有碰撞检测,玩家可以随心所欲地穿过任何物体:

Image

我只能说小鸡是懂刷分这一块的!

树木碰撞检测

树木碰撞其实要比想象中的要简单,还记得在角色移动前我们会计算下一个目标格得到一个 this.targetTile吗?我们可以根据 this.targetTile在metadata相应位置上是否存在tree而判断是否触发了树木碰撞。那么此时原先流程图的相应部分现在变成了

Image
/** * 判断目标格子是否为有效位置 * @param {{x:number, z:number}} targetTile 目标格子坐标 * @param {Array} metaData 地图元数据数组 * @returns {boolean} 是否为有效位置 */export function endsUpInValidPosition(targetTile, metaData) {  // 1. 边界检查  if (targetTile.x < MIN_TILE_INDEX || targetTile.x > MAX_TILE_INDEX)    return false  if (targetTile.z <= -5)    return false
  // 2. 检查 metaData 是否有树  const rowIndex = targetTile.z  const row = metaData[rowIndex - 1]  if (row && row.type === 'forest') {    // 检查该行是否有树在目标 x    if (row.trees.some(tree => tree.tileIndex === targetTile.x)) {      return false    }  }  return true}
  // update方法:每帧调用,处理移动逻辑  update() {    if (!this.instance)      return    if (!this.movesQueue.length)      return
    // 计算下一步目标格子    if (!this.isMoving) {      const dir = this.movesQueue[0]      const nextTarget = { ...this.currentTile }      switch (dir) {        case 'forward':          nextTarget.z -= 1          break        case 'backward':          nextTarget.z += 1          break        case 'left':          nextTarget.x -= 1          break        case 'right':          nextTarget.x += 1          break      }
      // 检查是否合法      const mapMetadata = this.experience.world.map.metadata      if (!endsUpInValidPosition(nextTarget, mapMetadata)) { // 不合法则丢弃指令        this.movesQueue.shift()        return      }//后续逻辑不变...  }
Image

增强碰撞反馈

但是这样碰撞虽然发生,但给人一点反馈没有,为了让碰撞更有感觉,我们增加了两个效果:

  • 虽然撞墙但也转向行为
  • 原地蹦跶两下意思意思行为
      if (!endsUpInValidPosition(nextTarget, mapMetadata)) {        this.setRotation(1) // 虽然撞墙但也转向行为        // 不合法,执行 yoyo 动画并丢弃本次指令        this.playYoyoAnimation(nextTarget) //原地蹦跶两下意思意思行为        this.movesQueue.shift()原地蹦跶两下意思意思行为        return      }

this.playYoyoAnimation效果类似 gsap的yoyo行为,跳向目标点随后返回原tile。

最终效果如下:

Image

汽车碰撞

那么汽车碰撞是怎么实现的呢?毕竟场景里动态元素这么多,使用octree肯定不行。我这里使用了 BOX3为车辆和用户创建了包围盒,随后判断是否车辆和用户相交进而确定游戏进程是否继续。

AABB?!这场景这么多车你用这方法?这不是越玩越卡,开头 120FPS,结尾卡成PPT。可是。。。。为什么你的电脑这么流畅,难道你用 4090来玩这个4399小游戏?

「空间分区」:只检测玩家当前所在道路行的车辆

答案是只对玩家当前所在的道路行进行碰撞测试,比如玩家 A 在道路行 31 行,那么只要求第 31 行的车辆生成包围盒并判断是否与用户相交。

Image

那么首先我们需要解决的问题是 如何获取玩家当前所在行的所有汽车mesh, 这就需要我们提前维护一个汽车快表, 将每个行的行数以及当前汽车 mesh 映射起来

这里我们回到「车辆生成」相关代码,在这里维护一个this.carMeshDict字典

    // 新增:行号到车辆mesh数组的映射    this.carMeshDict = {}
 // 初始化地图内容  initializeMap() {//....逻辑不变  }
  // 添加一行车辆  addCarRow(vehicles, rowIndex = 0, direction = false, speed = 1) {    const carRow = new Car(this.scene, this.resources, vehicles, rowIndex, direction, speed)    this.carRows.push(carRow)    // 新增:记录每行车辆mesh    this.carMeshDict[rowIndex] = carRow.getCarMeshes()  }

随后封装一个方法来帮助快速获取某行车辆, 并解决判空问题。

  // 新增:获取指定行的车辆mesh数组  getCarMeshesByRow(rowIndex) {    return this.carMeshDict[rowIndex] || []  }

最后遍历每辆汽车,逐一检测碰撞

  • 对每个汽车 Mesh,分别构建包围盒(THREE.Box3)。

  • 构建玩家的包围盒。

  • 使用 Box3.intersectsBox() 判断玩家和汽车是否有包围盒重叠(即发生碰撞)。

  update() {    // 如果游戏已结束,直接返回,防止继续执行 update 逻辑    if (this.map) {      this.map.update()      if (this.user && !this.isGameOver) {        this.map.checkAndExtendMap(this.user.currentTile.z)        // === 碰撞检测 ===        // 获取玩家mesh和所在行        const playerMesh = this.user.instance        if (playerMesh) {          const playerRow = this.user.currentTile.z          const carMeshes = this.map.getCarMeshesByRow(playerRow)          if (carMeshes.length > 0) {            // 构建玩家包围盒            const playerBox = new THREE.Box3().setFromObject(playerMesh)            for (const carMesh of carMeshes) {              const carBox = new THREE.Box3().setFromObject(carMesh)              if (playerBox.intersectsBox(carBox)) {                  this.onGameOver() //撰写你想要的结束效果              }            }          }        }        this.user.update()      }    }  }

(顺便在这贴上 `BOX3`资源释放相关帖[19])

简单的游戏结束效果包括:

  • 玩家位置重置
  • 显示结束 UI
  • 上传分数
Image

这种实现方式既保证了碰撞检测的准确性,又通过空间分区优化确保了游戏性能,即使在低端设备上也能流畅运行。

7.📞GAME UI 通信

Image

在撰写Threejs项目时,我们常常面临一个关键挑战:如何将 3D 场景 (Scene) 中的动态信息有效地传递到 2D 游戏界面(Game UI)。就比如在这个游戏中当用户获取特殊道具时,虽然放慢周围车辆的特效很炫酷,但如果缺少清晰的道具倒计时提示和氛围光晕效果,整个体验就会显得不够完整。

框架解决方案的利与弊

目前市场上已有 React Three Fiber 和 Tresjs 等响应式框架,它们通过内置机制简化了3D场景与UI之间的数据交互 (类似的 issue)。这些框架确实提供了便捷的解决方案。然而,框架学习本身需要时间成本,且可能带来项目依赖性的问题。如果用户是在一个老的3D项目上维护场景呢?

回归本质:事件驱动架构

当我们希望保持项目轻量级,或者需要更灵活的解决方案时,可以回归到事件驱动架构(风水轮流转了属于是,没准前端真的是个圈)。这种发布 - 订阅模式 (Pub-Sub) 完美适用于处理事件触发和状态变更的场景:

首先让我们看threejs的入口文件Experience实体类,他负责接受页面canvas元素,随后将Render挂载到对应canvas上,这里我们让 Experience 类继承自 EventEmitter(事件发射器),具备事件注册、触发、移除等能力。

  • 组件 / 模块通过 on('事件名', 回调) 订阅事件。

  • 其他地方通过 trigger('事件名', [参数]) 触发事件,所有订阅者收到通知。

import * as THREE from 'three'
import Camera from './camera.js'import Renderer from './renderer.js'import sources from './sources.js'import Debug from './utils/debug.js'import EventEmitter from './utils/event-emitter.js'import IMouse from './utils/imouse.js'import Resources from './utils/resources.js'import Sizes from './utils/sizes.js'import Stats from './utils/stats.js'import Time from './utils/time.js'import PhysicsWorld from './world/physics-world.js'import World from './world/world.js'
let instance
export default class Experience extends EventEmitter {  constructor(canvas) {    // 确保单一实例    if (instance) {      return instance    }
    super()    instance = this
    // Global access    window.Experience = this
    this.canvas = canvas
    // 实例化所有类组件    this.debug = new Debug()    this.stats = new Stats()    this.sizes = new Sizes()    this.time = new Time()    this.scene = new THREE.Scene()    this.camera = new Camera(true)    this.renderer = new Renderer()    this.resources = new Resources(sources)    this.physics = new PhysicsWorld()    this.iMouse = new IMouse()    this.world = new World()
    this.sizes.on('resize', () => {      this.resize()    })
    this.time.on('tick', () => {      this.update()    })
    // 事件监听测试    this.on('pause', () => {      this.isPaused = true // 设置为暂停    })    this.on('resume', () => {      this.isPaused = false    })  }
  resize() {    this.camera.resize()    this.renderer.resize()  }
  update() {    if (this.isPaused)      return    this.camera.update()    this.world.update()    this.renderer.update()     this.stats.update()    this.iMouse.update()  }}

页面 UI 操控 游戏场景

完成threejs项目入口文件继承了发布订阅类之后,我们就能在获取唯一实例并调用trigger&on方法来进行Threejs scene & game UI之间的信息传输 Image

Image

游戏场景操作页面 UI

Image
Image

8.🎊朋友,端午节快乐!六一儿童节快乐!

随着最后一个commit被push到远端,这个承载着心意的小项目安静地躺在 GitHub 上。还记得第一次敲下前端代码时的纯粹吗?那时的我,不在乎 deadline 的催促,不关心 KPI 的考核,只想要创造一个能让人眼前一亮的页面。

是时候将他分享出去了!

项目的开源方便给你的小孩、身边朋友、所爱之人埋下彩蛋,我只希望这个游戏能带来单纯的快乐,所以右上角的排行榜会在每天凌晨刷新,弱化他的竞技性。Work Life Balance 不只是口号,这个小游戏就是我的践行方式。愿这些跳动的代码能为你和所爱之人带来欢乐! 「各位掘友,端午节和六一儿童节 节日快乐」!

愿我们永远保持对生活的热爱,对代码的激情,就像第一次写出 "Hello World" 时那样满怀欣喜。

(小提示:试着在游戏里寻找隐藏的粽子图案哦~)

Image

9.最后的一些话

技术的未来与前端迁移

随着 AI 技术的快速发展,各类技术的门槛正在大幅降低,以往被视为高门槛的 3D 技术也不例外。与此同时,过去困扰开发者的数字资产构建成本问题,也正在被最新的 3D generation 技术所攻克。这意味着,在不久的将来,前端开发将迎来一次技术迁移,开发者需要掌握更新颖的交互方式和更出色的视觉效果。

本专栏的愿景

本专栏的愿景是通过分享 Three.js 的中高级应用和实战技巧,帮助开发者更好地将 3D 技术应用到实际项目中,打造令人印象深刻的 Hero Section。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D 技术的普及和应用。

加入掘金社区,共同成长

如果您对 Threejs 这个 3D 图像框架很感兴趣,或者您也深信未来国内会涌现越来越多 3D 设计风格的网站,欢迎加入 「稀土掘金社区」 。

此外,如果您很喜欢 Threejs 又在烦恼其原生开发的繁琐,那么我诚邀您尝试 「Tresjs」 和 「TvTjs」, 他们都是基于 Vue 的 Threejs 框架。 「TvTjs」 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!