稀土掘金技术社区

我写出了 Threejs 版城市天际线?!

  • 「🔍 选择模式 (SELECT):」

    • 快捷键 「S」
    • 点击建筑查看详细信息,如这一级的产出 & 污染,以及下一级的产出 & 污染等。
    • 满足条件时可对建筑进行升级,提升其功能和产出。
Image
  • 「🚚 搬迁模式 (RELOCATE):」

    • 快捷键 「R」

    • 选中一个已建好的建筑,然后点击一个空地,即可轻松完成搬迁。

    • 在BUILD 放置后按 R 键,可以旋转建筑以适应你的城市布局。

Image
  • 「💣 拆除模式 (DEMOLISH):」

    • 快捷键 「D」
    • 切换到此模式,点击不再需要的建筑即可将其拆除。
    • 拆除建筑会返还部分建造成本。
Image

1.4 建筑相互作用

05.png
Image

Threejs 转发贴

06.png

 原贴 

07.png

2.游戏基建结构

「注意,本项目纯属整活项目!开发游戏项目请使用游戏引擎!!!」

「注意,本项目纯属整活项目!开发游戏项目请使用游戏引擎!!!」

「注意,本项目纯属整活项目!开发游戏项目请使用游戏引擎!!!」

在文章 😲我又写出了被 Three.js 官推转发的项目?!🥳🥳中曾提到过游戏开发三要素

Snipaste_2025-08-16_16-06-12.png

简单来说

  • 「Scene」 负责"演什么" - 处理3D模型、光照、物理效果

  • 「UI」 负责"怎么看" - 控制分数显示、菜单系统

  • 「Metadata」 负责"怎么玩" - 管理游戏状态、得分规则、角色属性

接下来我也会从这三个方面大致的介绍项目运行的基本原理,但想在一篇文章内从头到尾解释清楚项目中所有的细节怎么实现不太现实。不过「如果这篇文章点赞+收藏 超过 100。我会为这个项目单开一个栏目,从 需求分析、项目规划、美工素材处理、背景音乐生成、代码撰写、项目管理到最后的部署从头到尾解释一遍。谢谢大家支持。」

2.1 元数据体系与规划 (Metadata)

核心思想是把“城市事实”与“三维表现”彻底解耦:「一切以 Pinia 的 metadata 为“单一事实源(Single Source of Truth)”,三维场景只是其投影和可视化。」 常有人调侃游戏玩家:“真是好笑,一堆数据当宝?!” 事实确实如此。不同的表现形式基于统一的数据,比如这个项目。若使用 2D 界面显示数据,也一样可以玩(例如在地图格子上玩简易城建游戏):

09.png

那么接下来我来介绍一下Metadata是如何规划的,有一点我们绝对不能忘:「任何业务逻辑必须“元数据优先,三维从属”。」

2.1.1 网格与坐标约定

08.png

《CubeCity》的主要玩法是用户在一块 17×17 的地皮上建造、销毁、迁移、升级建筑,并随着游戏时间增加金币。城市采用 17×17 的离散网格(默认 SIZE = 17),每个格子是一个 Tile(地皮)。因此,我在 Store(Pinia) 中定义的初始城市就是一个 17*17 的二维数组。

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 的数据模型

当某块地皮上存在建筑时,则会在对应下标的元数据中填入相应的建筑信息

在后续我会提到游戏种各个类型建筑都具体起到哪些作用,在这里仅简单介绍一下存放存档时的对象

{  "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            // 产出系数(相互作用或全局事件影响)}
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:

Image

这样做有一些好处:比如「当我们在做聚合计算(收入/人口/电力/污染)时可直接遍历整个metadata,简化心智模型。」

计算每日总收入的getters

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中执行
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。

  1. 「核心思想」:以 Experience 单例为中心,统一管理 Three.js 场景、渲染器、相机、资源加载、时间、尺寸、交互、调试等。任何 3D 组件都通过 new Experience() 获取全局依赖,保持解耦与一致风格。

    import 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 (或者任何一类具有发布者订阅者模式的功能函数)。

  • 把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 确定建筑方向。

    11.png

    「World.js: 管理 City 类的挂载和更新」

    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)」

    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 负责呈现地皮与建筑的模型」

    // 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判断当前应该由那个具体建筑类进行后续实例化.

    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}
    12.png

    到这里就可以将 Metadata转换为 3D场景了。

    ❝

    在Metadata 0-0 上填写建筑信息,刷新后效果如下

    ❞
    Image

    3.交互系统:模式驱动 + 射线拾取

    首先提到交互系统我们需要填上之前提到的一个坑,就是Tile实体类继承的 SimObject到底起到什么作用?为什么不直接继承 Object3D?

    事实是如果你仔细观察游戏界,当用户指向某块地皮时地皮会显示出特殊的互动效果,建造模式下可建区域为绿色(不可建为绿色)、拆除模式(DEMOLISH) 下所选区域为红色、迁移模式下为蓝色。

    Image

    但是Tile & Building类组件却没有相应实现的代码。

    相信到这里你也想到这 「SimObject类的作用:承担Scene里,交互对象的“行为”与“表现”。」

    3.1 SimObject 在交互系统中的作用与原理

    SimObject 作为所有可交互物体的基类(继承 THREE.Object3D),统一封装了:

    • mesh 材质克隆(避免共享材质被串改)

    • 选中/聚焦高亮(按模式映射不同颜色与透明度)

    • 动画反馈(gsap 轻量的 y 轴 yoyo 浮动)

    mesh 材质克隆

    「mesh 初始化:克隆 GLTF,遍历所有子节点克隆材质并开启透明;将 userData 指向自身实例,便于射线命中后反查 Tile/Building」

    // 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。在后续修改材质时则会出现以下情况:

    Image

    选中/聚焦高亮(按模式映射不同颜色与透明度)+ 动画反馈

    实现高亮的代码并不难,只是简单的更改被选择mesh的发光色 & 透明度 :

      // 设置 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 做轻微浮动,形成好的视觉反馈。

      /**   * 设置聚焦高亮,根据当前操作模式调整颜色和透明度   * @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 实例(或其子节点)。

    「流程图」

    13.png

    相信看这篇文章的您来说**射线检测**,并不是一个值得去花费篇幅的知识点。

    这里仅简单的说一下主要优化手段:「限定射线检测对象为地皮」

    首先在「City.js」构建地皮时,将 Tile 统一塞入一个单独的 Three.Group,之后射线检测对象只针对这个 Group,这样一来

        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上:

    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)模式

    Image

    **BUILD 模式下主要任务是把“选中的建筑类型”落到“合规地块”,并同步当前Scene  与 Metadata。**在上述GIF你可以观察到:

    1. 用户可以在右侧建筑卡片栏选择不同的建筑来进行建造
    2. 路可以随意构建,但其余类型建筑只能在路边构建。即一块路的上下左右四个地块就是「合规地块」。

    流程图如下:

    14.png

    当用户在侧边栏点击建筑卡片后会在Pinia设定SelectBuilding的值

    「侧边栏.vue」

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

    随后当用户点击一个地块时则触发当前模式下对应的逻辑

    _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判断当前地皮是否满足建造条件

    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中相应的值

    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)模式

    Image

    流程图如下:

    15.png

    在用户切换为 DEMOLISH模式后点击想要删除的Tile时 emit(发布者角色)就会执行一次广播事件

    export functionhandleDemolishMode(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层执行拆除逻辑

    export functionconfirmDemolish(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 虽然是个“整活”项目,但麻雀虽小五脏俱全,涉及的知识点非常庞杂:

    1. 「Three.js 核心:」 场景、相机、渲染器、几何体、材质、纹理、光照(环境光、平行光)、轨道控制器、Raycaster 点击交互、GLTF 模型加载与优化、后期处理(OutlinePass 做高亮)等。
    2. 「Vue 生态:」 Vue 3 + Vite 项目结构、Pinia 状态管理、组件通信、动画过渡、响应式 UI 设计。
    3. 「游戏逻辑:」 上面详述的 Metadata 设计、建筑数据配置、资源产出/消耗计算、升级/拆除/搬迁逻辑、建筑间相互作用(如住宅需要靠近道路)的实现。
    4. 「工具链:」 Blender 基础模型处理、纹理制作、背景音乐制作、Vercel 部署等。
    5. **项目管理:**如何管理项目周期,如何借助Linear MCP + Cursor来push我这样一个懒人去完成项目
    16.png

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

    4.2 回归初心:Three.js 与我的方向

    这已经是我用 Three.js 实现的「第三个游戏项目」了,是时候该停下了。

    Image

    写完 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 仓库 Star 数慢慢增长,这种感觉真的很棒,是纯粹用爱发电的动力之一。开源的精神、技术的分享、社区的反馈,这些都让我觉得有价值。

    「但是...」 (是的,总有个但是)

    维护一个开源项目(即使像 CubeCity 这样自认为的“小玩具”),投入的时间、精力远超想象。写代码只是第一步,文档、Issue 答疑、可能的 Bug 修复、兼容性更新、依赖库升级、甚至 feature request... 这些都需要持续投入。同时,我也看到不少朋友(包括一些前辈)的建议:“你花这么多时间做这些,质量也不错,应该考虑商业化”、“可以弄个付费教程”、“接点定制需求吧”、“开源核心,高级功能收费”...

    我理解这些建议的出发点,都是善意的,希望我的付出能得到更实际的回报,能走得更远。毕竟,头发不能白掉,电费网费也是钱。「纯粹的“为爱发电”能持续多久?如何在热爱、分享与获得合理回报(或者说,至少覆盖成本,让自己能持续投入)之间找到平衡点?」 这是我最近一直在纠结的问题。

    收费?怕违背开源初心,也怕麻烦。完全免费?时间和精力的持续性又是个问号。接定制?又怕偏离了自己想做的方向...

    「开源之路,道阻且长。面包与理想,如何兼得?或者说,是否真的能兼得?」 我还没有完美的答案。也许屏幕前的你,有什么想法或建议?欢迎在评论区聊聊。你们的反馈和支持(无论是精神上的 Star 分享,还是物质上的咖啡),对我来说都「真的很重要」。

    5.最后的一些话

    本专栏的愿景

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

    AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding

    图片
    点击"阅读原文"了解详情~