策略模式实战:City Builder 的可扩展放置系统
上一篇搭好了数据层,这篇进入放置系统的核心——如何用策略模式让建筑和道路共享同一套框架,同时保证 “加一种新放置类型” 只需要写一个新策略类,核心代码零改动。
一、开篇:170 行 if-else 的血泪教训
在讲策略模式之前,先看一个反面案例——项目中 BuildingAreaManager 的最初版本。
BuildingAreaManager 负责管理”可建造区域”。当玩家放置一条路时,路两侧的格子需要被标记为”可建造区域”。问题在于——每种道路形状(直道、弯道、十字路口、丁字路口)、每种旋转角度(0°、90°、180°、270°),其两侧标记规则都不一样。
最初版本是这样写的:
// ❌ 反模式:硬编码所有分支if (roadType == RoadType.StraightPath){ if (rotation == Quaternion.Euler(0, 0, 0)) // 横向 { for (int i = 0; i < 2; i++) db.AddEnableToBuildArea(tempPos + Vector3.forward); for (int i = 0; i < 2; i++) db.AddEnableToBuildArea(tempPos - Vector3.forward); } else if (rotation == Quaternion.Euler(0, 90, 0)) // 纵向 { for (int i = 0; i < 2; i++) db.AddEnableToBuildArea(tempPos + Vector3.right); for (int i = 0; i < 2; i++) db.AddEnableToBuildArea(tempPos - Vector3.right); } // ... 还有更多的旋转角度}else if (roadType == RoadType.BendSquare){ // 又来一段 ...}else if (roadType == RoadType.IntersectionPath){ // 又来一段 ...}// ... 170 行这是典型的”类型码 + 条件分支”反模式。每加一种新道路形态,需要:
- 加一个
else if分支(~10 行) - 为每种旋转加子分支(~5 行 × 旋转数)
- 复制粘贴方向偏移逻辑
累计下来,维护成本指数级增长。
但这段代码并非没有价值——它留下了”形状”:所有分支都遵循相同的模式:“往某个方向,延伸 N 格,执行某种操作(添加/删除)“。这个形状就是重构的切入点。
二、策略模式:将”放置算法”变成可替换的零件
核心思想
策略模式的精髓:定义一系列算法,将每个算法封装成独立的类,让它们可以互相替换。算法的使用者不需要知道具体实现——它只需要知道”我可以调用 CanPlaceAt 和 ExecutePlace”。
在 City Builder 的放置系统中,三个角色如下:
| 角色 | 代码 | 职责 |
|---|---|---|
| 策略接口 | IPlacementStrategy | 定义”放置一个东西”的统一契约 |
| 具体策略 | BuildingPlacementStrategy / RoadPlacementStrategy | 实现特定的放置规则 |
| 环境(Context) | PlacementSystem | 持有当前策略,触发放置流程 |
第一步:定义策略接口
public interface IPlacementStrategy{ // 判断能否在指定位置放置 bool CanPlaceAt(Vector3Int gridPosition, PlacementContext ctx);
// 执行放置,返回实例化的 GameObject GameObject ExecutePlace( Vector3Int gridPosition, Quaternion rotation, PlacementContext ctx);}接口只定义了两个方法:查询(能不能放)和命令(执行放置)。这不是偶然的——命令-查询分离原则(CQS)告诉我们:问问题的方法(CanPlaceAt)不应有副作用,做操作的方法(ExecutePlace)才产生副作用。这个接口天然遵循了 CQS。
第二步:实现具体策略
建筑放置策略:
public class BuildingPlacementStrategy : IPlacementStrategy{ private ObjectData objectData;
public BuildingPlacementStrategy(ObjectData data) { objectData = data; }
public bool CanPlaceAt(Vector3Int gridPosition, PlacementContext ctx) { // 检查一:网格是否被占用 bool notOccupied = ctx.roadData.CanPlaceObjectAt( gridPosition, objectData.Size );
// 检查二:是否在建筑区域内(建筑独有) bool inBuildingArea = ctx.BuildingAreaDatabase.CanBuildAt( gridPosition, objectData.Size );
return notOccupied && inBuildingArea; }
public GameObject ExecutePlace( Vector3Int gridPosition, Quaternion rotation, PlacementContext ctx) { // 第一步:实例化预制体(通过 Context 中的 ObjectPlacer) GameObject obj = ctx.objectPlacer.PlacedObject( objectData.Prefab, gridPosition, rotation );
// 第二步:更新逻辑网格(通过 Context 中的 GridData) ctx.roadData.AddObjectAt( gridPosition, objectData.Size, objectData.ID, obj.GetComponent<PlacedObject>().Index, rotation );
return obj; }}道路放置策略:
public class RoadPlacementStrategy : IPlacementStrategy{ private ObjectData objectData;
public RoadPlacementStrategy(ObjectData data) { objectData = data; }
public bool CanPlaceAt(Vector3Int gridPosition, PlacementContext ctx) { // 道路只需要检查占用,不需要建筑区域检查 return ctx.roadData.CanPlaceObjectAt(gridPosition, objectData.Size); }
public GameObject ExecutePlace( Vector3Int gridPosition, Quaternion rotation, PlacementContext ctx) { // 流程与建筑完全相同:实例化 → 更新网格 GameObject obj = ctx.objectPlacer.PlacedObject( objectData.Prefab, gridPosition, rotation ); ctx.roadData.AddObjectAt( gridPosition, objectData.Size, objectData.ID, obj.GetComponent<PlacedObject>().Index, rotation ); return obj; }}关键观察
建筑策略比道路策略多一个条件:inBuildingArea。而道路策略的 CanPlaceAt 只有一行。
这就是策略模式的力量——每个策略只封装自己需要关心的规则。建筑的”建筑区域检查”逻辑只在 BuildingPlacementStrategy 中存在,不影响道路策略,也不会泄漏到 PlacementSystem 中。
将来如果你想加一个”装饰物放置策略”(可以在建筑区域外放置,但必须在道路旁边),只需要新建 DecorationPlacementStrategy,在 CanPlaceAt 里加一条”检查邻近格子是否有道路”,然后注册即可。 PlacementSystem 一行不用改。
三、PlacementContext:解决接口膨胀的关键设计
问题:策略接口的参数该怎么传
策略的 CanPlaceAt 和 ExecutePlace 都需要访问外部依赖——GridData、ObjectPlacer、BuildingAreaDatabase 等等。最直接的方式是:
// ❌ 参数列表爆炸public interface IPlacementStrategy{ bool CanPlaceAt( Vector3Int gridPosition, GridData roadData, BuildingAreaDatabase buildingAreaDb, ObjectPlacer objectPlacer, PreviewSystem previewSystem, Grid grid, // 将来再加一个依赖 → 所有策略类签名都得改 Transform structureParent );}每加一个新依赖,接口就膨胀一次,所有实现类跟着报错。这就是接口膨胀问题。
解法:打包外部依赖 → PlacementContext
public class PlacementContext{ public GridData roadData; public BuildingAreaDatabase BuildingAreaDatabase; public ObjectPlacer objectPlacer; public PreviewSystem previewSystem; public Grid grid; public Transform structureParent;}然后策略接口保持干净:
public interface IPlacementStrategy{ bool CanPlaceAt(Vector3Int gridPosition, PlacementContext ctx); GameObject ExecutePlace( Vector3Int gridPosition, Quaternion rotation, PlacementContext ctx);}加一个新依赖?只改 PlacementContext 一个类。所有策略类不需要动。
PlacementContext 是”依赖倒置”还是”上帝对象”?
有人说把所有依赖打包到一个 Context 里等于造了一个上帝对象。区别在于——上帝对象是逻辑 + 数据的混合体,而 PlacementContext 是纯数据容器,没有方法逻辑。它只是一个”数据包”,把策略执行过程中可能用到的引用集中传递。这和 C# 中的 EventArgs、Android 中的 Intent 是同一思路。
四、PlacementSystem:总指挥的工作流
PlacementSystem 持有当前策略和状态机,是放置流程的入口:
public class PlacementSystem : MonoBehaviour{ private PlacementContext ctx; private GridData roadData; private PlacementStateMachine stateMachine;
void Start() { // 初始化 Context —— 把所有依赖注入 ctx = new PlacementContext { roadData = roadData, objectPlacer = GetComponent<ObjectPlacer>(), previewSystem = GetComponent<PreviewSystem>(), grid = GetComponent<Grid>(), structureParent = transform }; }
// 外部调用:开始放置某种建筑 public void StartPlacement(int objectID) { // 从数据库查数据 ObjectData data = objectDatabase.objectDataList .Find(o => o.ID == objectID);
// 根据类型创建策略 IPlacementStrategy strategy = data.Type == ObjectType.Building ? new BuildingPlacementStrategy(data) : new RoadPlacementStrategy(data);
// 状态机切换到"放置中"状态 stateMachine.TransitionTo(new PlacementState(strategy, ctx)); }
void Update() { // 每帧委托状态机处理 stateMachine.CurrentState?.UpdateState(gridPosition); }}为什么不直接在 PlacementSystem 里写 if-else,而要引入状态机?
PlacementStateMachine 目前只有两个状态:IdleState 和 PlacementState。看起来状态机有点大材小用。
但状态机的优势在于——扩展时不需要改动现有关键流程。将来如果你加”批量放置模式”(按住 Shift 拖拽放置一排建筑)或”移除模式”(点击删除),加一个新状态类即可,Update 中的 stateMachine.CurrentState?.UpdateState() 不用改写。这也是一种开闭原则的实践。
五、完整交互流程
注意第 14 步和第 19 步的区别——CanPlaceAt 调用了两次:一次是鼠标移动时做预览判断(第 8 步),一次是点击时做最终确认(第 14 步)。这是在防御一个竞态条件:玩家在预览显示”能放”和实际点击之间,另一个游戏系统可能已经修改了该格子的状态。先检查再操作,每次都做,不信任缓存。
六、开闭原则:衡量扩展性的试金石
开闭原则:对扩展开放,对修改关闭。
检验标准很简单:加一个新功能,需要改多少已有代码?
| 场景 | 需要改什么 | 行数 |
|---|---|---|
| 加一种新建筑类型 | 创建一个 ObjectData asset(ScriptableObject) | 0 行代码 |
| 加一种新道路类型 | 同上 | 0 行代码 |
| 加一种新放置类型(如”装饰物”) | 新建 DecorationPlacementStrategy + 在 StartPlacement 加一个分支 | ~20 行 |
| 改变网格数据结构 | 只改 GridData 内部实现 | 策略类和 PlacementSystem 不动 |
对比重构前——加一种新道路形态需要在 BuildingAreaManager 里写 ~30 行 if-else——策略模式把”新功能的新增代码”局限在一个新类中,不污染现有逻辑。
七、已知局限与反思
策略类膨胀
策略模式的代价是类数量会增加。目前只有两个策略类还好,但如果将来有 10 种放置类型(每种建筑风格、每种道路形态一个策略),文件数量会快速膨胀。
缓解方案:将”共性”提取到抽象基类中,子策略只覆写差异部分。例如:
public abstract class BasePlacementStrategy : IPlacementStrategy{ protected ObjectData objectData;
public virtual bool CanPlaceAt(Vector3Int pos, PlacementContext ctx) { // 所有策略的通用检查:网格是否被占用 return ctx.roadData.CanPlaceObjectAt(pos, objectData.Size); }
// ExecutePlace 也提供默认实现,子类可覆写}RoadPlacementStrategy 直接用基类的 CanPlaceAt(只需要默认的占用检查),BuildingPlacementStrategy 覆写加一个建筑区域检查。这样策略类数量不减,但每个类的代码量更小。
ExecutePlace 的重复代码
建筑策略和道路策略的 ExecutePlace 几乎一模一样——都是 “实例化 → 更新网格”。这暗示可以提取到基类中,但目前的实现故意保留了这个”重复”,因为将来道路放置可能会有额外步骤(比如更新路网连接),提取到基类后再加特例会破坏基类的纯洁性。宁可有一点重复,也不要过早抽象——等第三个策略类出现时再看是否有提取的必要。
八、完整架构
九、总结
| 模块 | 技术方案 | 设计意图 |
|---|---|---|
| 策略接口 | IPlacementStrategy | 统一契约,让使用者不依赖具体实现 |
| 具体策略 | Building- / Road-PlacementStrategy | 每个类封装一类对象的专属规则,修改互不影响 |
| 上下文对象 | PlacementContext | 打包外部依赖,防止接口膨胀,支持未来扩展 |
| 流程控制 | PlacementSystem + 状态机 | 状态机为将来加新模式(批量、移除)预留扩展点 |
核心收获
-
策略模式不只是一种模式,它是一种思维习惯。当你看到”不同类型有不同行为”时,条件反射地想到接口+多态,而不是 if-else。
-
PlacementContext 是策略模式在 Unity 中的实战补丁。Unity 中
MonoBehaviour不能通过构造函数注入依赖(由引擎管理生命周期),Context 对象是在这种约束下的务实解法。 -
开闭原则是检验重构成果的唯一标准。不要问”代码写得好不好”,问”加新功能需要改几处”。如果答案是”1 处”(新策略类 + 注册),方向就对了。
-
不要过早抽象。两个策略类的
ExecutePlace重复了?等等,看第三个策略类出现时是否还一样。过早提取基类会导致后期为了”打破基类约束”而写出更复杂的代码。
下一篇:《City Builder 架构总览:一人开发如何避免烂代码》——回顾整个项目从 170 行 if-else 到数据驱动+策略模式的架构演进历程,总结一人开发中的设计决策教训。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
