干Unity这些年,最让我头疼的从来不是渲染管线,也不是网络同步,而是项目中期那批盘根错节的脚本依赖:一个敌人AI挂了四五个开关,技能系统里到处是switch-case,UI按钮点了之后直接去改GameManager的公有字段。越往后越不敢动。这个系列聊了面向对象基础、SOLID原则、以及不少主流设计模式,今天这篇把真正在实战里救过命的“其他设计模式”补完——策略、组合、命令、模板方法、责任链,每一种我都会结合Unity里的真实场景重新讲一遍,讲清楚到底解决什么问题、为什么好用、坑在哪里。
1. 为什么写了这么多期,还要单独拎出“其他设计模式”
1.1 设计模式在Unity里不是用来炫技的
很多新手朋友容易走两个极端:要么一个模式都不用,全部脚本扁平排列,要么一上来就照着GoF的23种模式硬套,代码里全是抽象工厂和访问者,跑起来却连暂停游戏都做不利索。我自己早期属于第二种,总觉得自己写了抽象类就显得高级,直到被一个同事重构了三个月才醒悟:Unity里的设计模式,首要价值是把“变化点”隔离出来,而不是把简单问题包装得复杂。
“其他设计模式”这个词听起来像边角料,实际上指的是那些不适合用大类目一把抓、但在Unity工程里极其常用的模式。比如策略模式,很多教程把它归到行为型,但在Unity的敌方AI、技能模块、甚至技能编辑器里,它的出现频率远超工厂模式和单例。组合模式也是,Unity的Transform层级骨子里就是一棵树,UI的Panel里面叠Panel、技能树里面挂技能子节点,全都可以用组合模式理解,但真正把这层关系讲透的资料并不多。
1.2 这套模式组合能覆盖哪些真实场景
我挑这五个模式,不是随便凑数,而是因为它们在Unity项目里的分工非常互补:
- 策略模式:解决“同一类行为,有多种算法实现,且运行时可以切换”的问题,典型场景是敌人AI的巡逻/追击/逃跑,以及技能伤害计算里的各种系数。
- 组合模式:解决“单个对象和对象集合应当对客户端保持一致”的问题,典型场景是UI层级嵌套、技能树结构、场景节点批量操作。
- 命令模式:解决“把请求封装成对象,支持记录、撤销、重放”的问题,典型场景是输入系统、关卡编辑器里的Ctrl+Z、战斗录像回放。
- 模板方法模式:解决“整体流程固定,局部步骤由子类扩展”的问题,典型场景是关卡加载流程、通用交互流程。
- 责任链模式:解决“一个请求可能被多个对象处理,且处理者顺序动态排列”的问题,典型场景是UI事件拦截、多段伤害判定、Buff优先级结算。
这个组合还有一个好处:它们之间天然能搭配起来用。命令模式可以把玩家输入变成命令对象,策略模式决定这个命令的具体执行算法,责任链模型可以在命令执行前做权限拦截,模板方法模式把整条流程固定好。到了项目后期,代码结构会非常舒服。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 策略模式:从敌人AI和技能系统里的switch说起
2.1 需求是怎么从“简单”变成“地狱”的
我接手一个RPG项目的技能系统时,最初的代码长这样:
csharp复制public float CalculateDamage(AttackData data)
{
float damage = data.baseDamage;
switch (data.skillType)
{
case SkillType.Melee:
damage = data.baseDamage * 1.2f + attacker.attackPower;
break;
case SkillType.Ranged:
damage = data.baseDamage * 0.9f + attacker.attackPower * 0.5f + attacker.dexterity * 0.7f;
break;
case SkillType.Magic:
damage = data.baseDamage * 1.5f + attacker.magicPower;
break;
}
return damage;
}
刚开始只有三种技能,没问题。后来加了火球术、冰霜箭、毒刃、圣光术……这个函数越来越长。更要命的是,策划说“火球术在灼烧状态下伤害翻倍”,我只能在里面再写一个if。当这个函数超过300行时,我已经不敢改了,因为我不知道哪段逻辑还被别人调用。
这还不是最恶劣的。敌人AI那边更离谱:一个EnemyController里用枚举区分状态,每个状态在Update()里做判断,当状态从巡逻切到追击时,需要清空若干标记、重置几个协程、再通知动画系统切换动画。如果切到一半玩家退出了锁定范围,还要再切回巡逻。用switch-case写出来,每个case里全是残留状态,bug极其难查。
2.2 用策略模式重组敌人AI
策略模式的核心思路特别简单:把“算法”抽成一组实现同一接口的类,运行时可以互相替换。拿敌人AI来说,行为就是“下一步往哪走”,可以抽成这样:
csharp复制public interface IEnemyStrategy
{
void UpdateBehavior(EnemyContext context);
}
不同行为各自实现这个接口:
csharp复制public class PatrolStrategy : IEnemyStrategy
{
private Transform[] waypoints;
private int index;
public PatrolStrategy(Transform[] waypoints)
{
this.waypoints = waypoints;
}
public void UpdateBehavior(EnemyContext context)
{
if (Vector3.Distance(context.transform.position, waypoints[index].position) < 0.5f)
{
index = (index + 1) % waypoints.Length;
}
context.agent.SetDestination(waypoints[index].position);
context.animator.SetBool("Running", false);
}
}
追击策略:
csharp复制public class ChaseStrategy : IEnemyStrategy
{
public void UpdateBehavior(EnemyContext context)
{
context.agent.SetDestination(context.target.position);
context.animator.SetBool("Running", true);
}
}
敌人在运行时切换策略:
csharp复制public class EnemyContext : MonoBehaviour
{
public IEnemyStrategy strategy;
public Transform target;
public NavMeshAgent agent;
public Animator animator;
public void SetStrategy(IEnemyStrategy newStrategy)
{
strategy = newStrategy;
}
private void Update()
{
strategy?.UpdateBehavior(this);
}
}
这样以后加“逃跑”“巡逻+远程射击”策略,只需要再写一个类,原来的代码一行不用改。这符合开闭原则,也符合游戏开发里“策划随时改行为”的实际节奏。
2.3 策略模式在技能伤害计算里的落地
技能伤害计算也可以这么做。把每种伤害计算都实现一套接口,配合ScriptableObject做成数据驱动:
csharp复制public interface IDamageStrategy
{
float CalculateDamage(AttackData data);
}
然后火球术策略:
csharp复制[CreateAssetMenu(menuName = "Strategy/FireballDamage")]
public class FireballDamageStrategy : ScriptableObject, IDamageStrategy
{
public float baseMultiplier = 1.5f;
public float burnBonus = 2f;
public float CalculateDamage(AttackData data)
{
float damage = data.baseDamage * baseMultiplier + data.attacker.magicPower;
if (data.targetEffect == EffectType.Burning)
{
damage *= burnBonus;
}
return damage;
}
}
技能配置里挂一个ScriptableObject引用,策划在编辑器里调整数值,不需要动代码。
2.4 踩坑记录:策略模式与状态模式的边界
策略模式容易被新手和状态模式搞混。我早期的AI代码也想用状态机,但真做下来发现:状态机强调的是“状态之间的流转规则”,比如受击状态可以被击退、不能攻击;策略模式强调的是“独立算法的可替换性”,它不关心里面怎么流转,只关心给我一个输入,我算出行为。在敌人AI这种需求里,一般先问自己:行为之间是否存在强约束(比如“眩晕状态不能切换”),如果有,优先状态机;如果没有强约束,只用策略模式就够了。
另一个坑是“策略持有Transform引用导致无法序列化”。如果你在策略类里保存了Transform和NavMeshAgent,要注意对象生命周期,敌人死了但策略还在引用它,容易造成内存泄漏。最好让策略只存数据,或者策略方法入参传入EnemyContext,由Context统一管理组件生命周期。
3. 组合模式:UI树、技能树与场景节点树其实是同一回事
3.1 Unity里的“树”俯拾即是
Unity的Transform层级设计,天然就是一棵树。UI系统里,一个Panel可以叠加好几个Button,Button里面又能放Text和Icon;技能系统里,一个高级技能需要先解锁前置技能,而前置技能本身可能也是一个技能组。处理这批层级结构时,最容易出现的问题就是客户端逻辑里到处判断“当前对象是叶子还是树枝”,代码里if(getChildCount()>0)满天飞。
组合模式就是为了解决这个问题的:它允许你将单个对象和对象的组合看作是同一种类型。客户端无需关心自己正在操作的是单个物体还是整体,都能一致执行。
3.2 用组合模式做技能树
比如我要做一套技能树,技能分两种:单个技能SkillLeaf,以及技能组SkillGroup。技能组里既可以挂单个技能,也可以挂其他技能组。
csharp复制public abstract class SkillNode
{
public string skillName;
public bool isUnlocked;
public abstract bool CanUnlock();
public abstract void Unlock();
}
叶子节点:
csharp复制public class SkillLeaf : SkillNode
{
public Requirement[] requirements;
public override bool CanUnlock()
{
foreach (var r in requirements)
{
if (!r.IsMet) return false;
}
return true;
}
public override void Unlock()
{
if (!CanUnlock())
{
Debug.LogError($"技能 {skillName} 前置条件未满足");
return;
}
isUnlocked = true;
Debug.Log($"解锁技能:{skillName}");
}
}
组合节点:
csharp复制public class SkillGroup : SkillNode
{
public List<SkillNode> children = new List<SkillNode>();
public override bool CanUnlock()
{
foreach (var child in children)
{
if (!child.CanUnlock()) return false;
}
return true;
}
public override void Unlock()
{
foreach (var child in children)
{
child.Unlock();
}
isUnlocked = true;
}
}
这样客户端调用时完全不用区分:到底是一个技能还是一个技能组,只需要对根节点调用Unlock(),递归结构会自动处理下级节点。
3.3 UI嵌套、物理批处理与组合模式
组合模式在UI里最典型的表现是“UI元素的嵌套控制”。比如活动弹窗,它有底板、标题、关闭按钮、奖励列表,奖励列表里每项又是图标+数量+名字。需求是:一次性设置整个弹窗的可用状态,或者统一做淡入淡出。
用组合模式抽象后,可以做出一种通用的UIGroup组件:
csharp复制public abstract class UIGroup : MonoBehaviour
{
public abstract void Show();
public abstract void Hide();
}
每个叶子控件实现自己的显隐逻辑,Panel作为组合节点,把Show()和Hide()分发给所有子节点。这样无论多深的UI嵌套,调用同一个入口,所有子节点都被统一管理。如果再配合CanvasGroup的alpha控制,一个淡入淡出就能带动整棵树。
3.4 组合模式在场景节点批量操作中的应用
场景节点其实可以更“组合”。我做关卡编辑器时,需要对一整个房间做统一开关碰撞体、统一设置可见性、统一挂载音频源等操作。用代码写一大串递归查找子物体很麻烦,有了抽象节点接口之后,可以直接对根物体调用,让所有子节点按需处理。
踩过的一个坑是:组合模式配合Unity的UI事件系统,容易出现重复执行。因为组合节点有一部分子节点自身也挂了勾选Toggle的事件监听,父节点在执行foreach时会触发子节点事件,子节点又会反过来修改父节点状态,结果就死循环了。稳妥做法是:在事件回调里加一个isApplying标记,防止重复执行。
4. 命令模式:输入系统、录像回放与撤销栈的统一抽象
4.1 为什么直接用Input.GetKeyDown写业务逻辑会后悔
一般新手项目里,玩家移动和跳跃直接写在Update():
csharp复制if (Input.GetKeyDown(KeyCode.Space))
{
player.Jump();
}
看着简单,但游戏上线后问题就来了:想给玩家录一段操作回放,发现没有历史记录;想让服务器回滚上一个错误操作,发现没法撤销;换了一套操作设备(手柄、触屏),又得在这里加一堆if。命令模式的价值就在于,它不是让“Jump()”被直接调用,而是把“跳跃”这个请求封装成一个命令对象,由调用方决定何时执行、是否记录、是否撤销。
4.2 命令模式的基本骨架
一个最简单的命令接口:
csharp复制public interface ICommand
{
void Execute();
void Undo();
}
跳跃命令:
csharp复制public class JumpCommand : ICommand
{
private PlayerController player;
public JumpCommand(PlayerController controller)
{
player = controller;
}
public void Execute()
{
player.Jump();
}
public void Undo()
{
player.MoveToPreviousPosition();
}
}
输入系统只负责生产命令并交给Invoker:
csharp复制public class InputInvoker : MonoBehaviour
{
private Stack<ICommand> history = new Stack<ICommand>();
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
ICommand cmd = new JumpCommand(player);
cmd.Execute();
history.Push(cmd);
}
}
public void UndoLastCommand()
{
if (history.Count > 0)
{
ICommand cmd = history.Pop();
cmd.Undo();
}
}
}
4.3 命令模式做战斗录像回放
命令模式最爽的一个应用是战斗录像回放。我之前做横版动作游戏回放系统时,没有第一时间采用命令模式,而是直接记录了每一帧的玩家坐标和输入状态,结果定位到某个技能释放问题时,Debug回放卡得不行,因为回放的是“状态快照”,不是“操作意图”。
改用命令模式之后,每一条输入都生成一个ICommand,并记录命令名、参数、触发时间。回放时按时间戳重放命令:
csharp复制public class ReplaySystem : MonoBehaviour
{
private List<TimedCommand> timeline = new List<TimedCommand>();
private float currentTime;
public void Play()
{
var nextCmd = timeline[timelineIndex];
while (nextCmd.time <= currentTime)
{
nextCmd.command.Execute();
timelineIndex++;
if (timelineIndex >= timeline.Count) break;
nextCmd = timeline[timelineIndex];
}
currentTime += Time.deltaTime;
}
}
这套回放记录的其实是“玩家做了什么”,而不是“最终是什么状态”。好处是:如果后来数值调整了,回放同一个命令序列,会得到新的正确结果;而快照回放只会展示旧结果,调试就没意义了。
4.4 撤销时最容易踩的坑:非幂等操作
命令模式看着简单,但真正实现撤销时,最常遇到的坑是“撤销操作不是命令的逆操作”。比如一个命令是“播放特写镜头”,执行完以后角色换了个位置,你的Undo()如果只把角色位置改回去,却没有恢复镜头状态,那撤销就是不完整的。
我的判断标准是:Undo()必须把系统恢复到Execute()之前的那一刻,包括动画、UI、物理状态。如果做不到,就干脆不要承诺“可撤销”,否则用户会看到更诡异的bug。另一个实战细节是:在Unity里,命令栈不要无限增长。一个战斗场景里每帧可能有几十条命令,如果全压栈,几个小时后内存会爆。建议设一个上限,比如history超过100条就把最旧的丢弃,不丢反而影响体验。
5. 模板方法模式:把关卡流程和通用交互流程固定成骨架
5.1 为什么每关的流程代码都长得一样
每个关卡的流程基本一致:播片、初始化主角、生成敌人、等待通关条件、展示结算界面。但如果每个关卡都用独立脚本手写,很容易出现“A关卡忘了切BGM”“C关卡初始化顺序反了导致闪黑屏”这种低级错误。
模板方法模式就是干这个的:把整体流程在基类里定好,子类只负责实现具体步骤。
csharp复制public abstract class LevelManagerBase : MonoBehaviour
{
public void StartLevel()
{
PlayOpeningCutscene();
LoadLevelData();
SpawnPlayer();
SpawnEnemies();
SetupLevelEvent();
OnLevelReady();
}
protected abstract void PlayOpeningCutscene();
protected abstract void LoadLevelData();
protected abstract void SpawnPlayer();
protected abstract void SpawnEnemies();
protected abstract void SetupLevelEvent();
protected virtual void OnLevelReady() { }
}
每一个关卡继承这个基类,只需要实现自己的SpawnEnemies()和SetupLevelEvent()。像初始化UI、播放开场动画这些公共步骤,在基类做默认实现就可以。这样既保证了所有关卡执行顺序一致,又允许不同关卡扩展差异。
5.2 模板方法在枪械换弹逻辑里的应用
不只关卡流程,枪械系统的换弹逻辑也高度适合模板方法。无论手枪、步枪还是狙击枪,换弹顺序都一样:停火、播动画、等待动画结束、更新备用弹匣与当前弹量、切换到待发状态。但每种枪的动画名、换弹时长、弹匣容量都不同。
基类可以这样设计:
csharp复制public abstract class WeaponBase : MonoBehaviour
{
public void Reload()
{
if (!CanReload()) return;
StopFiring();
PlayReloadAnimation();
StartCoroutine(WaitForReloadComplete());
}
protected abstract bool CanReload();
protected abstract void PlayReloadAnimation();
protected abstract IEnumerator WaitForReloadComplete();
protected virtual void StopFiring()
{
// 默认实现:停止开火动画
}
}
子类只需要关心差异,公共流程不会因为新枪的加入而反复修改。
5.3 模板方法模式与继承的滥用边界
模板方法模式最大的槽点是“容易把继承体系搞得很深”。我在一个项目里见过:BaseEnemy -> HumanEnemy -> KnightEnemy -> FireKnightEnemy -> UltimateFireKnightEnemy,改了基类一个方法,底层十几个类跟着编译。用模板方法模式时,一个重要的原则是:基类只应对“稳定的流程”负责,不要试图把百分之百的逻辑放进去。如果某一步有30%的敌人不吃,那就应该拆成一个子类重写,而不是在基类里堆分支。
优先用组合和组合关系替代过深的继承。比如武器系统,用WeaponController持有ReloadBehaviour组件,比继承更灵活。模板方法模式真正的价值是“保证顺序”和“防止遗漏”,千万别把它当成万能的继承模板。
6. 责任链模式:UI事件穿透、伤害计算与Buff优先级
6.1 UI点击被“吞掉”问题的本质
Unity的UGUI里,最烦人的问题之一就是“点击登录按钮,底下的场景也触发了点击”。这是因为UI射线穿透到3D物体的事件链没有被正确拦截。直接在Button.onClick里挂监听,往往没有从机制上解决穿透问题。
责任链模式的设计思想是:把事件请求按顺序传给多个处理者,每个处理者决定自己是处理掉这个请求,还是传给链上的下一个。
6.2 用责任链处理UI点击穿透
先定义处理者接口:
csharp复制public interface IUIClickHandler
{
bool HandleClick(RaycastResult result);
void SetNext(IUIClickHandler next);
}
默认实现:
csharp复制public class DefaultClickHandler : IUIClickHandler
{
private IUIClickHandler next;
private Func<RaycastResult, bool> onHandle;
public bool HandleClick(RaycastResult result)
{
if (onHandle != null && onHandle(result))
{
return true;
}
return next != null && next.HandleClick(result);
}
public void SetNext(IUIClickHandler handler)
{
next = handler;
}
}
在点击时的处理流程变成:先执行关卡内弹出的技能选择面板,如果它在最上层并且响应了点击,链就结束了;如果它没有响应,再传递到场景里的战斗系统。这样UI层级无论多复杂,事件归属都清晰了。
6.3 责任链做伤害结算与Buff加成
另一个经典场景是伤害计算。一个人物的最终伤害会受到很多阶段影响:首先武器基础伤害,然后是技能增伤、暴击、护甲减免、最终伤害加成。它们有严格的顺序,而且不同Buff的优先级不同。
用责任链包装“伤害计算“:
csharp复制public interface IDamageInterceptor
{
int ProcessDamage(int damage, DamageContext context);
void SetNext(IDamageInterceptor next);
}
每个Buff节点只处理自己的那段逻辑:
csharp复制public class ArmorReductionInterceptor : IDamageInterceptor
{
private IDamageInterceptor next;
public void SetNext(IDamageInterceptor next)
{
this.next = next;
}
public int ProcessDamage(int damage, DamageContext context)
{
int reducedDamage = damage - context.target.armor;
if (next != null)
{
reducedDamage = next.ProcessDamage(reducedDamage, context);
}
return reducedDamage;
}
}
通过配置链的顺序,能实现对数值框架的精确控制,而不需要每个技能都写一堆if嵌套。
6.4 责任链的坑:配置顺序就是业务顺序,千万别写死
责任链模式里最容易踩的坑是“链的顺序被写死在代码里”。有一次我定义了一条伤害计算链,把暴击放在护甲减免后面,结果玩家发现,角色护甲越高,暴击伤害反而越高,因为基础伤害被护甲砍了之后才乘暴击系数,但玩家预期暴击应该按原伤害算。后来我把链的组装搬到一个编辑器配置文件里,策划可以自己拖拽顺序,问题才算彻底解决。
还有一个细节:责任链里的每个处理者必须返回一个明确结果。是“被处理了”还是“继续传递”?如果语义不清晰,排查问题时会无从下手。最好在每个处理者里打日志或者提供断点条件。
7. 几种模式如何配合起来使用
7.1 一个比较完整的示例:现代化技能反馈系统
这些“其他设计模式”很少单打独斗。我做过一个技能反馈系统,包含方向键录制的操作序列、不同角色使用不同技能算法、释放技能前要判断主角是否处于眩晕状态。最初代码互相通知,后来重构成:
- 命令模式封装“输入技能”这个行为,按下技能键时生成一条
SkillCommand,并压入历史栈。 - 策略模式负责技能实际效果:同一个“火焰斩”命令,在不同等级、不同天赋下执行不同的
IDamageStrategy。 - 责任链进行释放锁逻辑:先判断是否眩晕、再判断是否在冷却中、最后判断目标是否合法。
- 模板方法将整个释放流程固定成“检查-扣除消耗-播放动画-创建命中体-计算伤害-清理后处理”。
这时候你会明显感觉到,代码从“对象之间互相调用”变成了“一个个独立模块互相配合”。后面来新需求时,往往只在某个节点里增删一块逻辑,不需要牵连其他系统。
7.2 什么时候不该用这么多设计模式
但我也得提醒一句:设计模式不是万灵药。在小项目或原型阶段,才几百行代码,你非要抽象三个接口加四个工厂类,只会拖慢迭代速度。我判断一个模式该不该上,通常问自己三个问题:
- 这段逻辑会不会因为需求变化而频繁修改?
- 周围系统是否已经大于三个,再修改会不会牵一发动全身?
- 团队里的其他人能不能一眼看懂这个抽象?
如果三个答案都是“是”,那这个模式就值得上;如果有一个是“否”,那就先用最直白的方式写。我的原则是:先让代码能跑,再让代码好改。
7.3 落地建议:以一个Mini项目做试炼场
如果看完这篇文章想练手,建议不要直接重构正式项目,而是先做一个小的Demo:一个小场景,玩家控制角色,怪物拥有三种AI行为,技能系统至少有三种类型,界面两级嵌套,家具包含撤销操作。在这样的项目里把策略、组合、命令、模板方法、责任链都过一遍。做完后你会有种感觉:它们不再是一个个名字干巴巴的概念,而是你工具箱里真实可用的工具。
