1. 项目概述:为什么Unity项目需要设计模式?
如果你在Unity里做过几个项目,尤其是那种功能越加越多、代码越来越乱的游戏,肯定有过这样的体验:改一个角色的跳跃逻辑,结果UI不刷新了;想加个新的敌人行为,发现得把半个战斗系统重写一遍。代码像一团纠缠不清的毛线,牵一发而动全身。这就是典型的“面条式代码”问题,而设计模式,就是帮你把这团毛线梳理成清晰、可维护的模块的那把梳子。
这次我们不空谈理论,直接动手,用三个最实用、最能解决Unity开发痛点的行为型设计模式——观察者模式、命令模式和状态模式——来重构你的游戏逻辑。你会发现,它们不是什么高深莫测的学术概念,而是解决“事件通知混乱”、“输入处理耦合”、“状态管理爆炸”这些具体问题的瑞士军刀。通过这次重构,你的代码将变得更容易阅读、更容易扩展,也更不容易出Bug。无论你是正在为项目代码臃肿而头疼的开发者,还是希望提升代码架构能力的学习者,这篇从实战出发的总结都会给你带来直接的启发。
2. 核心设计思路与模式选型考量
在动手写代码之前,我们先得想清楚为什么要用这些模式,以及它们各自解决什么问题。很多教程一上来就讲UML图,但对我们一线开发者来说,模式是“用”出来的,不是“背”出来的。
2.1 问题诊断:典型Unity代码的三大痛点
强耦合的事件通信:这是最常见的问题。比如,玩家血量变化时,你需要更新UI血条、播放受伤音效、触发屏幕特效,可能还要检查成就系统。新手通常会直接在
PlayerHealth脚本里写:FindObjectOfType<UIManager>().UpdateHealthBar(currentHealth); GetComponent<AudioSource>().Play(); ...。这种写法把PlayerHealth和UI、音频、成就系统紧紧绑死。哪天你想把血条逻辑独立成一个模块,或者换一种UI表现方式,就得回来修改PlayerHealth这个核心逻辑类,风险极高。僵化的输入与逻辑绑定:输入处理直接写在
Update里,if (Input.GetKeyDown(KeyCode.Space)) { player.Jump(); }。看起来简单直接,但问题很多。首先,它不支持按键重映射,玩家想改成鼠标右键跳跃?你得硬编码改逻辑。其次,它难以实现“宏命令”(一键执行多个操作)或“撤销/重做”功能。最后,对于网络同步或回放系统,这种紧耦合的输入处理几乎是灾难。用枚举和巨型Switch管理状态:角色的状态(闲置、移动、攻击、死亡)通常用一个
enum和一个巨大的switch语句或一堆if-else来管理。随着状态增多,这个switch会膨胀到几百行,各种状态间的转换条件混杂在一起,改一个状态的行为可能意外影响到另一个。而且,很难清晰地定义“进入状态”、“退出状态”、“状态持续”这些逻辑。
2.2 模式匹配:三大模式如何对症下药
针对上述痛点,我们选择三个模式来重构:
- 观察者模式 (Observer Pattern):解耦事件源与事件处理器。它定义了“一对多”的依赖关系,让多个对象(观察者)监听一个对象(主题)的状态变化。主题状态改变时,自动通知所有观察者,而无需知道它们具体是谁、做了什么。这完美解决了“强耦合事件通信”问题。在Unity中,
UnityEvent和C#的event关键字是其内置的轻量级实现,但我们将构建一个更灵活、类型安全的自定义事件系统。 - 命令模式 (Command Pattern):将请求封装为对象。它将一个动作请求(如“跳跃”、“攻击”)封装成一个独立的对象,这个对象包含了执行该动作所需的所有信息。这样,发出请求的对象(如输入管理器)和执行请求的对象(如玩家控制器)就解耦了。你可以轻松地将命令对象放入队列、记录日志、支持撤销,或者动态改变按键映射。这直接攻克了“僵化的输入绑定”问题。
- 状态模式 (State Pattern):允许对象在其内部状态改变时改变其行为。它为每一种状态创建一个独立的类,并将状态特定的行为封装在这个类中。原始对象(上下文)持有一个当前状态对象的引用,并将行为委托给当前状态对象。这使得增加新状态变得非常容易,并且状态转换逻辑清晰、集中。这是治理“巨型Switch状态机”的良方。
选择这三个模式,是因为它们在游戏开发中应用频率极高,且改造效果立竿见影。下面,我们就进入实战环节,看看如何将它们落地到具体的Unity项目中。
3. 观察者模式实战:构建松耦合的事件总线
我们首先解决最棘手的通信耦合问题。虽然Unity提供了UnityEvent,但在大型项目中,我们往往需要更强大的功能,比如泛型支持、静态访问、严格的事件类型定义。因此,我们常会自己实现一个轻量级的“事件总线”(Event Bus)。
3.1 核心实现:一个类型安全的事件系统
我们不使用UnityEvent那种基于字符串或UnityAction的弱类型事件,而是采用基于C#泛型和委托的强类型方案。这样能在编译期就发现很多错误。
// 1. 定义事件基类 public abstract class GameEvent {} // 2. 定义具体事件类,携带所需数据 public class PlayerHealthChangedEvent : GameEvent { public int CurrentHealth; public int MaxHealth; public GameObject DamageSource; // 可选,伤害来源 } public class EnemyDefeatedEvent : GameEvent { public EnemyController Enemy; public int ExperienceReward; } // 3. 核心事件管理器(事件总线) public static class EventManager { // 使用字典来存储事件类型和对应的委托列表 private static Dictionary<Type, System.Delegate> _eventDelegates = new Dictionary<Type, System.Delegate>(); // 订阅事件 public static void AddListener<T>(System.Action<T> handler) where T : GameEvent { Type eventType = typeof(T); if (_eventDelegates.TryGetValue(eventType, out var existingDelegate)) { _eventDelegates[eventType] = System.Delegate.Combine(existingDelegate, handler); } else { _eventDelegates[eventType] = handler; } } // 取消订阅 public static void RemoveListener<T>(System.Action<T> handler) where T : GameEvent { Type eventType = typeof(T); if (_eventDelegates.TryGetValue(eventType, out var existingDelegate)) { System.Delegate newDelegate = System.Delegate.Remove(existingDelegate, handler); if (newDelegate == null) { _eventDelegates.Remove(eventType); } else { _eventDelegates[eventType] = newDelegate; } } } // 触发事件 public static void TriggerEvent<T>(T gameEvent) where T : GameEvent { Type eventType = typeof(T); if (_eventDelegates.TryGetValue(eventType, out var delegateToInvoke)) { (delegateToInvoke as System.Action<T>)?.Invoke(gameEvent); } } }3.2 应用场景与实操示例
假设我们有一个玩家生命值组件和一个UI血条组件。重构后,它们之间不再直接引用。
事件发布者 (PlayerHealth.cs):
public class PlayerHealth : MonoBehaviour { public int CurrentHealth = 100; public int MaxHealth = 100; public void TakeDamage(int damage, GameObject source) { CurrentHealth -= damage; CurrentHealth = Mathf.Clamp(CurrentHealth, 0, MaxHealth); // 不再直接调用UI,而是触发一个事件 EventManager.TriggerEvent(new PlayerHealthChangedEvent { CurrentHealth = this.CurrentHealth, MaxHealth = this.MaxHealth, DamageSource = source }); if (CurrentHealth <= 0) { // 同样,触发死亡事件 EventManager.TriggerEvent(new PlayerDiedEvent { Player = this.gameObject }); } } }事件订阅者 (HealthBarUI.cs):
public class HealthBarUI : MonoBehaviour { public Slider healthSlider; private void OnEnable() { // 在组件启用时订阅事件 EventManager.AddListener<PlayerHealthChangedEvent>(OnPlayerHealthChanged); } private void OnDisable() { // 在组件禁用时取消订阅,防止内存泄漏! EventManager.RemoveListener<PlayerHealthChangedEvent>(OnPlayerHealthChanged); } private void OnPlayerHealthChanged(PlayerHealthChangedEvent e) { // 收到事件后,更新UI healthSlider.maxValue = e.MaxHealth; healthSlider.value = e.CurrentHealth; } }另一个订阅者 (DamageIndicator.cs):可以独立地监听同一个事件,播放受击方向指示特效,它与HealthBarUI和PlayerHealth完全解耦。
3.3 注意事项与实操心得
注意:内存泄漏风险。这是使用观察者模式最容易踩的坑。如果订阅了事件的MonoBehaviour对象被销毁(如场景切换、对象
Destroy),但没有取消订阅,那么事件总线里仍然保留着对它的委托引用。这个引用会阻止该对象被垃圾回收,导致内存泄漏。务必在OnDisable()或OnDestroy()中调用RemoveListener。
心得1:事件数据的权衡。事件类里应该放多少数据?原则是:提供足够的信息,让订阅者能完成自己的工作,但不要多。比如PlayerHealthChangedEvent提供了当前和最大血量,这足够UI更新。如果音效系统只需要知道“是否受到伤害”,而不关心具体数值,你可以选择触发另一个更轻量级的PlayerDamagedEvent,或者让音效系统自己判断血量变化值。过度臃肿的事件数据会增加耦合。
心得2:避免“事件链”地狱。有时A事件触发B事件的处理函数,B事件的处理函数又触发了C事件……形成一条长长的调用链。这会让调试变得极其困难,因为很难追踪事件的源头和传播路径。尽量让事件处理保持扁平化,一个事件的处理逻辑应尽可能独立、完整。
心得3:考虑使用ScriptableObject作为事件通道。对于更复杂的系统,可以考虑使用ScriptableObject来创建“事件通道”资产。这样可以在编辑器中对事件流进行可视化配置和调试,架构上更清晰,但会引入一定的复杂度。对于中小型项目,上述静态事件总线通常足够高效。
4. 命令模式实战:实现灵活可配置的输入系统
接下来,我们解放被硬编码绑死的输入逻辑。命令模式的核心思想是:把“做什么”这个动作请求,变成一个可以传递、存储、复用的对象。
4.1 核心实现:命令接口与具体命令
首先,定义一个所有命令都必须遵守的契约。
// 命令接口 public interface ICommand { void Execute(); // 可选:实现撤销功能 // void Undo(); } // 具体的跳跃命令 public class JumpCommand : ICommand { private PlayerController _player; // 命令对象可以持有执行者(Receiver)的引用 public JumpCommand(PlayerController player) { _player = player; } public void Execute() { if (_player != null && _player.CanJump) { _player.Jump(); } } } // 具体的攻击命令 public class AttackCommand : ICommand { private PlayerCombat _combat; public AttackCommand(PlayerCombat combat) { _combat = combat; } public void Execute() { _combat?.PerformAttack(); } } // 宏命令:一个命令执行多个子命令 public class MacroCommand : ICommand { private List<ICommand> _commands = new List<ICommand>(); public void AddCommand(ICommand command) { _commands.Add(command); } public void Execute() { foreach (var cmd in _commands) { cmd.Execute(); } } }4.2 输入管理器与命令绑定
现在,我们需要一个输入管理器来监听物理输入,并将其映射到相应的命令对象上。
public class InputManager : MonoBehaviour { // 使用字典来配置按键到命令的映射 private Dictionary<KeyCode, ICommand> _keyCommandMap = new Dictionary<KeyCode, ICommand>(); private PlayerController _playerController; private PlayerCombat _playerCombat; private void Awake() { _playerController = FindObjectOfType<PlayerController>(); _playerCombat = FindObjectOfType<PlayerCombat>(); // 初始化按键映射 ConfigureInputs(); } private void ConfigureInputs() { // 清除旧映射 _keyCommandMap.Clear(); // 绑定按键到命令 _keyCommandMap[KeyCode.Space] = new JumpCommand(_playerController); _keyCommandMap[KeyCode.Mouse0] = new AttackCommand(_playerCombat); // 示例:绑定一个宏命令到F键(比如同时使用药水和强化技能) var macroCmd = new MacroCommand(); macroCmd.AddCommand(new UsePotionCommand(_playerController)); macroCmd.AddCommand(new BuffSkillCommand(_playerCombat)); _keyCommandMap[KeyCode.F] = macroCmd; } private void Update() { // 遍历映射表,检测按键并执行对应命令 foreach (var kvp in _keyCommandMap) { if (Input.GetKeyDown(kvp.Key)) { kvp.Value?.Execute(); } } } // 提供一个公共方法,允许运行时动态修改按键绑定(用于游戏内的按键设置) public void RebindKey(KeyCode oldKey, KeyCode newKey) { if (_keyCommandMap.TryGetValue(oldKey, out var command)) { _keyCommandMap.Remove(oldKey); // 注意:这里可能需要处理新键已存在的情况 if (!_keyCommandMap.ContainsKey(newKey)) { _keyCommandMap[newKey] = command; } else { Debug.LogWarning($"Key {newKey} is already bound!"); // 或者选择覆盖:_keyCommandMap[newKey] = command; } } } }4.3 高级应用:命令队列、撤销与网络同步
命令模式的威力远不止于此。
- 命令队列:你可以把命令对象放入一个队列,而不是立即执行。这对于实现“回合制游戏”、“输入缓冲”(如街霸的搓招预输入)或“网络延迟补偿”非常有用。输入管理器在
Update中只负责将命令入队,另一个系统在FixedUpdate或特定时机按顺序出队执行。 - 撤销/重做:如果命令实现了
Undo()方法,并保存了执行前的状态,你就可以轻松实现撤销功能。这在策略游戏、编辑器工具中是无价之宝。只需维护一个已执行命令的历史栈即可。 - 网络同步与回放:在网络游戏中,你可以将命令对象序列化,通过网络发送给其他客户端。所有客户端按相同的顺序执行相同的命令,就能实现确定性的状态同步。同理,记录下每一帧的所有命令,就可以实现完美的游戏回放系统。
4.4 注意事项与实操心得
注意:命令对象的生命周期。如果命令对象持有对MonoBehaviour对象的引用(如
_player),要确保在目标对象被销毁后,命令不再被执行,否则会引发空引用异常。可以在命令的Execute()方法开始时检查引用是否为null,或者设计一个机制在目标销毁时清理所有相关的命令。
心得1:区分“瞬时命令”与“持续命令”。跳跃、攻击通常是瞬时命令(按下即触发一次)。但移动(如按住W前进)或蓄力攻击是持续命令。对于持续命令,你可能需要定义ICommand的ExecuteContinuous()方法,并在Update中持续调用,或者在命令内部管理一个计时器。
心得2:结合ScriptableObject创建数据驱动的命令。你可以将命令的配置(如伤害值、冷却时间、特效预制体)做进ScriptableObject资产中。这样,策划或设计师可以在不修改代码的情况下调整游戏行为,AttackCommand只需引用这个ScriptableObject即可。这大大提升了项目的可配置性和团队协作效率。
心得3:性能考量。每帧在Update里遍历整个按键映射字典,在映射项很多时可能有轻微开销。对于绝大多数情况,这都不是问题。如果确实需要优化,可以考虑按输入设备(键盘、鼠标、手柄)分组,或者使用更高效的数据结构。但切忌过早优化,清晰的架构永远是第一位的。
5. 状态模式实战:重构混乱的角色状态机
最后,我们来治理那个令人头疼的巨型状态枚举和switch语句。状态模式将每个状态的行为封装到独立的类中,让状态转换逻辑变得清晰。
5.1 核心实现:状态接口与上下文
首先定义状态接口和角色(上下文)类。
// 状态接口 public interface IPlayerState { void EnterState(PlayerController player); void UpdateState(PlayerController player); void ExitState(PlayerController player); } // 角色控制器(上下文) public class PlayerController : MonoBehaviour { // 当前状态引用 public IPlayerState CurrentState { get; private set; } // 状态实例(通常共享,无需每个角色都new一个) public readonly IdleState IdleState = new IdleState(); public readonly RunState RunState = new RunState(); public readonly JumpState JumpState = new JumpState(); public readonly AttackState AttackState = new AttackState(); // 角色属性 public float MoveSpeed = 5f; public float JumpForce = 10f; public bool IsGrounded { get; set; } public Rigidbody2D Rb { get; private set; } public Animator Anim { get; private set; } private void Awake() { Rb = GetComponent<Rigidbody2D>(); Anim = GetComponent<Animator>(); } private void Start() { // 初始状态 TransitionToState(IdleState); } private void Update() { // 将更新委托给当前状态 CurrentState?.UpdateState(this); } // 关键方法:状态转换 public void TransitionToState(IPlayerState newState) { // 退出旧状态 CurrentState?.ExitState(this); // 切换状态 CurrentState = newState; // 进入新状态 CurrentState?.EnterState(this); // 可以在这里触发一个状态变化事件,方便其他系统(如动画、音效)响应 // EventManager.TriggerEvent(new PlayerStateChangedEvent { NewState = newState }); } // 供状态类调用的辅助方法 public void Move(float horizontalInput) { Rb.velocity = new Vector2(horizontalInput * MoveSpeed, Rb.velocity.y); // 翻转角色朝向等逻辑... } public void PerformJump() { if (IsGrounded) { Rb.AddForce(Vector2.up * JumpForce, ForceMode2D.Impulse); IsGrounded = false; } } }5.2 具体状态类实现
现在,实现各个独立的状态类。
// 闲置状态 public class IdleState : IPlayerState { public void EnterState(PlayerController player) { player.Anim.Play("Idle"); // 可能重置一些速度或计时器 } public void UpdateState(PlayerController player) { float horizontalInput = Input.GetAxisRaw("Horizontal"); // 状态转换条件判断 if (Mathf.Abs(horizontalInput) > 0.1f) { player.TransitionToState(player.RunState); } else if (Input.GetKeyDown(KeyCode.Space) && player.IsGrounded) { player.TransitionToState(player.JumpState); } else if (Input.GetKeyDown(KeyCode.Mouse0)) { player.TransitionToState(player.AttackState); } // 闲置状态自身的逻辑(如呼吸动画、耐力恢复)可以写在这里 } public void ExitState(PlayerController player) { // 退出闲置状态时可能需要做的清理工作 } } // 奔跑状态 public class RunState : IPlayerState { public void EnterState(PlayerController player) { player.Anim.Play("Run"); } public void UpdateState(PlayerController player) { float horizontalInput = Input.GetAxisRaw("Horizontal"); player.Move(horizontalInput); // 状态转换 if (Mathf.Abs(horizontalInput) < 0.1f) { player.TransitionToState(player.IdleState); } else if (Input.GetKeyDown(KeyCode.Space) && player.IsGrounded) { player.TransitionToState(player.JumpState); } // 奔跑中攻击?可能需要定义一个新的“奔跑攻击”状态,或者允许在奔跑中直接切换到攻击状态 // 这取决于你的游戏设计 } public void ExitState(PlayerController player) { // 比如停止脚步声 } } // 跳跃状态 public class JumpState : IPlayerState { public void EnterState(PlayerController player) { player.Anim.Play("Jump"); player.PerformJump(); // 执行跳跃物理逻辑 } public void UpdateState(PlayerController player) { // 空中移动 float horizontalInput = Input.GetAxisRaw("Horizontal"); player.Move(horizontalInput); // 状态转换:落地回到闲置或奔跑 if (player.IsGrounded) { if (Mathf.Abs(horizontalInput) > 0.1f) { player.TransitionToState(player.RunState); } else { player.TransitionToState(player.IdleState); } } // 空中攻击?同样,可能需要“跳跃攻击”子状态 } public void ExitState(PlayerController player) { // 清理 } } // 攻击状态(示例,通常更复杂,可能包含连击) public class AttackState : IPlayerState { private float _attackTimer; private bool _attackCompleted = false; public void EnterState(PlayerController player) { player.Anim.Play("Attack"); _attackTimer = 0.5f; // 假设攻击动画持续0.5秒 _attackCompleted = false; // 触发伤害检测等 } public void UpdateState(PlayerController player) { _attackTimer -= Time.deltaTime; if (_attackTimer <= 0 && !_attackCompleted) { _attackCompleted = true; // 攻击结束,根据输入决定下一个状态 if (player.IsGrounded) { float horizontalInput = Input.GetAxisRaw("Horizontal"); player.TransitionToState(Mathf.Abs(horizontalInput) > 0.1f ? player.RunState : player.IdleState); } else { player.TransitionToState(player.JumpState); } } // 攻击过程中,可能不允许移动或只允许轻微移动 } public void ExitState(PlayerController player) { // 关闭伤害检测框等 } }5.3 状态模式的优势与扩展
通过上面的重构,你会发现:
- 职责清晰:每个状态类的代码只关心自己状态下的行为和转换条件,
PlayerController变得非常干净。 - 易于扩展:要加一个“蹲下”状态?只需新建一个
CrouchState类,并在IdleState和RunState的转换条件里加上它即可。完全不用动其他状态的代码。 - 状态数据隔离:像
_attackTimer这样的临时变量,现在封装在AttackState内部,不会污染PlayerController或其他状态。 - 便于复用:
RunState的逻辑可以很容易地复用到敌人AI上。
5.4 注意事项与实操心得
注意:状态转换的时机。状态转换通常发生在
UpdateState中,但务必小心状态转换循环。例如,从Idle转到Run,又在同一帧的RunState的Update里因为条件不满足转回Idle,就会造成死循环。确保你的转换条件逻辑严谨,或者引入一个“冷却时间”或“状态锁”机制。
心得1:使用状态机框架还是自己实现?对于非常复杂的状态机(如带有层级状态、并行状态的AI),使用成熟的有限状态机(FSM)框架(如Unity Asset Store里的PlayMaker、NodeCanvas,或开源库)可能更高效。但对于大多数角色控制,自己实现上述轻量级的状态模式完全够用,且理解和定制成本更低。
心得2:状态与动画的配合。我们的例子中直接使用了player.Anim.Play。在实际项目中,更推荐使用Unity的Animator Controller配合状态参数(Parameters)来控制动画。可以在状态类的EnterState中设置Animator的参数(如anim.SetTrigger("Attack")),让Unity的动画状态机来处理动画融合和过渡,这样更强大、更可视化。
心得3:共享状态数据。有时多个状态需要访问或修改一些共享数据(如“连击计数”)。你可以将这些数据放在PlayerController(上下文)中作为公共属性,或者创建一个独立的PlayerStateData类,在状态间传递。避免使用静态变量,以保证多个玩家实例的独立性。
6. 模式融合与高级架构思考
单独使用每个模式已经能带来巨大提升,但真正的威力在于将它们有机地结合起来。
6.1 观察者 + 命令模式:实现全局输入与响应解耦
想象一个场景:你按下一个“技能键”,这个输入被InputManager捕获,生成一个CastSkillCommand命令对象并执行。这个命令的执行,并不是直接调用玩家的技能方法,而是触发一个SkillCastRequestEvent事件。
为什么?因为释放一个技能可能涉及多个系统:
- 资源系统:检查魔法值是否足够(监听事件,扣减魔法)。
- 冷却系统:检查技能是否在冷却中(监听事件,触发冷却计时)。
- 效果系统:如果前两者都通过,才真正执行技能效果(播放动画、生成弹道、计算伤害)。
- UI系统:更新技能图标冷却状态(监听冷却开始事件)。
通过事件来串联,CastSkillCommand只负责“请求”施法,具体能否施法、如何施法,由各个专业系统通过监听事件来决定。这实现了输入、逻辑、表现之间的彻底解耦,系统间的依赖关系清晰可见。
6.2 状态模式 + 观察者模式:优雅的状态通知
在我们的状态模式实现中,TransitionToState方法里有一个注释掉的触发事件的代码。在实际项目中,这非常有用。当角色状态改变时,触发一个PlayerStateChangedEvent。这样,其他不关心状态内部逻辑,只关心“状态变了”这一事实的系统就可以轻松响应:
- 动画系统:监听事件,根据新的状态类型播放对应的动画(甚至可以进一步解耦,动画系统自己管理动画状态机)。
- 音效系统:进入“奔跑状态”时开始循环播放脚步声,退出时停止。
- AI系统(对于敌人):感知到玩家进入“攻击状态”,可能触发敌人的防御或闪避行为。
6.3 命令模式 + 状态模式:实现复杂的角色能力
有些能力本身就是一个状态机。例如,“蓄力攻击”可能包含“开始蓄力”、“蓄力中”、“释放”三个子状态。你可以为这个能力单独实现一个ChargedAttackState,在这个状态内部,再用命令模式来管理蓄力阶段的输入(如按住鼠标、松开鼠标)。这样,大状态机(角色主状态)套小状态机(能力内部状态),结构依然清晰。
6.4 架构演进:面向数据与组合优于继承
当你熟练运用这些模式后,可能会发现,我们一直在用“组合”(Composition)来构建功能,而不是深度的“继承”(Inheritance)。PlayerController组合了各种状态对象,InputManager组合了各种命令对象。这符合“组合优于继承”的设计原则,使得代码更灵活、更易复用。
更进一步,你可以探索ECS(实体组件系统)或面向数据的设计(DOD)。在这些架构中,“状态”可能只是一个组件上的标签或枚举,“命令”可能是一个存储在队列中的数据结构,而“事件”则是实体间通信的消息。它们的思想与我们今天探讨的模式一脉相承,都是为了降低耦合、提高效率。对于性能要求极高的游戏(如大量单位的RTS、MOBA),ECS是更终极的解决方案。但对于大多数Unity项目,掌握并用好观察者、命令、状态这三大模式,足以构建出坚实、可维护的代码基础。
重构不是一蹴而就的,也不要在项目初期就过度设计。我的经验是,在第一次发现“这块代码改起来很费劲”或者“加个新功能要动好多地方”的时候,就是引入设计模式的最佳时机。从一个小模块开始尝试,比如先把UI更新改成事件驱动,你会立刻感受到架构清晰带来的愉悦感,然后才有动力把整个项目逐步重构得更加健壮。记住,好的代码不是写出来的,是不断重构出来的。