2558 字
7 分钟
City Builder 架构总览:一人开发如何避免烂代码
2026-06-26

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

摄像机系统的 CameraControllerViewportLimitation 的拆分就是一个 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

几个方案在权衡中:

  1. 手动转换成 List 再序列化 —— 简单可靠,适合当前规模
  2. Newtonsoft Json —— 原生支持 Dictionary,但引入额外依赖
  3. ScriptableObject 存档 —— 运行时数据存 SO 有风险(退出 Unity Editor 后会被重置)

目前倾向方案 1,用最小的复杂度解决问题。


七、总结:一个业余开发者的认知变化#

回顾这个项目从第一行代码到现在,最大的变化不是代码量(其实还在早期),而是写代码时的思维习惯

阶段写之前的思考写的代码改代码时
初学这个功能怎么写?用 Update + if-else 硬堆复制粘贴改参数
现在这个东西什么会变、什么不变?先画 PlantUML 类图,再写代码只改需要变的那块

从”能跑就行”到”能改就行”——这大概是业余和专业的真正区别。不是代码量,不是懂得多少种设计模式,而是写下的每一行代码,都清楚地知道将来改动时只需要动多少地方


City Builder 博客系列的摄像机系统和放置系统部分到此完成。项目还在继续——经济系统、存档、UI 等模块开发完成后会陆续补充新的开发日志。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

City Builder 架构总览:一人开发如何避免烂代码
https://www.losrhythm.world/posts/citybuilder/04-architecture-overview/
作者
LosRhythm
发布于
2026-06-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录