稀土掘金技术社区 09月11日
基于Three.js+Vue的城建游戏开发
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文介绍了如何使用Three.js和Vue开发一款名为CubeCity的城建游戏。文章详细阐述了游戏的元数据体系规划、三维场景呈现流程以及交互系统的实现原理。游戏采用17x17的网格地图,通过Pinia管理元数据,并利用City、Tile和Building等类将元数据转换为三维场景。交互系统采用模式驱动和射线拾取相结合的方式,实现了建造、选择、搬迁和拆除等操作模式。文章还探讨了SimObject类在交互系统中的作用,以及射线检测的优化手段。

💡 元数据体系规划:文章核心思想是将“城市事实”与“三维表现”解耦,以Pinia的metadata作为单一事实源,将三维场景视为其投影和可视化。这种设计使得游戏逻辑与表现分离,简化了心智模型,并提高了性能。

🏗️ 三维场景呈现:文章详细介绍了从Pinia读取元数据到City按网格实例化,再到Tile生成地形与挂载建筑,最后由Building初始化模型与状态效果的流程。这种分层设计使得代码结构清晰,易于维护。

🔍 交互系统:文章深入探讨了SimObject在交互系统中的作用,包括mesh材质克隆、选中/聚焦高亮和动画反馈等功能。此外,文章还介绍了射线拾取的原理和优化手段,例如限定射线检测对象为地皮,以提高性能。

原创 何贤 2025-08-30 09:01 重庆

点击关注公众号,技术干货及时达。

(💰金石瓜分计划强势上线,速戳上图了解详情🔍)

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 建筑相互作用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 的二维数组。

export const useGameState = defineStore('gameState', {
  state() => ({
    metadataArray.from({ length17 }, _ =>
      Array.from({ length17 }, _ => ({
        type'grass',
        buildingnull,
        direction0,
      }))),
    currentMode'build'//当前玩家选择的模式
    selectedBuildingnull//当前玩家选择的地皮
  }),

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

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

计算每日总收入的getters

export const useGameState = defineStore('gameState', {
  state() => ({
    // 核心游戏状态
    metadataArray.from({ length17 }, _ =>
      Array.from({ length17 }, _ => ({
        type'grass',
        buildingnull,
        direction0,
      }))),
    currentMode'build',
    selectedBuildingnull,
    // 游戏时间和经济
    gameDay1,
    credits3000,
    // ... 其余同上
  }),
  getters: {
    /**
     * 计算每日总收入(直接使用metadata中的detail,大幅提升性能)
     * @param {objectstate - 游戏状态
     * @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 {objectstate - 游戏状态
     * @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() 获取全局依赖,保持解耦与一致风格。

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

「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.980.20.98),
            new THREE.MeshStandardMaterial({ color: '#579649' }),
          )
        this.grassMesh.position.set(000)
        this.grassMesh.scale.set(0.9810.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(10.21),
            new THREE.MeshStandardMaterial({ color: '#a89984' }),
          )
        this.groundMesh.position.set(00.010// 稍微高于 grass,避免 z-fighting
        this.groundMesh.scale.set(0.9810.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 = {
  houseHouse,
  house2House2,
  factoryFactory,
  shopShop,
  officeOffice,
  parkPark,
  policePolice,
  hospitalHospital,
  roadRoad,
  chemistry_factoryChemistryFactory,
  nuke_factoryNukeFactory,
  fire_stationFireStation,
  sun_powerSunPower,
  water_towerWaterTower,
  wind_powerWindPower,
  garbage_stationGarbageStation,
  hero_parkHeroPark,
  // 其他建筑类型可在此扩展
}

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」

// 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 {objectresource - 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的发光色 & 透明度 :

  // 设置 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 实例(或其子节点)。

「流程图」

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

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

首先在「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'buildingnull }
        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(000)
      mesh.scale.set(0.80.80.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」

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(thisthis.selected)
      break
    case MODES.BUILD:
      handleBuildMode(thisthis.focused)
      break
    case MODES.DEMOLISH:
      handleDemolishMode(thisthis.selected)
      break
    case MODES.RELOCATE:
      handleRelocateMode(thisthis.selected)
      break
    default:
      handleDefaultMode(thisthis.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 = [[01], [10], [0, -1], [-10]]
  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,
    direction0// 可根据实际情况
    level: buildingLevelToBuild,
    // 新增建筑详情
    detailBUILDING_DATA[buildingTypeToBuild]?.levels[buildingLevelToBuild],
    // 产出因子 可能因为某些因素而影响产出,比如人口、科技等
    outputFactor1,
  })
  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(发布者角色)就会执行一次广播事件

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

export function confirmDemolish(ctx{
  const tile = ctx.selected
  const building = tile.buildingInstance
  if (tile && building) {
    ctx.gameState.setTile(tile.x, tile.y, {
      type'ground',
      buildingnull,
      direction0,
      level0,
    })
    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 + Cursorpush我这样一个懒人去完成项目

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

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

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

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

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

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

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

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

""~

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Three.js Vue 城建游戏 元数据 交互系统
相关文章