City Builder 架构总览:一人开发如何避免烂代码
前三篇拆解了摄像机、数据层、策略模式三个子系统。这篇从全局视角回顾整个架构演进历程——以及一个业余开发者从”能跑就行”到”可维护架构”的认知转变。
一、项目起点:从 170 行 if-else 说起
City Builder 是我边学 Unity 边开发的第一个项目。和大多数初学者一样,我的初始策略是:先跑起来再说。
于是 BuildingAreaManager 第一版长这样:
if (roadType == StraightPath && rotation == 0°) { 往前扩展2格; 往后扩展2格; }else if (roadType == StraightPath && rotation == 90°) { 往左扩展2格; 往右扩展2格; }else if (roadType == BendSquare && rotation == 0°) { ... }else if (roadType == CrossroadPath && rotation == 0°) { ... }// ... 170 行这段代码完美运行。放置系统按照它的规则正确地标记了可建造区域。但问题不在功能,在可维护性:
- 加一种新道路形态 → 加 ~30 行 if-else
- 改一条标记规则 → 在所有相关分支中逐个找到并修改
- 新加入看这段代码 → 需要先理解 4 种道路 × 4 种旋转 × 2 个方向 = 32 种组合的对应关系
它是一堵 “功能正常但认知负载极高” 的代码墙。
但这段代码不是废物——它是我架构重构的”化石燃料”。因为从这段代码中,我提炼出了一个关键的认知:
二、核心认知:DDC 的”三步拆解法”
回看那 170 行 if-else,我发现所有分支都遵循同一个骨架:
对某种 道路形状 × 某种 旋转角度: 向 方向A 延伸 N 格 向 方向B 延伸 M 格变的东西只有四个:道路形状、旋转角度、方向、步数。不变的是”延伸 N 格”这个操作本身。
由此我总结出了一套方法论——DDC(Data-Driven Design)三步法:
步骤一:找不变的骨架
删掉代码中所有具体的数值、枚举值和方向向量。剩下的 if-else 分支结构就是”骨架”。骨架告诉你:这段代码本质上是一个查找过程——给定”道路类型”和”旋转角度”,找到”偏移规则列表”。
步骤二:分离变化的值
骨架中每一个可能变化的”参数”都是数据结构的一个字段。从代码分支中提炼出:
道路类型(枚举)──────────┐ ├─── 变 → 外层查找键旋转角度(float list)────┘ │方向(Vector3)──────────┐ ├─── 变 → 内层规则值步数(int)──────────────┘步骤三:分层打包
变化少、作为”入口”的放外层;变化多、依赖上层决定的放内层:
// 外层:道路类型 + 一套规则public class RoadAreaConfigSO : ScriptableObject{ public RoadType roadType; public List<RoadRotationEntry> entries;}
// 中层:旋转角度 + 对应的偏移规则public class RoadRotationEntry{ public List<float> yRotations; // 哪些旋转共用这套规则 public List<AreaOffsetRule> rules; // 这套规则的具体偏移}
// 内层:单个偏移规则(方向 + 步数)public class AreaOffsetRule{ public Vector3 direction; public int cellCount;}这就是从代码的形状反推出数据的形状。结果是一个 ScriptableObject asset——以后加新道路形态,策划在 Inspector 里填规则即可,一行代码不用写。
这套方法论可以推广
DDC 三步法不只适用于道路配置。项目中的 ObjectDatabaseSO(建筑数据库)也是用同一套方法论设计的:
- 骨架:一个”数据库”包含多个”条目”,每个条目有”名称、ID、类型、尺寸、Prefab”
- 变化的值:名称不是固定的、ID 不是固定的、尺寸不是固定的……
- 分层打包:数据库是外层,条目是内层,逐级包含
甚至策略模式本身也是这套思维的延续——变化的是放置规则,不变的是”查询 → 执行”的流程骨架。用接口来表达骨架,用实现类来封装变化。
三、架构全景:四个子系统如何协作
┌─────────────────────────────────────────────────────────┐│ 摄像机系统 ││ CameraController + ViewportLimitation(Singleton) ││ 职责:移动 / 旋转 / 缩放 / 边界约束 ││ 技术:协程 + Rigidbody + 自维护欧拉角 │└─────────────────────────────────────────────────────────┘ │ │ 提供交互入口 ▼┌─────────────────────────────────────────────────────────┐│ 放置系统 ││ ┌───────────────────────────────────────────────────┐ ││ │ PlacementSystem │ ││ │ (总指挥:持有 Context、状态机、当前策略) │ ││ └──────────┬──────────────────┬─────────────────────┘ ││ │ │ ││ ┌──────────▼────────┐ ┌─────▼──────────────────────┐ ││ │ IPlacementStrategy│ │ PlacementContext │ ││ │ (策略接口) │ │ (依赖打包) │ ││ │ ├ BuildingStrategy│ │ ├ ObjectPlacer │ ││ │ └ RoadStrategy │ │ ├ GridData │ ││ └────────────────────┘ │ └ PreviewSystem │ ││ └────────────────────────────┘ │└─────────────────────────────────────────────────────────┘ │ │ 读写数据 ▼┌─────────────────────────────────────────────────────────┐│ 数据层 ││ ObjectDatabaseSO (ScriptableObject) ← 设计时静态配置 ││ GridData (Dictionary<Vector3Int, PlacementData>) ← 运行时 ││ 职责:建筑数据库 + 网格状态管理 │└─────────────────────────────────────────────────────────┘ │ │ 事件解耦 ▼┌─────────────────────────────────────────────────────────┐│ 事件总线(规划中) ││ EventBus ← 建筑放置后通知 UI 更新、经济系统扣费 ││ 职责:模块间松耦合通信 │└─────────────────────────────────────────────────────────┘四层之间通过清晰的接口和 Context 对象通信。没有跨层直接引用——摄像机只知道 ViewportLimitation,不知道网格;放置系统只知道 PlacementContext 中的接口,不知道摄像机的实现细节。
四、一人开发中的设计决策:什么时候应该重构
业余开发最大的陷阱是:要么永远不重构,要么过早过度设计。我踩过两个极端。
教训一:永远不重构 → 代码墙
BuildingAreaManager 从第 1 个道路类型到第 4 个,每次加都是复制粘贴改参数。到第 4 种道路形态(CrossroadPath)时,我已经记不住每个分支的对应关系了。Debug 时需要在 170 行里肉眼找”这个旋转 + 这个方向”的分支。
信号:当你开始用 Ctrl+F 在自己代码里找人看不懂的逻辑时,就该重构了。
教训二:过早抽象 → 基类地狱
放置系统的第一版策略模式,我写了一个 BasePlacementStrategy 基类,把所有”可能被覆写”的方法都标记为 virtual。结果两个策略类有 80% 的继承代码是基类的默认行为,只有 20% 是覆写的差异。
后来我把它改成了纯接口——两个策略类独立实现,有重复代码但逻辑一眼能看完。三个类出现相似模式时才提取,这是 “Rule of Three”——一项重构经验法则:第一次写,第二次复制,第三次才抽象。
什么时候重构的判断标尺
| 信号 | 诊断 | 行动 |
|---|---|---|
| 同类 if-else 超过 3 层 | 条件分支 → 考虑策略模式或字典映射 | 立即 |
| 添加新功能需要改 3+ 个文件 | 耦合度过高 | 立即 |
| 复制粘贴代码超过 20 行 | 可能是抽象候选项 | 等下次再遇到时提取 |
| 写完代码一周后自己看不懂 | 可读性差 | 重构 + 加注释 |
| 不确定要不要抽象 | — | 不抽象。等用到第三次再说 |
核心原则:代码写出来是给人读的,不是给编译器读的。编译器能跑 if-else 嵌套 10 层,但人脑跟踪不了。
五、两条贯穿始终的设计原则
开闭原则(OCP):扩展开放,修改关闭
这八个字是整个架构的最高指导思想。每做一个设计决策我都问自己:加这个新功能,需要改多少已有代码?
| 功能 | 重构前改了哪里 | 重构后改了哪里 |
|---|---|---|
| 加新道路形态 | BuildingAreaManager 里加 ~30 行 if-else | 创建一个 SO asset |
| 加新建筑类型 | BuildingPlacer 里加 else if | 创建一个 SO asset |
| 换一种网格数据结构 | 所有用到网格的类都要改 | 只改 GridData |
| 加新放置模式(如删除) | 所有放置流程代码 | 加一个 RemoveState 类 |
OCP 不是让你一开始就设计好所有的扩展点——那是过度设计。OCP 是让你在第一次加新功能时感受到痛点,第二次加时提炼模式,第三次加时重构。这是一个”感知 → 反应”的循环。
单一职责原则(SRP)
摄像机系统的 CameraController 和 ViewportLimitation 的拆分就是一个 SRP 的实例:
CameraController:“摄像机怎么动”(运动逻辑)ViewportLimitation:“能动的范围”(约束逻辑)
如果不拆分,CameraController 会同时包含移动、旋转、缩放、边界检查、动态阈值计算……类会膨胀到 300+ 行,修改任何一个维度都可能在预期之外破坏另一个维度的行为。
SRP 的实践标准不是”类做了几件事”,而是**“类的修改原因有几个”**。如果两个不同的需求方向(比如”改变移动手感”和”扩大地图边界”)会导致同一个类被修改,那么这个类的职责就该拆了。
六、未完待续:下一步做什么
City Builder 目前只完成了摄像机系统和放置系统。下一个大型功能模块是经济系统。有几个架构上的思考已经开始:
EventBus:模块间的”邮局”
当前放置系统和其他系统(UI、经济)之间还没有通信。放下一栋建筑时,UI 不知道要更新余额,经济系统不知道要扣钱。
直接引用方案:
// ❌ PlacementSystem 直接持有经济系统引用economicSystem.DeductMoney(buildingCost);uiSystem.UpdateBalance();这样做的问题是——每加一个新系统(成就系统、任务系统、音效系统),PlacementSystem 就多一行依赖。很快就变成”中心枢纽黑洞”。
EventBus 方案:
// ✅ 放置成功 → 广播事件EventBus.Trigger(GameEvent.BuildingPlaced, new BuildingPlacedArgs(buildingID, cost));
// 经济系统订阅EventBus.Listen(GameEvent.BuildingPlaced, (args) => DeductMoney(args.cost));
// UI 系统也订阅EventBus.Listen(GameEvent.BuildingPlaced, (args) => UpdateBalance());放置系统不需要知道谁在监听,新系统加入只需要订阅事件。
存档系统:序列化 Dictionary
网格数据存储在 Dictionary<Vector3Int, PlacementData> 中。存档时不能直接把 Dictionary 序列化——Unity 的 JsonUtility 不支持 Dictionary。
几个方案在权衡中:
- 手动转换成 List 再序列化 —— 简单可靠,适合当前规模
- Newtonsoft Json —— 原生支持 Dictionary,但引入额外依赖
- ScriptableObject 存档 —— 运行时数据存 SO 有风险(退出 Unity Editor 后会被重置)
目前倾向方案 1,用最小的复杂度解决问题。
七、总结:一个业余开发者的认知变化
回顾这个项目从第一行代码到现在,最大的变化不是代码量(其实还在早期),而是写代码时的思维习惯:
| 阶段 | 写之前的思考 | 写的代码 | 改代码时 |
|---|---|---|---|
| 初学 | 这个功能怎么写? | 用 Update + if-else 硬堆 | 复制粘贴改参数 |
| 现在 | 这个东西什么会变、什么不变? | 先画 PlantUML 类图,再写代码 | 只改需要变的那块 |
从”能跑就行”到”能改就行”——这大概是业余和专业的真正区别。不是代码量,不是懂得多少种设计模式,而是写下的每一行代码,都清楚地知道将来改动时只需要动多少地方。
City Builder 博客系列的摄像机系统和放置系统部分到此完成。项目还在继续——经济系统、存档、UI 等模块开发完成后会陆续补充新的开发日志。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
