我又写出了被 Three.js 官推转发的项目?!(源码分享)
0.👁️前置条件
🎉 🎉 好久不见,亲爱的小伙伴们!欢迎来到这个充满童趣的技术分享!在儿童节即将到来之际,我特别准备了这篇轻松有趣的 Three.js 实战指南。
🎮 你将学到什么?
Three.js基础应用3D定制化资源获取技巧简易 Three.js游戏开发思路
💡 无需担心难度!本文专为儿童节设计,技术内容简单易懂,只要掌握Three.js 基础用法就能轻松上手!
1.🚀Page 预览
🎂 又一年儿童节将至,看着日历上跳动的数字,忽然意识到自己早已不是那个会收到节日礼物的小朋友了。现在的我,日复一日地在公司里:
敲着后台管理系统的 CURD调试着微信小程序的接口 对着需求文档发呆
「⚠️ 系统警报:检测到开发者快乐指数跌破警戒线!」
「🚀 正在注入童趣补丁...」
「🎮 强制启动儿童节特别模式....」
画面预览
小心飞驰的车辆,收集道具,创造你的最高纪录!
新手指南
PC 端:使用方向键或 WASD 控制小鸡移动 手机端:滑动屏幕即可轻松操控(适配完美!) 游玩的新手指南您可以在点击右上角的暂停按钮看到,并且您可以再此为自己的角色取名,以便可以在排行榜上找到自己
相关地址
🖥️ 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] ,此网站不需要魔法。
此外如果您正在使用手机浏览这篇文章,我非常建议您直接点击进入游玩,因为这个游戏我已经做了移动端适配。滑动将会以最符合操纵直觉的操控方式呈现在您面前
(小提示: 游戏会记录你的最佳成绩,在死亡时将成绩发送至排行榜单!快来挑战好友吧!🐣💨)
本着开源精神与诚实创作的原则,我必须说明:本项目的核心机制灵感来源于业界前辈的智慧结晶。特别感谢:「Hunor Borbely」 的优秀教程:《Crossy Road 风格游戏开发指南》[9]
参考内容有:
游戏元数据 (metadata) 的结构设计 角色与车辆的碰撞检测逻辑
(指路原作者)
🌟 官方认可
很荣幸这个项目同样获得了 Three.js 官推的转发认可:
( Tips: 站在巨人的肩膀上,我们才能看得更远!🚀)
2.🎮 Three.js 游戏三要素
我之前在 2025 年了,我不允许有前端不会用 Trae 让页面 Hero Section 变得高级!!!(Threejs)[10] 提到过 Three.js三要素,他们分别是场景、相机以及渲染器。
什么是游戏开发三要素?
而在 Three.js 游戏开发中,我们同样有三大核心架构:
| 「Scene」 | ||
| 「Game UI」 | ||
| 「Metadata」 |
为什么这很重要?
理解这三个要素的关系至关重要:
「Scene」 负责 "演什么" - 处理 3D 模型、光照、物理效果 「UI」 负责 "怎么看" - 控制分数显示、菜单系统 「Metadata」 负责 "怎么玩" - 管理游戏状态、得分规则、角色属性
3.📦资源获取
3D Model 资源生成
在😮😮😮 我写出了被 Threejs 官推转发的项目🚀✨?![11] 文章中我曾写过 「3D 客制化资源的获取与处理」,这里我不做过多赘述,只做简单举例
就比小鸡模型的获取我先通过 awesome-gpt4o-images[12] 给的提示参考图以及相应的提示语生成一张背景单一,结构合理的 2.5D 小鸡图片,
我这里将参考图奉上,您有空也可以试试这些神奇的提示词
随后再将图片导入 AI 3D generation 平台,我比较喜欢的是 hyper3D[13]。 过 30 秒即可生成对应的模型。看起来还不错。
游戏背景音乐资源获取
游戏背景音乐则是使用 Suno AI 进行生成的。老实说我觉得还挺好听的。
但是如果想要获取真正的游戏背景音乐以及各种游戏音效可以访问 Opengameart 的音乐板块[15]
❝输入风格描述:"8-bit retro game music with cheerful melody"
❞
如果对于这块教程还有疑惑可以点击这里,在前一篇文章中我有着详细的介绍。
AI 工具让独立开发者也能拥有专业级美术资源, 随着 AI 数字资产的获取门槛降低,我相信未来对于我们网站开发人员来说真正的上限应该是「视觉想象力」与「数字美感素养」。
4.🤖metadata & 基础场景搭建
现在让我们来看一看原版的小鸡过马路游戏场景
现在的首要目的是分析出当前场景所需的 metadata, 在不考虑场景自生成的情况下利用metadata在 Threejs中搭建出游戏的基本场景。
首先我们可以看到图中小鸡不断前进,场景会由数个不同的 “行” 拼接而成,他们可能是 “草地“,也可能是” 柏油路“ 然后每个行上都有着相应的物体,"草地" 上会出现高矮不一的 "树木",而 "柏油路" 上会出现行驶方向向左或者向右的汽车
那么metadata应该如何更好的囊括这些信息呢?我是这样做的:
现在我们先采用静态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 = scenethis.object3d = object3dthis.rowIndex = rowIndexthis.tiles = []this.createGrassRow()}// 生成一行草地createGrassRow() {// 获取 tile 资源(假设资源名为 'grass',如有不同请调整)const tileResource = this.object3dtileResource.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)
动态元素生成
现在我们需要向森林行和道路行上添加对应的物体,这些物体并不是固定的某一行有多少多少个,而是根据在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 = scenethis.resources = resourcesthis.trees = treesthis.rowIndex = rowIndexthis.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 = scenethis.resources = resourcesthis.time = this.experience.timethis.vehicles = vehiclesthis.rowIndex = rowIndexthis.direction = directionthis.speed = speedthis.timeMultiplier = 1this.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)// 新增:记录每行车辆meshthis.carMeshDict[rowIndex] = carRow.getCarMeshes()}
最后我们只需要在给车辆增加移动效果,让车辆随着requestAnimationFrame更新不断更新mesh的位移, 向指定方向direction移动即可。记得别忘了超出边界要及时重置车辆位置哦。
// 更新车辆位置(可用于动画)update() {// 获取全局已用时间,单位ms,转为秒const t = this.time.elapsed * 0.03 * this.timeMultiplierthis.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.5const amp1 = 0.02const freq2 = 4.3const amp2 = 0.01// 计算抖动偏移const offsetY = Math.sin(t * freq1 + shake.phase) * amp1 + Math.cos(t * freq2 + shake.phase * 1.3) * amp2car.position.y = shake.baseY + offsetY})}
(最后效果图)
这个我们就已经完成了根据 metadata生成对应的场景内容的功能,现在您可以为用户生成一个较为简单的初始场景,虽然我们后面会利用随机生成的metadata数据生成地形,但相信我,我曾经生成过除去出生点后面 6、7 行都是马路。毕竟玩家刚复活下一步就要开始过马路不是一个很好的游戏体验。
5.🐤角色移动与场景生成
引入角色
首先,我们需要将小鸡模型添加到场景中并进行适当调整:
// 加载并放置小鸡模型initChicken() {// 获取 instance 资源const chickenResource = this.resources.items.bigChickenif (!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)// 添加到 agentGroupthis.agentGroup.add(this.instance)}
角色移动
随后需要对小鸡加入按键监听以及移动控制,在之前的项目😮😮😮 我写出了被 Threejs 官推转发的项目🚀✨?!中,我们已经探讨过角色移动的基本原理。这个项目我沿用了当时的角色按键移动响应和转向逻辑,在这里就不炒冷饭去解释_为什么使用 event.code_ 或者_角色转向 BUG_。 「直接重点解释当前项目和上一个项目移动的不同点:引入入了移动队列 (movesQueue) 机制」
首先我们来看在游戏中正常移动是什么样的。
可以看到小鸡在场景中每次都是「每次按键都对应一个完整的移动单位,不会因按键时间长短影响移动距离」。这就是为什么我们相较于前作的移动逻辑多了一个movesQueue,movesQueue的作用是作为移动指令队列,存储待执行的移动方向(如 forward、left)。用户可以连续输入多个移动指令,使动画未完成,用户的后续输入也会被记录。避免了突然变向等不自然现象。
以下是 this.movesQueue 的监听逻辑
listenKeyboard() {window.addEventListener('keydown', (event) => {if (this.experience.isPaused) {return}let move = nullswitch (event.code) {case 'ArrowUp':case 'KeyW':move = 'forward'breakcase 'ArrowDown':case 'KeyS':move = 'backward'breakcase 'ArrowLeft':case 'KeyA':move = 'left'breakcase 'ArrowRight':case 'KeyD':move = 'right'breakdefault:break}// 只在首次按下时 pushif (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中进行相应指令的执行和释放。这里有一个具体的流程图如下:
(移动流程图)
核心逻辑可简化为三个步骤:
根据指令计算目标格子 执行动画插值 移除已完成的指令
以下是核心代码片段
update() {if (!this.instance)returnif (!this.movesQueue.length)return// 计算下一步目标格子if (!this.isMoving) {const dir = this.movesQueue[0]this.targetTile = { ...this.currentTile }switch (dir) {case 'forward':this.targetTile.z -= 1breakcase 'backward':this.targetTile.z += 1breakcase 'left':this.targetTile.x -= 1breakcase 'right':this.targetTile.x += 1break}// 先设置旋转,让小鸡朝向尝试方向this.startRot = this.instance.rotation.ythis.endRot = getTargetRotation(dir)// 启动移动this.isMoving = truethis.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()}}
角色现在是可以移动了,但是当玩家移动到场景边界时会出现...
下面我们需要解决场景生成问题?不能一次生成太多,也不能让用户看到场景边界!
场景生成
我们需要实现动态地形生成来解决这个问题,主要考虑两个关键点:
「生成时机」:何时触发地形扩展? 「生成方式」:如何生成新的地形?
生成时机判定
让我们来看一张图
这里我将「刷新时机可以被认定为当用户距离地图板边小于一定距离时触发」,用户的位置我们很容易获取到,而地图板边呢?
我们可以将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.lengthconst 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)}}
最后在让我们进入游戏中试试
(这里特地把视角调远)
6.🚗用户碰撞检测
当前问题:无所不能的小鸡!!
目前游戏完全没有碰撞检测,玩家可以随心所欲地穿过任何物体:
我只能说小鸡是懂刷分这一块的!
树木碰撞检测
树木碰撞其实要比想象中的要简单,还记得在角色移动前我们会计算下一个目标格得到一个 this.targetTile吗?我们可以根据 this.targetTile在metadata相应位置上是否存在tree而判断是否触发了树木碰撞。那么此时原先流程图的相应部分现在变成了
/*** 判断目标格子是否为有效位置* @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 falseif (targetTile.z <= -5)return false// 2. 检查 metaData 是否有树const rowIndex = targetTile.zconst row = metaData[rowIndex - 1]if (row && row.type === 'forest') {// 检查该行是否有树在目标 xif (row.trees.some(tree => tree.tileIndex === targetTile.x)) {return false}}return true}// update方法:每帧调用,处理移动逻辑update() {if (!this.instance)returnif (!this.movesQueue.length)return// 计算下一步目标格子if (!this.isMoving) {const dir = this.movesQueue[0]const nextTarget = { ...this.currentTile }switch (dir) {case 'forward':nextTarget.z -= 1breakcase 'backward':nextTarget.z += 1breakcase 'left':nextTarget.x -= 1breakcase 'right':nextTarget.x += 1break}// 检查是否合法const mapMetadata = this.experience.world.map.metadataif (!endsUpInValidPosition(nextTarget, mapMetadata)) { // 不合法则丢弃指令this.movesQueue.shift()return}//后续逻辑不变...}
增强碰撞反馈
但是这样碰撞虽然发生,但给人一点反馈没有,为了让碰撞更有感觉,我们增加了两个效果:
虽然撞墙但也转向行为 原地蹦跶两下意思意思行为
if (!endsUpInValidPosition(nextTarget, mapMetadata)) {this.setRotation(1) // 虽然撞墙但也转向行为// 不合法,执行 yoyo 动画并丢弃本次指令this.playYoyoAnimation(nextTarget) //原地蹦跶两下意思意思行为this.movesQueue.shift()原地蹦跶两下意思意思行为return}
this.playYoyoAnimation效果类似 gsap的yoyo行为,跳向目标点随后返回原tile。
最终效果如下:
汽车碰撞
那么汽车碰撞是怎么实现的呢?毕竟场景里动态元素这么多,使用octree肯定不行。我这里使用了 BOX3为车辆和用户创建了包围盒,随后判断是否车辆和用户相交进而确定游戏进程是否继续。
AABB?!这场景这么多车你用这方法?这不是越玩越卡,开头 120FPS,结尾卡成PPT。可是。。。。为什么你的电脑这么流畅,难道你用 4090来玩这个4399小游戏?
「空间分区」:只检测玩家当前所在道路行的车辆
答案是只对玩家当前所在的道路行进行碰撞测试,比如玩家 A 在道路行 31 行,那么只要求第 31 行的车辆生成包围盒并判断是否与用户相交。
那么首先我们需要解决的问题是 如何获取玩家当前所在行的所有汽车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)// 新增:记录每行车辆meshthis.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.instanceif (playerMesh) {const playerRow = this.user.currentTile.zconst 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 上传分数
这种实现方式既保证了碰撞检测的准确性,又通过空间分区优化确保了游戏性能,即使在低端设备上也能流畅运行。
7.📞GAME UI 通信
在撰写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 instanceexport default class Experience extends EventEmitter {constructor(canvas) {// 确保单一实例if (instance) {return instance}super()instance = this// Global accesswindow.Experience = thisthis.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)returnthis.camera.update()this.world.update()this.renderer.update()this.stats.update()this.iMouse.update()}}
页面 UI 操控 游戏场景
完成threejs项目入口文件继承了发布订阅类之后,我们就能在获取唯一实例并调用trigger&on方法来进行Threejs scene & game UI之间的信息传输
游戏场景操作页面 UI
8.🎊朋友,端午节快乐!六一儿童节快乐!
随着最后一个commit被push到远端,这个承载着心意的小项目安静地躺在 GitHub 上。还记得第一次敲下前端代码时的纯粹吗?那时的我,不在乎 deadline 的催促,不关心 KPI 的考核,只想要创造一个能让人眼前一亮的页面。
是时候将他分享出去了!
项目的开源方便给你的小孩、身边朋友、所爱之人埋下彩蛋,我只希望这个游戏能带来单纯的快乐,所以右上角的排行榜会在每天凌晨刷新,弱化他的竞技性。Work Life Balance 不只是口号,这个小游戏就是我的践行方式。愿这些跳动的代码能为你和所爱之人带来欢乐! 「各位掘友,端午节和六一儿童节 节日快乐」!
愿我们永远保持对生活的热爱,对代码的激情,就像第一次写出 "Hello World" 时那样满怀欣喜。
(小提示:试着在游戏里寻找隐藏的粽子图案哦~)
9.最后的一些话
技术的未来与前端迁移
随着 AI 技术的快速发展,各类技术的门槛正在大幅降低,以往被视为高门槛的 3D 技术也不例外。与此同时,过去困扰开发者的数字资产构建成本问题,也正在被最新的 3D generation 技术所攻克。这意味着,在不久的将来,前端开发将迎来一次技术迁移,开发者需要掌握更新颖的交互方式和更出色的视觉效果。
本专栏的愿景
本专栏的愿景是通过分享 Three.js 的中高级应用和实战技巧,帮助开发者更好地将 3D 技术应用到实际项目中,打造令人印象深刻的 Hero Section。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D 技术的普及和应用。
加入掘金社区,共同成长
如果您对 Threejs 这个 3D 图像框架很感兴趣,或者您也深信未来国内会涌现越来越多 3D 设计风格的网站,欢迎加入 「稀土掘金社区」 。
此外,如果您很喜欢 Threejs 又在烦恼其原生开发的繁琐,那么我诚邀您尝试 「Tresjs」 和 「TvTjs」, 他们都是基于 Vue 的 Threejs 框架。 「TvTjs」 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!