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

12,662 阅读22分钟

0.👁️前置条件

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

🎮 你将学到什么?

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

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

1. 🚀Page 预览

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

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

1.jpg

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

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

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

画面预览

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

2.png

1.gif

新手指南

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

3.png

相关地址

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

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

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

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

参考内容有:

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

(指路原作者) 5.png

🌟 官方认可

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

6.png

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

2.🎮 Three.js 游戏三要素

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

什么是游戏开发三要素?

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

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

为什么这很重要?

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

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

7.png

3.📦资源获取

3D Model 资源生成

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

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

9.png

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

8.jpeg

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

3.gif

游戏背景音乐资源获取

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

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

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

10.png

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

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

4.🤖metadata & 基础场景搭建

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

11.png

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

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

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

12.png

现在我们先采用静态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 )

13.png

动态元素生成

现在我们需要向森林行和道路行上添加对应的物体,这些物体并不是固定的某一行有多少多少个,而是根据在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()
  }

14.png

最后我们只需要在给车辆增加移动效果,让车辆随着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
    })
  }

( 最后效果图 ) 4.gif

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

15.png

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)机制

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

5.gif

可以看到小鸡在场景中每次都是每次按键都对应一个完整的移动单位,不会因按键时间长短影响移动距离。这就是为什么我们相较于前作的移动逻辑多了一个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中进行相应指令的执行和释放。这里有一个具体的流程图如下:

(移动流程图)

16.png

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

  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()
    }
  }

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

6.gif

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

场景生成

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

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

生成时机判定

让我们来看一张图

17.png

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

我们可以将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)
    }
  }

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

( 这里特地把视角调远 ) 7.gif

6.🚗用户碰撞检测

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

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

8.gif

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

树木碰撞检测

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

18.png

/**
 * 判断目标格子是否为有效位置
 * @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
      }
	//后续逻辑不变...
  }

9.gif

增强碰撞反馈

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

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

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

最终效果如下:

10.gif

汽车碰撞

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

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

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

参考文章: crossRoad 撞击检测章节

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

19.png

那么首先我们需要解决的问题是 如何获取玩家当前所在行的所有汽车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资源释放相关帖)

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

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

11.gif

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

7.📞GAME UI 通信

12.gif

在撰写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之间的信息传输 20.png

13.gif

游戏场景操作页面UI

21.png

14.gif

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

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

是时候将他分享出去了!

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

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

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

22.png

9.最后的一些话

技术的未来与前端迁移

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

本专栏的愿景

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

加入社区,共同成长

如果您对 Threejs 这个 3D 图像框架很感兴趣,或者您也深信未来国内会涌现越来越多 3D 设计风格的网站,欢迎加入 ice 图形学社区。这里是国内 Web 图形学最全的知识库,致力于打造一个全新的图形学生态体系!您可以在认证达人里找到我这个 Threejs 爱好者和其他大佬。

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

上期回顾: 我写出了被 Threejs 官推转发的项目——3D 个人简历

掘金站内链接: juejin.cn/post/749891…

16.gif

下期预告: 😲我写出了 Threejs 版城市天际线?!(官推转发🥳+ 源码分享🚀

文章已于 8月15日撰写完毕,掘金站内链接:juejin.cn/post/753871…

01.gif