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材质克隆、选中/聚焦高亮和动画反馈等功能。此外,文章还介绍了射线拾取的原理和优化手段,例如限定射线检测对象为地皮,以提高性能。
快捷键 「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: () => ({
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",
"building": "factory",
"direction": 0,
"level": 1,
"detail": {
"coinOutput": 70,
"powerUsage": 40,
"pollution": 22,
"population": 20,
"category": "industrial"
},
"outputFactor": 1
}
export const useGameState = defineStore('gameState', {
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,简化心智模型。」计算每日总收入的gettersexport 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。「核心思想」:以 Experience 单例为中心,统一管理 Three.js 场景、渲染器、相机、资源加载、时间、尺寸、交互、调试等。任何 3D 组件都通过 new Experience() 获取全局依赖,保持解耦与一致风格。import Experience from './experience.js'
export default class Your3DComponent {
constructor() {
this.experience = new Experience()
this.scene = this.experience.scene
this.resources = this.experience.resources
this.camera = this.experience.camera.instance
this.renderer = this.experience.renderer.instance
this.time = this.experience.time
this.sizes = this.experience.sizes
this.iMouse = this.experience.iMouse
this.debug = this.experience.debug
this.physics = this.experience.physics
this.stats = this.experience.stats
this.canvas = this.experience.canvas
}
update() {
}
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() {
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()
}
}
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 负责呈现地皮与建筑的模型」
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
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)
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)
if (building) {
this.setBuilding(building, level, direction)
}
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
}
到这里就可以将 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」
export default class SimObject extends THREE.Object3D {
#mesh = null
#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
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()
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的发光色 & 透明度 :
#setMeshEmission(color) {
if (!this.mesh)
return
this.mesh.traverse(obj => obj.material?.emissive?.setHex(color))
}
#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') {
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)
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) {
gsap.killTweensOf(this.mesh.position)
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', 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)
}
然后对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)模式
**BUILD 模式下主要任务是把“选中的建筑类型”落到“合规地块”,并同步当前Scene 与 Metadata。**在上述GIF你可以观察到:用户可以在右侧建筑卡片栏选择不同的建筑来进行建造路可以随意构建,但其余类型建筑只能在路边构建。即一块路的上下左右四个地块就是「合规地块」。流程图如下:
当用户在侧边栏点击建筑卡片后会在Pinia设定SelectBuilding的值「侧边栏.vue」function selectBuilding({ type, name, level = 1 }) {
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
}
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)
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',
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 仓库 Star 数慢慢增长,这种感觉真的很棒,是纯粹用爱发电的动力之一。开源的精神、技术的分享、社区的反馈,这些都让我觉得有价值。「但是...」 (是的,总有个但是)维护一个开源项目(即使像 CubeCity 这样自认为的“小玩具”),投入的时间、精力远超想象。写代码只是第一步,文档、Issue 答疑、可能的 Bug 修复、兼容性更新、依赖库升级、甚至 feature request... 这些都需要持续投入。同时,我也看到不少朋友(包括一些前辈)的建议:“你花这么多时间做这些,质量也不错,应该考虑商业化”、“可以弄个付费教程”、“接点定制需求吧”、“开源核心,高级功能收费”...我理解这些建议的出发点,都是善意的,希望我的付出能得到更实际的回报,能走得更远。毕竟,头发不能白掉,电费网费也是钱。「纯粹的“为爱发电”能持续多久?如何在热爱、分享与获得合理回报(或者说,至少覆盖成本,让自己能持续投入)之间找到平衡点?」 这是我最近一直在纠结的问题。收费?怕违背开源初心,也怕麻烦。完全免费?时间和精力的持续性又是个问号。接定制?又怕偏离了自己想做的方向...「开源之路,道阻且长。面包与理想,如何兼得?或者说,是否真的能兼得?」 我还没有完美的答案。也许屏幕前的你,有什么想法或建议?欢迎在评论区聊聊。你们的反馈和支持(无论是精神上的 Star 分享,还是物质上的咖啡),对我来说都「真的很重要」。5.最后的一些话本专栏的愿景本专栏的愿景是通过分享 Three.js 的中高级应用和实战技巧,帮助开发者更好地将 3D 技术应用到实际项目中,打造令人印象深刻的 Hero Section。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D 技术的普及和应用。AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding

点击"阅读原文"了解详情~
阅读原文
跳转微信打开