我写出了 Threejs 版城市天际线?!
0.好久不见
各位早上、中午、晚上好!我是鸽子王何贤,距离上次更新已时隔两月,在此深表歉意。除近期本职工作较忙外,还有一些特殊原因导致鸽了这么久:

Blender mcp
的作者联系我说有米奇妙妙小工具,我说这太好了,一来二去可能就耽误了本次发文章的时间,还是很对不起运营小哥的,在这说一声对不起。但请允许我将功补过------在完成Beta
版后第一时间与大家分享。
1.Page 预览
话说老何长时间被电子 ED 困扰,没什么游戏好玩,又不想干农活,于是就在 Steam
上找起了游戏。鼠标在夏日促销页面上不断翻动,商品琳琅满目但都提不起兴趣。
不知不觉就就找到了一款城建类卡通风游戏 《卡牌城镇Cardboard Town》

老何平时的游戏风格都是战斗爽,从来没玩过城建游戏哇!一下子就给陷进去了。后面两天老何天天就是白天看攻略,晚上通宵当市长。久而久之老何就连白天都想着能不能开一把。但是公司人多眼杂,当众玩游戏只怕游戏10点开的,12点就开始办离职申请了。但是规矩是死的,人是活的。上班玩游戏的胆子没有,但是...借着敲代码的名义开发一个游戏来玩的胆子大大的有。
因此,这个项目就在老板的眼皮底下诞生了。 由此,在老板眼皮底下诞生了基于Three.js+Vue
的城建游戏------《CubeCity》。
1.1 粗略概览(平台限制 画质需要压缩)

1.2 大致功能概览

1.3 玩法介绍
游戏主要围绕四种操作模式展开
- 🏗️ 建造模式 (BUILD):
- 快捷键 B
- 从左侧面板选择你想要的建筑。
- 在地图上的可用地皮上点击即可放置建筑,实时预览模型和高亮提示让操作更直观。

-
🔍 选择模式 (SELECT):
- 快捷键 S
- 点击建筑查看详细信息,如这一级的产出 & 污染,以及下一级的产出 & 污染等。
- 满足条件时可对建筑进行升级,提升其功能和产出。

-
🚚 搬迁模式 (RELOCATE):
-
快捷键 R
-
选中一个已建好的建筑,然后点击一个空地,即可轻松完成搬迁。
-
在BUILD 放置后按 R 键,可以旋转建筑以适应你的城市布局。
-

-
💣 拆除模式 (DEMOLISH):
- 快捷键 D
- 切换到此模式,点击不再需要的建筑即可将其拆除。
- 拆除建筑会返还部分建造成本。

1.4 建筑相互作用


PC端在线预览地址 (需要魔法):cube-city.vercel.app/
源码地址 (需要魔法):github.com/hexianWeb/C...
Threejs 转发贴

原贴 & 地址

2.游戏基建结构
注意,本项目纯属整活项目!开发游戏项目请使用游戏引擎!!!
注意,本项目纯属整活项目!开发游戏项目请使用游戏引擎!!!
注意,本项目纯属整活项目!开发游戏项目请使用游戏引擎!!!
在 😲我又写出了被 Three.js 官推转发的项目?!🥳🥳(源码分享) 中曾提到过游戏开发三要素

简单来说
-
Scene 负责"演什么" - 处理3D模型、光照、物理效果
-
UI 负责"怎么看" - 控制分数显示、菜单系统
-
Metadata 负责"怎么玩" - 管理游戏状态、得分规则、角色属性
接下来我也会从这三个方面大致的介绍项目运行的基本原理,但想在一篇文章内从头到尾解释清楚项目中所有的细节怎么实现不太现实。不过如果这篇文章点赞+收藏 超过 100
。我会为这个项目单开一个栏目,从 需求分析、项目规划、美工素材处理、背景音乐生成、代码撰写、项目管理到最后的部署从头到尾解释一遍。谢谢大家支持。
2.1 元数据体系与规划 (Metadata)
核心思想是把"城市事实"与"三维表现"彻底解耦:一切以 Pinia 的 metadata 为"单一事实源(Single Source of Truth)",三维场景只是其投影和可视化。 常有人调侃游戏玩家:"真是好笑,一堆数据当宝?!" 事实确实如此。不同的表现形式基于统一的数据,比如这个项目。若使用 2D 界面显示数据,也一样可以玩(例如在地图格子上玩简易城建游戏):

那么接下来我来介绍一下Metadata
是如何规划的,有一点我们绝对不能忘:任何业务逻辑必须"元数据优先,三维从属"。
2.1.1 网格与坐标约定

《CubeCity》的主要玩法是用户在一块 17×17 的地皮上建造、销毁、迁移、升级建筑,并随着游戏时间增加金币。城市采用 17×17 的离散网格(默认 SIZE = 17),每个格子是一个 Tile(地皮)。因此,我在 Store(Pinia)
中定义的初始城市就是一个 17*17
的二维数组。
js
export const useGameState = defineStore('gameState', {
state: () => ({
metadata: Array.from({ length: 17 }, _ =>
Array.from({ length: 17 }, _ => ({
type: 'grass',
building: null,
direction: 0,
}))),
currentMode: 'build', //当前玩家选择的模式
selectedBuilding: null, //当前玩家选择的地皮
}),
2.1.2 单格 Tile 的数据模型
当某块地皮上存在建筑时,则会在对应下标的元数据中填入相应的建筑信息
在后续我会提到游戏种各个类型建筑都具体起到哪些作用,在这里仅简单介绍一下存放存档时的对象
json
{
"type": "ground", // 地形:grass/ground/road ...
"building": "factory", // 建筑类型 如 民宿、工厂、商店
"direction": 0, // 朝向:0/1/2/3(右/下/左/上)
"level": 1, // 建筑等级
"detail": { // 后续会提到具体各项 props
"coinOutput": 70,
"powerUsage": 40,
"pollution": 22,
"population": 20,
"category": "industrial" // 建筑类别 主要分三大类 住房 工业 商业
},
"outputFactor": 1 // 产出系数(相互作用或全局事件影响)
}
js
export const useGameState = defineStore('gameState', {
// ....
// 更新 metadata 地皮的 action
action: {
setTile(x, y, patch) {
Object.assign(this.metadata[x][y], patch)
},
updateTile(x, y, patch) {
Object.assign(this.metadata[x][y], patch)
},
getTile(x, y) {
return this.metadata?.[x]?.[y] || null
},
}
})
建造模式点击地皮时action
:

这样做有一些好处:比如当我们在做聚合计算(收入/人口/电力/污染)时可直接遍历整个metadata
,简化心智模型。
计算每日总收入的getters
js
export const useGameState = defineStore('gameState', {
state: () => ({
// 核心游戏状态
metadata: Array.from({ length: 17 }, _ =>
Array.from({ length: 17 }, _ => ({
type: 'grass',
building: null,
direction: 0,
}))),
currentMode: 'build',
selectedBuilding: null,
// 游戏时间和经济
gameDay: 1,
credits: 3000,
// ... 其余同上
}),
getters: {
/**
* 计算每日总收入(直接使用metadata中的detail,大幅提升性能)
* @param {object} state - 游戏状态
* @returns {number} 总收入
*/
dailyIncome: (state) => {
let totalIncome = 0
state.metadata.forEach((row, x) => {
row.forEach((tile, y) => {
//遍历有建筑的地皮进行汇总计算
if (tile.building && tile.detail) {
// 使用高效函数:自动判断是否需要相互作用计算(后面会讲到)
const income = getEffectiveBuildingValue(state, x, y, 'coinOutput')
totalIncome += income
}
})
})
return totalIncome
},
// ... 其余同上
}
假设现实中每5S
就类比游戏世界的一天,实现金币增加功能:定时器定时 + 相关逻辑即可:
- 构建下一天逻辑,有关
Metadata
时间的逻辑都在此action
中执行
js
export const useGameState = defineStore('gameState', {
// 省略...
getters: {
/**
* 计算每日总收入(直接使用metadata中的detail,大幅提升性能)
* @param {object} state - 游戏状态
* @returns {number} 总收入
*/
dailyIncome: (state) => {
let totalIncome = 0
state.metadata.forEach((row, x) => {
row.forEach((tile, y) => {
//遍历有建筑的地皮进行汇总计算
if (tile.building && tile.detail) {
// 使用高效函数:自动判断是否需要相互作用计算(后面会讲到)
const income = getEffectiveBuildingValue(state, x, y, 'coinOutput')
totalIncome += income
}
})
})
return totalIncome
},
// ... 其余同上
},
action: {
/**
* 进入下一天,更新金币和稳定度
*/
nextDay() {
// 经济系统更新
this.credits += this.dailyIncome
this.gameDay++
// ... 其余同上
},
}
2.1.3 从元数据到三维
vite-three-js 框架结构速览(给没接触过的同学)
最先开始为了防止您没有接触过任何我之前写过的项目,我将向您展示我是如何组织我的代码(师承Bruno Simon ),你可以把他看做一份High Level View
。
-
核心思想:以 Experience 单例为中心,统一管理 Three.js 场景、渲染器、相机、资源加载、时间、尺寸、交互、调试等。任何 3D 组件都通过 new Experience() 获取全局依赖,保持解耦与一致风格。
jsimport Experience from './experience.js' export default class Your3DComponent { constructor() { // 获取 Experience 单例实例 this.experience = new Experience() // 通过 experience 实例访问核心组件和工具 this.scene = this.experience.scene // THREE.Scene 实例 this.resources = this.experience.resources // 资源加载器实例 (Resources) this.camera = this.experience.camera.instance // THREE.Camera 实例 (透视或正交) this.renderer = this.experience.renderer.instance // THREE.WebGLRenderer 实例 this.time = this.experience.time // 时间控制器实例 (Time) this.sizes = this.experience.sizes // 尺寸管理器实例 (Sizes) this.iMouse = this.experience.iMouse // 鼠标跟踪器实例 (IMouse) this.debug = this.experience.debug // 调试 UI 实例 (Debug) this.physics = this.experience.physics // 物理世界实例 (PhysicsWorld) this.stats = this.experience.stats //性能监控实例 (Stats) this.canvas = this.experience.canvas // HTML Canvas 元素 // ... 其他可能需要的实例 } update() { // 如果需要逐帧更新,从 this.experience.time 取时间对象 } resize() { // 如果需要响应尺寸变化 } }
-
Vue 与 Three.js 通信(解耦)
全局状态用 Pinia(如模式、选中类型),即时事件用 mitt (或者任何一类具有发布者订阅者模式的功能函数)。
如果您对整体项目没有较完整的概念,这边也建议您去阅读 😲我又写出了被 Three.js 官推转发的项目?!🥳🥳(源码分享)。
把Metadata
用 3D
场景呈现出来的流程可分为四步:Pinia 元数据读取 → City 按网格实例化 → Tile 生成地形与挂载建筑 → Building 初始化模型与状态效果。
-
Pinia 是事实源:用于提供
Metadata
中的具体信息,如 type/building/direction/level/detail。 -
City 是投影器:按网格遍历 metadata,生成 Tile 并挂到 city.root。
-
Tile 是地皮容器:根据 type 决定草地/地面层显隐;根据 building/level/direction 实例化建筑。
-
Building 是可视实体:加载 GLTF 或占位体,按 direction 确定建筑方向。

World.js: 管理 City 类的挂载和更新
js
export default class World {
constructor() {
this.experience = new Experience() //
this.scene = this.experience.scene
this.resources = this.experience.resources
this.resources.on('ready', () => {
// 实例化城市地皮
this.city = new City()
})
}
update() {
// 若 city 有 update 行为可调用
if (this.city && this.city.update) {
this.city.update()
}
}
}
City.js: 从 Pinia 读取并实例化网格地皮(Tile)
js
import { useGameState } from '@/stores/useGameState.js'
export default class City {
constructor() {
this.experience = new Experience()
this.scene = this.experience.scene
this.resources = this.experience.resources
// 初始化地皮
this.initTiles()
// 调试面板
if (this.debug.active) {
this.debugInit()
}
}
//初始化 17x17 地皮,分布在 XOZ 平面 -8~+8
initTiles() {
const gameState = useGameState()
const { metadata } = storeToRefs(gameState)
const meta = metadata.value
for (let x = 0; x < this.size; x++) {
const row = []
for (let y = 0; y < this.size; y++) {
const tileMeta = meta[x]?.[y] || { type: 'grass', building: null }
const tile = new Tile(x, y, {
type: tileMeta.type,
building: tileMeta.building,
direction: tileMeta.direction !== undefined ? tileMeta.direction : 0,
level: tileMeta.level !== undefined ? tileMeta.level : 0,
})
row.push(tile)
this.root.add(tile)
}
this.meshes.push(row)
}
}
Tile.js 负责呈现地皮与建筑的模型
js
// Tile 类,代表单个地皮格子,继承 SimObject (具体 SimObject作用在后面会说,现在只需要把他等同于 Object3D 类即可)
export default class Tile extends SimObject {
constructor(x, y, { type = 'grass', building = null, direction = 0, level = 0 } = {}) {
this.experience = new Experience()
this.scene = this.experience.scene
this.resources = this.experience.resources
//...some code
//加载草地资源
this.grassMesh = grassResource
? grassMesh
: new THREE.Mesh(
new THREE.BoxGeometry(0.98, 0.2, 0.98),
new THREE.MeshStandardMaterial({ color: '#579649' }),
)
this.grassMesh.position.set(0, 0, 0)
this.grassMesh.scale.set(0.98, 1, 0.98)
this.grassMesh.userData = this
this.grassMesh.name = `${this.name}-grass`
//加载平地资源
const groundResource = resources.items.ground ? resources.items.ground : null
this.groundMesh = groundResource
? this.initMeshFromResource(groundResource)
: new THREE.Mesh(
new THREE.BoxGeometry(1, 0.2, 1),
new THREE.MeshStandardMaterial({ color: '#a89984' }),
)
this.groundMesh.position.set(0, 0.01, 0) // 稍微高于 grass,避免 z-fighting
this.groundMesh.scale.set(0.98, 1, 0.98)
this.groundMesh.userData = this
this.groundMesh.name = `${this.name}-ground`
this.groundMesh.visible = (type === 'ground') // 初始是否显示
this.grassMesh.add(this.groundMesh)
this.add(this.grassMesh) //层级为 Tile--> grassMesh(groundMesh) --> building
// 如果有建筑,加载建筑实例
if (building) {
this.setBuilding(building, level, direction)
}
// 切换地皮类型(实际上是控制 ground mesh 的显示与隐藏)
setType(type) {
this.type = type
this.groundMesh.visible = (type === 'ground')
}
// 创建并添加建筑实例
setBuilding(type, level = 1, direction = 0) {
this.removeBuilding()
const buildingData = BUILDING_DATA[type]
const levelData = buildingData.levels[level]
const options = { buildingData, levelData, position: { x: this.x, y: this.y } }
const buildingInstance = createBuilding(type, level, direction, options)
if (buildingInstance) {
this.buildingInstance = buildingInstance
this.grassMesh.add(buildingInstance)
}
}
}
这里出现的createBuilding
实际上时一个选择类工厂,会根据传入的type
判断当前应该由那个具体建筑类进行后续实例化.
js
const BUILDING_CLASS_MAP = {
house: House,
house2: House2,
factory: Factory,
shop: Shop,
office: Office,
park: Park,
police: Police,
hospital: Hospital,
road: Road,
chemistry_factory: ChemistryFactory,
nuke_factory: NukeFactory,
fire_station: FireStation,
sun_power: SunPower,
water_tower: WaterTower,
wind_power: WindPower,
garbage_station: GarbageStation,
hero_park: HeroPark,
// 其他建筑类型可在此扩展
}
export function createBuilding(type, level = 1, direction = 0, options = {}) {
const Cls = BUILDING_CLASS_MAP[type]
if (Cls) {
return new Cls(type, level, direction, options)
}
return null
}

到这里就可以将 Metadata
转换为 3D
场景了。
在Metadata 0-0 上填写建筑信息,刷新后效果如下

3.交互系统:模式驱动 + 射线拾取
首先提到交互系统我们需要填上之前提到的一个坑,就是Tile
实体类继承的 SimObject
到底起到什么作用?为什么不直接继承 Object3D
?
事实是如果你仔细观察游戏界,当用户指向某块地皮时地皮会显示出特殊的互动效果,建造模式下可建区域为绿色(不可建为绿色)、拆除模式(DEMOLISH) 下所选区域为红色、迁移模式下为蓝色。

但是Tile
& Building
类组件却没有相应实现的代码。
相信到这里你也想到这 SimObject
类的作用:承担Scene
里,交互对象的"行为"与"表现"。
3.1 SimObject 在交互系统中的作用与原理
SimObject 作为所有可交互物体的基类(继承 THREE.Object3D),统一封装了:
-
mesh 材质克隆(避免共享材质被串改)
-
选中/聚焦高亮(按模式映射不同颜色与透明度)
-
动画反馈(gsap 轻量的 y 轴 yoyo 浮动)
mesh 材质克隆
mesh 初始化:克隆 GLTF,遍历所有子节点克隆材质并开启透明;将 userData 指向自身实例,便于射线命中后反查 Tile/Building
js
// SimObject 互动基类,所有可交互对象继承自此类
export default class SimObject extends THREE.Object3D {
/** @type {THREE.Mesh?} */
#mesh = null
/** @type {THREE.Vector3} */
#worldPos = new THREE.Vector3()
/**
* @param {number} x 对象的 x 坐标
* @param {number} y 对象的 y 坐标
* @param {object} resource 可选,threejs 资源对象(如 gltf 加载结果)
*/
constructor(x = 0, y = 0, resource = null) {
super()
this.name = 'SimObject'
this.position.x = x
this.position.z = y
// 如果传入资源,自动初始化 mesh
if (resource) {
const mesh = this.initMeshFromResource(resource)
if (mesh) {
this.setMesh(mesh)
}
}
}
/**
* 从 threejs 资源对象(如 gltf 加载结果)初始化 mesh,并克隆材质
* @param {object} resource - threejs 资源对象,需包含 scene 属性
* @returns {THREE.Object3D|null}
*/
initMeshFromResource(resource) {
if (!resource || !resource.scene)
return null
// 克隆模型
const mesh = resource.scene.clone()
// 遍历所有子节点,克隆材质并设置透明,userData 指向自身
mesh.traverse((child) => {
child.userData = this
if (child instanceof THREE.Mesh && child.material) {
child.receiveShadow = true
child.castShadow = true
child.material = child.material.clone()
child.material.transparent = true
}
})
return mesh
}
}
因为**mesh.clone()
默认会"共享"材质引用(以及几何体引用)**。也就是说,clone()
并不会对 material
做深拷贝;你改了任意一个克隆体的 material
(比如改 color
),所有共享同一 material
的 mesh 都会一起变化。
如果不对材质进行clone
。在后续修改材质时则会出现以下情况:

选中/聚焦高亮(按模式映射不同颜色与透明度)+ 动画反馈
实现高亮的代码并不难,只是简单的更改被选择mesh
的发光色 & 透明度 :
js
// 设置 mesh 的发光色
#setMeshEmission(color) {
if (!this.mesh)
return
this.mesh.traverse(obj => obj.material?.emissive?.setHex(color))
}
// 设置 mesh 的透明度
#setMeshOpacity(opacity) {
if (!this.mesh)
return
this.mesh.traverse(obj => obj.material && (obj.material.opacity = opacity))
}
setFocused(value, mode)
内部按模式映射发光色与透明度(建造/拆除/搬迁/选择/无效建造),并用 gsap 做轻微浮动,形成好的视觉反馈。
js
/**
* 设置聚焦高亮,根据当前操作模式调整颜色和透明度
* @param {boolean} value 是否聚焦
* @param {string} mode 操作模式,可选:'select' | 'build' | 'relocate' | 'demolish',默认为 'select'
*/
setFocused(value, mode = 'select') {
// mode 到颜色和透明度的映射
let emissionColor = HIGHLIGHTED_COLOR
let opacity = SIMOBJECT_SELECTED_OPACITY
switch (mode) {
case 'select':
emissionColor = SELECTED_COLOR
opacity = SELECTED_COLOR_OPACITY
break
case 'build':
emissionColor = BUILD_COLOR
opacity = BUILD_COLOR_OPACITY
break
case 'build-invalid':
emissionColor = BUILD_INVALID_COLOR
opacity = BUILD_INVALID_COLOR_OPACITY
break
case 'relocate':
emissionColor = RELOCATE_COLOR
opacity = RELOCATE_COLOR_OPACITY
break
case 'demolish':
emissionColor = DEMOLISH_COLOR
opacity = DEMOLISH_COLOR_OPACITY
break
default:
emissionColor = HIGHLIGHTED_COLOR
opacity = SIMOBJECT_SELECTED_OPACITY
}
if (value) {
this.#setMeshEmission(emissionColor)
this.#setMeshOpacity(opacity)
// 使用gsap实现y轴yoyo动画
if (this.mesh) {
// 先停止可能已有动画
gsap.killTweensOf(this.mesh.position)
gsap.to(this.mesh.position, {
y: 0.13,
duration: 0.41,
yoyo: true,
repeat: -1,
ease: 'sine.inOut',
})
}
}
else {
// 取消聚焦,恢复默认
this.#setMeshEmission(0)
this.#setMeshOpacity(SIMOBJECT_DEFAULT_OPACITY)
if (this.mesh) {
// 停止动画并复位y轴
gsap.killTweensOf(this.mesh.position)
// 直接回到初始y(假设聚焦只加了0.1)
gsap.to(this.mesh.position, { y: 0, duration: 0.2, overwrite: true })
}
}
}
3.2 射线拾取:把鼠标落到正确的 Tile
原理为用 iMouse.normalizedMouse(iMouse
为该项目框架中提供当前mouse
信息的专属类)+ Raycaster → 命中 city.root → 沿父链向上找到带有 userData 的 Tile 实例(或其子节点)。
流程图

相信看这篇文章的您来说**射线检测**,并不是一个值得去花费篇幅的知识点。
这里仅简单的说一下主要优化手段:限定射线检测对象为地皮
首先在City.js 构建地皮时,将 Tile 统一塞入一个单独的 Three.Group
,之后射线检测对象只针对这个 Group
,这样一来
js
for (let x = 0; x < this.size; x++) {
const row = []
for (let y = 0; y < this.size; y++) {
// 读取元数据
const tileMeta = meta[x]?.[y] || { type: 'grass', building: null }
const tile = new Tile(x, y, {
type: tileMeta.type,
building: tileMeta.building,
direction: tileMeta.direction !== undefined ? tileMeta.direction : 0, // 传递建筑朝向
level: tileMeta.level !== undefined ? tileMeta.level : 0, // 传递建筑等级
})
row.push(tile)
this.root.add(tile) //统一塞入一个单独的 Three.Group
}
// 随后让 group 居中
this.meshes.push(row)
}
然后对Building
添加mesh.raycast = () => {}
逻辑射线检测只作用在 Tile
上:
js
export default class Building extends SimObject {
// 初始化建筑模型
initModel() {
const modelName = `${this.type}_level${this.level}`
const modelResource = this.resources.items[modelName]
if (modelResource && modelResource.scene) {
const mesh = this.initMeshFromResource(modelResource)
mesh.position.set(0, 0, 0)
mesh.scale.set(0.8, 0.8, 0.8)
// 设置朝向
const angle = (this.direction % 4) * 90
mesh.rotation.y = THREE.MathUtils.degToRad(angle)
// 禁止建筑被选中
mesh.raycast = () => {}
this.setMesh(mesh)
}
//...
}
3.3 模式驱动
在本项目中,模式切换由鼠标/键盘事件监听驱动:事件触发后更新 Pinia 的管理的全局属性 (如 currentMode,selectBuilding),而 Interactor 在每次点击时读取这些全局属性 并分发到相应的交互处理流程。比如建造模式下Interactor (BUILD)
则会执行建造模式下相应逻辑,而拆除模式下会执行 Interactor (DEMOLISH)
另一套逻辑。这里我会通过BUILD
模式 & 拆除模式来粗略解释一下具体实现过程。
3.3.1 建造(BUILD)模式
**BUILD 模式下主要任务是把"选中的建筑类型"落到"合规地块",并同步当前Scene 与 Metadata。**在上述GIF你可以观察到:
- 用户可以在右侧建筑卡片栏选择不同的建筑来进行建造
- 路可以随意构建,但其余类型建筑只能在路边构建。即一块路的上下左右四个地块就是合规地块。
流程图如下:

当用户在侧边栏点击建筑卡片后会在Pinia
设定SelectBuilding
的值
侧边栏.vue
js
function selectBuilding({ type, name, level = 1 }) {
// 仅在 BUILD 模式下允许选中
if (currentMode.value !== 'build')
return
if (selectedBuilding.value?.type === type && selectedBuilding.value?.level === level)
return
gameState.setSelectedBuilding({ type, level })
gameState.addToast(`${t('selectedIndicator.selected')}: ${name[language.value]}`, 'info')
}
随后当用户点击一个地块时则触发当前模式下对应的逻辑
js
_onClick() {
if (!this.focused)
return
const mode = this.gameState.currentMode
// 在特定模式下,单击即选中
if (PERSISTENT_HIGHLIGHT_MODES.includes(mode))
this._setSelected(this.focused)
// 根据模式委托给对应的处理器
switch (mode) {
case MODES.SELECT:
handleSelectMode(this, this.selected)
break
case MODES.BUILD:
handleBuildMode(this, this.focused)
break
case MODES.DEMOLISH:
handleDemolishMode(this, this.selected)
break
case MODES.RELOCATE:
handleRelocateMode(this, this.selected)
break
default:
handleDefaultMode(this, this.focused)
break
}
}
在建造模式下,Interactor
首先会利用canPlaceBuilding
判断当前地皮是否满足建造条件
js
export function canPlaceBuilding(x, y, buildingType, metadata) {
if (!metadata?.[x]?.[y])
return false
// 部分特殊建筑可随意建造
if (FREE_BUILDING_TYPES.includes(buildingType))
return true
// 其他建筑需相邻道路
const dirs = [[0, 1], [1, 0], [0, -1], [-1, 0]]
for (const [dx, dy] of dirs) {
const nx = x + dx
const ny = y + dy
if (metadata[nx]?.[ny]?.type === 'ground' && metadata[nx]?.[ny]?.building === 'road')
return true
}
return false
}
如果满足则会在 3D
端放置建筑并同步Metadata
中相应的值
js
export function handleBuildMode(ctx, tile) {
const buildingTypeToBuild = ctx.gameState.selectedBuilding?.type
const buildingLevelToBuild = 1
if (!tile)
return
const { x, y } = tile
const metadata = ctx.gameState.metadata
const canBuild = canPlaceBuilding(x, y, buildingTypeToBuild, metadata)
if (!buildingTypeToBuild || !canBuild || tile.buildingInstance) {
showToast('error', '无法在此处建造,请选择合规地块。')
return
}
// 通过 Pinia 修改 metadata
ctx.gameState.setTile(x, y, {
type: 'ground',
building: buildingTypeToBuild,
direction: 0, // 可根据实际情况
level: buildingLevelToBuild,
// 新增建筑详情
detail: BUILDING_DATA[buildingTypeToBuild]?.levels[buildingLevelToBuild],
// 产出因子 可能因为某些因素而影响产出,比如人口、科技等
outputFactor: 1,
})
if (ctx.gameState.credits < BUILDING_DATA[buildingTypeToBuild]?.levels[buildingLevelToBuild]?.cost) {
showToast('error', 'Insufficient funds, unable to build.')
return
}
ctx.gameState.updateCredits(-BUILDING_DATA[buildingTypeToBuild]?.levels[buildingLevelToBuild]?.cost)
// ...后续同步 Three.js 层刷新
tile.setBuilding(buildingTypeToBuild, buildingLevelToBuild, 0)
tile.setType('ground')
updateAdjacentRoads(tile, ctx.experience.world.city)
showBuildingPlacedToast(buildingTypeToBuild, tile, buildingLevelToBuild, ctx.gameState)
}
其实在游戏中的四大模式中,BUILD
模式是有别于其余三大模式的。因为 BUILD
是在四大模式中唯一一个不需要确认二次意图的模式,玩家只需要选择建筑,然后建造即可。而其余模式都需要在操作时再让用户确认一遍是否执行此操作。以下我以拆除模式为例做一个粗略介绍:
3.3.2 拆除(DEMOLISH)模式

流程图如下:

在用户切换为 DEMOLISH
模式后点击想要删除的Tile
时 emit(发布者角色)
就会执行一次广播事件
js
export function handleDemolishMode(ctx, tile) {
if (!tile)
return
if (tile.buildingInstance) {
eventBus.emit('ui:confirm-action', {
action: 'demolish',
tileId: tile.id,
tileName: tile.name || '',
buildingType: tile.buildingInstance.type,
buildingLevel: tile.buildingInstance.level,
})
ctx.gameState.setSelectedBuilding({ type: tile.buildingInstance.type, level: tile.buildingInstance.level || 1 })
ctx.gameState.setSelectedPosition(tile.position)
}
else {
tile.setType('grass')
ctx._clearSelection()
}
}
随后相应的 Vue Component
就会开始展示面板信息,一旦用户在这是发送了确定执行本次拆除行为,
那么相应的Vue Component
就会发送eventBus.emit('ui:action-confirmed', dialogData.value.action)
事件通知Scene
层执行拆除逻辑
js
export function confirmDemolish(ctx) {
const tile = ctx.selected
const building = tile.buildingInstance
if (tile && building) {
ctx.gameState.setTile(tile.x, tile.y, {
type: 'ground',
building: null,
direction: 0,
level: 0,
})
tile.removeBuilding()
showBuildingRemovedToast(building.type, tile, building.level, ctx.gameState)
updateAdjacentRoads(tile, ctx.experience.world.city)
}
}
这也是选择模式 (SELECT) 和 拆迁模式 (RELOCATION)二次确认的逻辑
4.戛然而止的文章
4.1 文章长讲的又浅?!怒
那么关于建筑信息、相互作用计算 (getEffectiveBuildingValue
) 的具体实现,广告牌特效,路面建筑更新,每一个展开都是不小的篇幅。限于篇幅和大家的阅读耐心(我知道技术长文看着累!),这篇文章显然无法像保姆级教程 一样,把从 npm install three
敲下第一行命令开始,到最终部署上线的每一个步骤、每一行关键代码都掰开揉碎讲清楚。
CubeCity
虽然是个"整活"项目,但麻雀虽小五脏俱全,涉及的知识点非常庞杂:
- Three.js 核心: 场景、相机、渲染器、几何体、材质、纹理、光照(环境光、平行光)、轨道控制器、Raycaster 点击交互、GLTF 模型加载与优化、后期处理(OutlinePass 做高亮)等。
- Vue 生态: Vue 3 + Vite 项目结构、Pinia 状态管理、组件通信、动画过渡、响应式 UI 设计。
- 游戏逻辑: 上面详述的 Metadata 设计、建筑数据配置、资源产出/消耗计算、升级/拆除/搬迁逻辑、建筑间相互作用(如住宅需要靠近道路)的实现。
- 工具链: Blender 基础模型处理、纹理制作、背景音乐制作、Vercel 部署等。
- **项目管理:**如何管理项目周期,如何借助
Linear MCP + Cursor
来push
我这样一个懒人去完成项目

**如果大家对这个"把 Threejs 当游戏引擎搞结果还真搞出来的活"具体是怎么一步步实现的感兴趣,觉得这种实战项目拆解有价值,请务必用点赞❤️ + 收藏⭐️告诉我!如果数量可观(比如破百?),我承诺会为 CubeCity
单开一个系列专栏。**我会尽量以每篇 3000-4000 字左右的体量(不占用大家太多时间)来讲述这个项目。
4.2 回归初心:Three.js 与我的方向
这已经是我用 Three.js 实现的第三个游戏项目了,是时候该停下了。

写完 CubeCity
,虽然过程很有趣也很有成就感(特别是被 Three.js 官推转发时!)但也让我更深刻地意识到一点:用 Three.js 深度开发复杂游戏逻辑,确实不是它的核心优势和最合适的场景。这种"硬刚"游戏逻辑的开发体验,相比使用专业引擎,效率和舒适度上还是有显著差距的。 游戏引擎 (Unity, Unreal, Godot, Cocos 等) 在实体组件系统 (ECS)、物理、动画状态机、资源管线、跨平台打包、编辑器工具链等方面提供了极其成熟和高效的解决方案,是专门为此而生的工具。专业的事,真的应该让专业的引擎来干。(说多了都是泪)
因此,后续的创作方向,我会更聚焦于 Three.js 本身最闪耀的领域:创造令人惊艳的、交互式的 3D 视觉效果和体验,并将其应用于网站、数据可视化、产品展示、艺术装置等场景。 比如探索更酷炫的着色器效果、更流畅的动画交互、更创意的 3D UI、WebXR 体验,或者是将 Three.js 与 AI 生成内容结合的有趣尝试。这才是 Three.js 在 Web 生态中的独特魅力和不可替代性所在。期待能继续给大家带来视觉上的惊喜!
4.3 开源与面包
看着 CubeCity
的 GitHub 仓库(github.com/hexianWeb/C... Star 数慢慢增长,这种感觉真的很棒,是纯粹用爱发电的动力之一。开源的精神、技术的分享、社区的反馈,这些都让我觉得有价值。
但是... (是的,总有个但是)
维护一个开源项目(即使像 CubeCity
这样自认为的"小玩具"),投入的时间、精力远超想象。写代码只是第一步,文档、Issue 答疑、可能的 Bug 修复、兼容性更新、依赖库升级、甚至 feature request... 这些都需要持续投入。同时,我也看到不少朋友(包括一些前辈)的建议:"你花这么多时间做这些,质量也不错,应该考虑商业化"、"可以弄个付费教程"、"接点定制需求吧"、"开源核心,高级功能收费"...
我理解这些建议的出发点,都是善意的,希望我的付出能得到更实际的回报,能走得更远。毕竟,头发不能白掉,电费网费也是钱。纯粹的"为爱发电"能持续多久?如何在热爱、分享与获得合理回报(或者说,至少覆盖成本,让自己能持续投入)之间找到平衡点? 这是我最近一直在纠结的问题。
收费?怕违背开源初心,也怕麻烦。完全免费?时间和精力的持续性又是个问号。接定制?又怕偏离了自己想做的方向...
开源之路,道阻且长。面包与理想,如何兼得?或者说,是否真的能兼得? 我还没有完美的答案。也许屏幕前的你,有什么想法或建议?欢迎在评论区聊聊。你们的反馈和支持(无论是精神上的 Star 分享,还是物质上的咖啡),对我来说都真的很重要。
5.最后的一些话
本专栏的愿景
本专栏的愿景是通过分享 Three.js
的中高级应用和实战技巧,帮助开发者更好地将 3D
技术应用到实际项目中,打造令人印象深刻的 Hero Section
。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D
技术的普及和应用。
加入社区,共同成长
如果您对 Threejs
这个 3D
图像框架很感兴趣,或者您也深信未来国内会涌现越来越多 3D
设计风格的网站,欢迎加入 ice 图形学社区 。这里是国内 Web 图形学最全的知识库,致力于打造一个全新的图形学生态体系!您可以在认证达人里找到我这个 Threejs
爱好者和其他大佬。
此外,如果您很喜欢 Threejs
又在烦恼其原生开发的繁琐,那么我诚邀您尝试 Tresjs 和 TvTjs , 他们都是基于 Vue
的 Threejs
框架。 TvTjs 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!