全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2025-08-30_我写出了 Threejs 版城市天际线?!

您的位置:首页 >> 新闻 >> 行业资讯

我写出了 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 建筑相互作用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的二维数组。 exportconstuseGameState =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 // 产出系数(相互作用或全局事件影响)}exportconstuseGameState =defineStore('gameState', {// .... // 更新 metadata 地皮的 actionaction: { 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) { returnthis.metadata?.[x]?.[y] ||null }, }})建造模式点击地皮时action: 这样做有一些好处:比如「当我们在做聚合计算(收入/人口/电力/污染)时可直接遍历整个metadata,简化心智模型。」 计算每日总收入的getters exportconstuseGameState =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) ={ lettotalIncome =0 state.metadata.forEach((row, x) ={ row.forEach((tile, y) ={//遍历有建筑的地皮进行汇总计算 if(tile.building&& tile.detail) { // 使用高效函数:自动判断是否需要相互作用计算(后面会讲到) constincome =getEffectiveBuildingValue(state, x, y,'coinOutput') totalIncome += income } }) }) returntotalIncome }, // ... 其余同上 }假设现实中每5S就类比游戏世界的一天,实现金币增加功能:定时器定时 + 相关逻辑即可: 构建下一天逻辑,有关Metadata时间的逻辑都在此action中执行exportconstuseGameState =defineStore('gameState', {// 省略...getters: { /** * 计算每日总收入(直接使用metadata中的detail,大幅提升性能) *@param{object}state- 游戏状态 *@returns{number} 总收入 */ dailyIncome:(state) ={ lettotalIncome =0 state.metadata.forEach((row, x) ={ row.forEach((tile, y) ={//遍历有建筑的地皮进行汇总计算 if(tile.building&& tile.detail) { // 使用高效函数:自动判断是否需要相互作用计算(后面会讲到) constincome =getEffectiveBuildingValue(state, x, y,'coinOutput') totalIncome += income } }) }) returntotalIncome }, // ... 其余同上 },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() 获取全局依赖,保持解耦与一致风格。 importExperience from'./experience.js' export defaultclassYour3DComponent{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 defaultclassWorld{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 defaultclassCity{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() { constgameState = useGameState() const{ metadata } = storeToRefs(gameState) constmeta = metadata.value for(let x =0; x this.size; x++) { constrow = [] for(let y =0; y this.size; y++) { consttileMeta = meta[x]?.[y] || { type:'grass', building:null} consttile = 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 defaultclassTileextendsSimObject{ 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` //加载平地资源 constgroundResource = 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() constbuildingData = BUILDING_DATA[type] constlevelData = buildingData.levels[level] constoptions = { buildingData, levelData, position: { x:this.x, y:this.y } } constbuildingInstance = createBuilding(type, level, direction, options) if(buildingInstance) { this.buildingInstance = buildingInstance this.grassMesh.add(buildingInstance) } } }这里出现的createBuilding实际上时一个选择类工厂,会根据传入的type判断当前应该由那个具体建筑类进行后续实例化. constBUILDING_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,// 其他建筑类型可在此扩展} exportfunctioncreateBuilding(type, level =1, direction =0, options = {}) {constCls=BUILDING_CLASS_MAP[type]if(Cls) { returnnewCls(type, level, direction, options) }returnnull} 到这里就可以将Metadata转换为3D场景了。 ?在Metadata 0-0 上填写建筑信息,刷新后效果如下 ? 3.交互系统:模式驱动 + 射线拾取首先提到交互系统我们需要填上之前提到的一个坑,就是Tile实体类继承的SimObject到底起到什么作用?为什么不直接继承Object3D? 事实是如果你仔细观察游戏界,当用户指向某块地皮时地皮会显示出特殊的互动效果,建造模式下可建区域为绿色(不可建为绿色)、拆除模式(DEMOLISH) 下所选区域为红色、迁移模式下为蓝色。 但是TileBuilding类组件却没有相应实现的代码。 相信到这里你也想到这「SimObject类的作用:承担Scene里,交互对象的“行为”与“表现”。」 3.1 SimObject 在交互系统中的作用与原理SimObject 作为所有可交互物体的基类(继承 THREE.Object3D),统一封装了: mesh 材质克隆(避免共享材质被串改) 选中/聚焦高亮(按模式映射不同颜色与透明度) 动画反馈(gsap 轻量的 y 轴 yoyo 浮动) mesh 材质克隆「mesh 初始化:克隆 GLTF,遍历所有子节点克隆材质并开启透明;将 userData 指向自身实例,便于射线命中后反查 Tile/Building」 // SimObject 互动基类,所有可交互对象继承自此类exportdefaultclassSimObjectextendsTHREE.Object3D{/**@type{THREE.Mesh?} */ #mesh =null/**@type{THREE.Vector3} */ #worldPos =newTHREE.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) { constmesh =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) returnnull // 克隆模型 constmesh = resource.scene.clone() // 遍历所有子节点,克隆材质并设置透明,userData 指向自身 mesh.traverse((child) ={ child.userData=this if(childinstanceofTHREE.Mesh&& child.material) { child.receiveShadow=true child.castShadow=true child.material= child.material.clone() child.material.transparent=true } }) returnmesh }}因为**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(letx =0; x this.size; x++) { constrow = [] for(lety =0; y this.size; y++) { // 读取元数据 consttileMeta = meta[x]?.[y] || {type:'grass',building:null} consttile =newTile(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上: exportdefaultclassBuildingextendsSimObject{ // 初始化建筑模型initModel() { constmodelName =`${this.type}_level${this.level}` constmodelResource =this.resources.items[modelName] if(modelResource && modelResource.scene) { constmesh =this.initMeshFromResource(modelResource) mesh.position.set(0,0,0) mesh.scale.set(0.8,0.8,0.8) // 设置朝向 constangle = (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」 functionselectBuilding({type, name, level =1}) {// 仅在 BUILD 模式下允许选中if(currentMode.value!=='build') returnif(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 constmode =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]) returnfalse// 部分特殊建筑可随意建造if(FREE_BUILDING_TYPES.includes(buildingType)) returntrue // 其他建筑需相邻道路constdirs = [[0,1], [1,0], [0, -1], [-1,0]]for(const[dx, dy] of dirs) { constnx = x + dx constny = y + dy if(metadata[nx]?.[ny]?.type ==='ground'&& metadata[nx]?.[ny]?.building ==='road') returntrue }returnfalse}如果满足则会在3D端放置建筑并同步Metadata中相应的值 exportfunctionhandleBuildMode(ctx, tile) {constbuildingTypeToBuild = ctx.gameState.selectedBuilding?.typeconstbuildingLevelToBuild =1if(!tile) returnconst{ x, y } = tileconstmetadata = ctx.gameState.metadataconstcanBuild =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.creditsBUILDING_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(发布者角色)就会执行一次广播事件 exportfunctionhandleDemolishMode(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层执行拆除逻辑 exportfunctionconfirmDemolish(ctx){consttile= ctx.selectedconstbuilding= tile.buildingInstanceif(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 点击"阅读原文"了解详情~ 阅读原文

上一篇:2023-12-18_【招聘】McCann Worldgroup China 麦肯、Amber China 琥珀传播、UID,热招中~ 下一篇:2020-10-02_「转」今年第一部百亿阵容,你喜欢哪一个故事?

TAG标签:

17
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价