2396 字
6 分钟
策略模式实战:City Builder 的可扩展放置系统
2026-06-26

策略模式实战: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 行

这是典型的”类型码 + 条件分支”反模式。每加一种新道路形态,需要:

  1. 加一个 else if 分支(~10 行)
  2. 为每种旋转加子分支(~5 行 × 旋转数)
  3. 复制粘贴方向偏移逻辑

累计下来,维护成本指数级增长。

但这段代码并非没有价值——它留下了”形状”:所有分支都遵循相同的模式:“往某个方向,延伸 N 格,执行某种操作(添加/删除)“。这个形状就是重构的切入点。


二、策略模式:将”放置算法”变成可替换的零件#

核心思想#

策略模式的精髓:定义一系列算法,将每个算法封装成独立的类,让它们可以互相替换。算法的使用者不需要知道具体实现——它只需要知道”我可以调用 CanPlaceAtExecutePlace”。

在 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:解决接口膨胀的关键设计#

问题:策略接口的参数该怎么传#

策略的 CanPlaceAtExecutePlace 都需要访问外部依赖——GridDataObjectPlacerBuildingAreaDatabase 等等。最直接的方式是:

// ❌ 参数列表爆炸
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 目前只有两个状态:IdleStatePlacementState。看起来状态机有点大材小用。

但状态机的优势在于——扩展时不需要改动现有关键流程。将来如果你加”批量放置模式”(按住 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 + 状态机状态机为将来加新模式(批量、移除)预留扩展点

核心收获#

  1. 策略模式不只是一种模式,它是一种思维习惯。当你看到”不同类型有不同行为”时,条件反射地想到接口+多态,而不是 if-else。

  2. PlacementContext 是策略模式在 Unity 中的实战补丁。Unity 中 MonoBehaviour 不能通过构造函数注入依赖(由引擎管理生命周期),Context 对象是在这种约束下的务实解法。

  3. 开闭原则是检验重构成果的唯一标准。不要问”代码写得好不好”,问”加新功能需要改几处”。如果答案是”1 处”(新策略类 + 注册),方向就对了。

  4. 不要过早抽象。两个策略类的 ExecutePlace 重复了?等等,看第三个策略类出现时是否还一样。过早提取基类会导致后期为了”打破基类约束”而写出更复杂的代码。


下一篇:《City Builder 架构总览:一人开发如何避免烂代码》——回顾整个项目从 170 行 if-else 到数据驱动+策略模式的架构演进历程,总结一人开发中的设计决策教训。

分享

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

策略模式实战:City Builder 的可扩展放置系统
https://www.losrhythm.world/posts/citybuilder/03-strategy-pattern-placement/
作者
LosRhythm
发布于
2026-06-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录