资源推荐
cubecity-threejs-city-game
cubecity-threejs-city-game

不用 Unity,Three.js 也能做城市建造游戏

CubeCity (opens in a new tab) 是一个跑在浏览器里的 2.5D 城市建造游戏,基于 Three.js 和 Vue 3,MIT 协议,目前 1000+ Star。玩法上类似简化版 SimCity:放建筑、铺道路、管电力和污染指数,建筑每 5 秒产一次金币,金币用来扩张城市。

这篇文章不聊游戏玩法,只看源码里几个具体的 Three.js 技术决策——它们解决的问题在场景编辑器、交互式地图、在线规划工具里同样会遇到。

正交相机:2.5D 视角不靠 UI 技巧,靠相机投影

游戏用的是正交相机(OrthographicCamera),不是常见的透视相机。

// experience.js
this.camera = new Camera(true)  // true = 使用正交投影
 
// camera.js
if (this.orthographic) {
  this.instance = new THREE.OrthographicCamera(
    -this.frustumSize * aspect,
     this.frustumSize * aspect,
     this.frustumSize,
    -this.frustumSize,
    -50, 100
  )
}

透视相机会产生近大远小的效果,让远处建筑显得更小。正交相机没有透视收缩,无论建筑离相机多远,大小在屏幕上都一样。这是 2D 棋盘游戏、城市建造游戏的标准视角选择——视觉上立体,逻辑上等距,玩家容易判断建筑之间的相对位置。

Three.js 默认示例几乎都用透视相机,正交相机用来做渲染器是不常见的,但它解决了一个实际问题:让 3D 场景看起来和 2D 网格逻辑保持一致。

极角锁定:允许旋转,但不允许仰俯

游戏支持鼠标旋转视角,但不能上下仰俯——只能水平绕城市转。背后是一个 OrbitControls 的小技巧:把 minPolarAnglemaxPolarAngle 锁成同一个值。

// camera.js - setControls()
this.orbitControls = new OrbitControls(this.instance, this.canvas)
this.orbitControls.enableZoom = false
this.orbitControls.enableRotate = true
 
// 计算初始相机到目标点的极角
const offset = new THREE.Vector3().subVectors(this.instance.position, this.target)
const polarAngle = offset.angleTo(new THREE.Vector3(0, 1, 0))
 
// 上下限设成相同值,极角被锁死
this.orbitControls.minPolarAngle = polarAngle
this.orbitControls.maxPolarAngle = polarAngle

polarAngle 是当前相机位置相对于 Y 轴的仰角,把上下限都设成这个值,鼠标拖动只能在这个仰角上做水平旋转,垂直方向被彻底锁死。

缩放用的是另一套控制器 TrackballControls,只开放缩放,关掉旋转和平移:

this.trackballControls = new TrackballControls(this.instance, this.canvas)
this.trackballControls.noRotate = true
this.trackballControls.noPan = true
this.trackballControls.noZoom = false

两个控制器同时 update,各管各的职责。这比试图在一个控制器里配置所有约束要清晰。

键盘左右箭头切换四个预设视角(城市四个角的斜 45 度),切换动画走 GSAP:

animateTo(targetPos) {
  this.isRotating = true
  gsap.to(this.instance.position, {
    duration: 0.7,
    ease: 'power2.inOut',
    x: targetPos.x, y: targetPos.y, z: targetPos.z,
    onUpdate: () => this.instance.lookAt(this.target),
    onComplete: () => { this.isRotating = false }
  })
}

建筑禁用射线检测:点击穿透到地皮

射线检测(Raycasting)是判断用户点击了哪个地皮的核心机制。但这里有个问题——地皮上面有 3D 建筑模型,射线会优先命中建筑的 mesh,而不是底下的地皮。

CubeCity 的处理方式是直接把建筑的射线检测关掉:

// building.js - initModel()
mesh.raycast = () => {}  // 建筑模型不参与射线检测

这样无论鼠标悬停在建筑上还是空地上,射线只会命中地皮的 mesh,而不会被建筑拦截。

但射线命中的是地皮的 grassMesh(一个 THREE.Mesh),而交互逻辑需要拿到 Tile 实例。怎么从 mesh 找回 Tile?在创建 Tile 时,把自身引用存进 mesh 的 userData:

// tile.js
this.grassMesh.userData = this  // 把 Tile 实例挂到 mesh 上

Raycasting 命中后,沿父对象链向上查找,直到找到带有 setBuilding 方法的 userData:

// interactor/utils.js
const findTile = (obj) => {
  if (!obj) return null
  if (obj.userData && typeof obj.userData.setBuilding === 'function')
    return obj.userData  // 找到了 Tile 实例
  return findTile(obj.parent)
}
return findTile(intersections[0].object)

这个组合——建筑禁用 raycast + userData 存引用 + 向上遍历——是处理 Three.js 场景中复杂对象层级时的一种通用思路。

渲染循环:EventEmitter 驱动的 tick 链

渲染循环没有用 setInterval 或直接在组件里调 requestAnimationFrame,而是用了 EventEmitter 模式:

// time.js
export default class Time extends EventEmitter {
  tick() {
    this.delta = Date.now() - this.current
    this.current = Date.now()
    this.trigger('tick')  // 每帧发出事件
    window.requestAnimationFrame(() => this.tick())
  }
}
// experience.js
this.time = new Time()
this.time.on('tick', () => {
  this.camera.update()
  this.world.update()
  this.renderer.update()
})

好处是各子系统不需要知道彼此的存在,只订阅 tick 事件自行更新。新增系统只要 this.time.on('tick', ...) 就能接入渲染循环,不需要改动 Experience 的 update 方法。

写在最后

CubeCity 里这几个技术点单独拿出来都不复杂,有意思的是它们组合使用的方式:正交相机解决视角问题,极角锁定解决旋转约束,建筑禁用 raycast 解决点击穿透,userData 反查解决对象层级导航,EventEmitter tick 解决多系统协调。

每个问题有各自的解法,没有大量耦合,整体代码读起来比较清晰。对做三维交互场景、在线地图编辑器、空间规划类应用的项目,源码里有不少可以直接参考的模式。

主分支名为 big-simcity,clone 后注意切换分支。

GitHub:https://github.com/hexianWeb/CubeCity (opens in a new tab)