三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity游戏开发中的命令模式:从解耦到高级应用实战

Unity游戏开发中的命令模式:从解耦到高级应用实战

1. 项目概述:为什么Unity游戏开发需要命令模式?

在Unity项目里,我们经常遇到这样的场景:玩家点击一个按钮,角色执行跳跃;长按某个键,角色开始蓄力攻击;或者,我们想做一个功能强大的撤销/重做系统,让关卡设计师能自由地回退操作。这些功能背后,其实都藏着一个核心问题:如何将“请求”的发起者与“请求”的执行者解耦?

直接让UI按钮去调用角色控制器里的Jump()方法,或者让输入管理器直接修改场景中的对象状态,在小型项目里或许可行。但随着功能膨胀,这种紧耦合的代码会迅速变成“面条代码”——UI逻辑、游戏逻辑、对象状态管理全部纠缠在一起。这时,命令模式(Command Pattern)的价值就凸显出来了。它不是什么高深莫测的黑科技,而是一种经过时间考验的、用来封装操作请求的设计模式。简单说,命令模式就是把一个操作请求(比如“移动到这里”、“攻击这个目标”)封装成一个独立的对象。这个对象里包含了执行这个操作所需的所有信息。

在Unity里实现命令模式,意味着你可以轻松实现一些高级功能:比如操作历史记录(用于撤销/重做)、宏命令(一键执行一系列复杂操作)、网络命令同步(将玩家的操作序列化后发送给服务器或其他客户端),甚至是AI行为队列。当你看到《帝国时代》里成百上千的单位井然有序地执行指令,或者《魔兽争霸3》地图编辑器里强大的触发器系统,其底层思想都与命令模式息息相关。

2. 命令模式的核心思想与Unity适配

2.1 经典命令模式结构解析

在深入Unity实现之前,我们先快速回顾一下命令模式的经典结构。它通常包含四个核心角色:

  1. 命令接口 (ICommand):定义一个执行操作的方法,例如Execute()。这是所有具体命令的契约。
  2. 具体命令 (Concrete Command):实现ICommand接口。它持有接收者对象的引用,并在Execute()方法中调用接收者的一个或多个动作。它就像一个“任务清单”,知道“做什么”和“对谁做”。
  3. 接收者 (Receiver):真正执行操作的对象。它知道如何完成具体的业务逻辑,比如CharacterController.Move()Light.TurnOn()
  4. 调用者/请求者 (Invoker):负责触发命令的对象。它持有一个命令对象,并在合适的时机(如按钮点击、定时器触发)调用命令的Execute()方法。它不关心命令具体做了什么,只负责“下达指令”。

这个结构的精妙之处在于解耦。调用者不需要知道接收者是谁、具体怎么执行,它只需要发出命令。命令对象像一个信使,把请求打包并传递给正确的接收者。这为动态替换命令、序列化命令、将命令放入队列延迟执行提供了可能。

2.2 在Unity中实现命令模式的特殊考量

Unity的开发环境(基于组件的架构、MonoBehaviour的生命周期、序列化系统)为命令模式的实现带来了一些独特的机会和挑战。

  • 机会:利用ScriptableObject:Unity的ScriptableObject是创建命令对象的绝佳载体。ScriptableObject是一种无需挂载到场景GameObject上即可存在的资源。你可以用它来创建可配置的、可序列化的命令资产。例如,创建一个MoveCommand的ScriptableObject,并在Inspector中设置移动的目标坐标。这样,关卡设计师无需写代码,就能在编辑器中配置复杂的AI行为序列。
  • 机会:与Unity事件系统集成:Unity的UnityEvent系统本质上就是一个简化版的命令模式。你可以将UnityEvent视为一个“命令列表”,在事件触发时依次执行。我们可以将自定义的命令模式与UnityEvent结合,创建出更强大、更类型安全的可视化脚本工具。
  • 挑战:性能与垃圾回收:在每帧都可能产生大量命令的游戏中(如RTS),频繁地new命令对象会产生GC(垃圾回收)压力。这时,我们需要引入对象池(Object Pool)来管理命令对象的创建与回收。
  • 挑战:撤销/重做的状态管理:实现撤销功能时,命令不仅需要知道如何执行(Execute),还需要知道如何撤销(Undo)。这就要求命令对象能够保存足够的状态信息,以便回退到之前的状态。对于复杂的对象状态,这可能是一个挑战。

3. 基础实现:从接口到可撤销的命令系统

3.1 定义核心接口与基础命令类

一切从定义一个最基础的命令接口开始。这是所有命令的基石。

// ICommand.cs public interface ICommand { /// <summary> /// 执行命令 /// </summary> void Execute(); /// <summary> /// 撤销该命令的执行效果 /// </summary> void Undo(); }

这个接口非常简单,只有ExecuteUndo两个方法。一个完整的、支持撤销的命令系统必须包含Undo

接下来,我们实现一个最简单的具体命令,例如一个让游戏对象移动的命令:

// MoveCommand.cs using UnityEngine; [System.Serializable] public class MoveCommand : ICommand { // 接收者:要移动的游戏对象 private GameObject _targetObject; // 命令执行前的原始位置,用于撤销 private Vector3 _originalPosition; // 命令要移动到的目标位置 private Vector3 _targetPosition; public MoveCommand(GameObject target, Vector3 targetPos) { _targetObject = target; _targetPosition = targetPos; _originalPosition = target.transform.position; // 记录初始状态 } public void Execute() { if (_targetObject == null) return; // 执行前再次备份位置(确保多次执行撤销的准确性,但通常Execute只调用一次) // _originalPosition = _targetObject.transform.position; _targetObject.transform.position = _targetPosition; Debug.Log($"{_targetObject.name} 移动到了 {_targetPosition}"); } public void Undo() { if (_targetObject == null) return; _targetObject.transform.position = _originalPosition; Debug.Log($"{_targetObject.name} 撤销移动,回到了 {_originalPosition}"); } }

这个MoveCommand类就是一个典型的具体命令。它在构造时捕获了接收者(_targetObject)的初始状态(_originalPosition),并在Execute中改变其位置,在Undo中恢复位置。

注意:在Execute中是否要记录_originalPosition取决于设计。如果命令设计为只能执行一次,那么在构造函数中记录即可。如果命令可能被重复执行(比如一个切换开关的命令),则需要在每次Execute时都记录当前状态,以便下次Undo能回到本次执行前的状态。

3.2 构建命令管理器(Command Invoker)

调用者负责持有、执行和管理命令。一个功能完整的命令管理器通常还需要维护一个历史堆栈,以实现撤销和重做。

// CommandManager.cs using System.Collections.Generic; using UnityEngine; public class CommandManager : MonoBehaviour { // 单例模式,方便全局访问 public static CommandManager Instance { get; private set; } // 命令历史栈(用于撤销) private Stack<ICommand> _commandHistory = new Stack<ICommand>(); // 重做栈(用于重做) private Stack<ICommand> _redoStack = new Stack<ICommand>(); private void Awake() { if (Instance != null && Instance != this) { Destroy(this.gameObject); } else { Instance = this; // 可选:使该对象在场景切换时不销毁 // DontDestroyOnLoad(this.gameObject); } } /// <summary> /// 执行一个新命令,并存入历史记录 /// </summary> /// <param name="command">要执行的命令</param> public void ExecuteCommand(ICommand command) { if (command == null) return; command.Execute(); _commandHistory.Push(command); // 当执行新命令时,清空重做栈(因为新的操作分支开始了) _redoStack.Clear(); Debug.Log("命令已执行并加入历史记录。"); } /// <summary> /// 撤销最近执行的一个命令 /// </summary> public void Undo() { if (_commandHistory.Count == 0) { Debug.Log("没有可以撤销的命令。"); return; } ICommand lastCommand = _commandHistory.Pop(); lastCommand.Undo(); _redoStack.Push(lastCommand); Debug.Log("命令已撤销。"); } /// <summary> /// 重做最近撤销的一个命令 /// </summary> public void Redo() { if (_redoStack.Count == 0) { Debug.Log("没有可以重做的命令。"); return; } ICommand lastUndoneCommand = _redoStack.Pop(); lastUndoneCommand.Execute(); _commandHistory.Push(lastUndoneCommand); Debug.Log("命令已重做。"); } /// <summary> /// 清空所有历史记录(例如在开始新关卡时) /// </summary> public void ClearHistory() { _commandHistory.Clear(); _redoStack.Clear(); Debug.Log("命令历史已清空。"); } // 在Update中监听键盘输入,方便测试 private void Update() { if (Input.GetKeyDown(KeyCode.Z) && (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl))) { Undo(); } if (Input.GetKeyDown(KeyCode.Y) && (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl))) { Redo(); } } }

这个CommandManager是系统的中枢。它提供了静态实例以便全局访问,维护了执行历史栈和重做栈,并提供了执行、撤销、重做的公共方法。在Update中监听了Ctrl+ZCtrl+Y快捷键,方便在编辑模式下测试。

3.3 实战:用命令控制一个Cube的移动与撤销

现在,我们创建一个简单的测试脚本来使用上面的系统。

  1. 场景搭建:在Unity中创建一个空对象,命名为CommandManager,挂载上面编写的CommandManager脚本。
  2. 创建Cube:在场景中创建一个Cube。
  3. 创建测试控制器
// CommandTestController.cs using UnityEngine; public class CommandTestController : MonoBehaviour { public GameObject targetCube; // 在Inspector中拖入Cube public float moveDistance = 2f; void Update() { // 使用方向键触发移动命令 if (Input.GetKeyDown(KeyCode.UpArrow)) { Vector3 newPos = targetCube.transform.position + Vector3.forward * moveDistance; ICommand moveCommand = new MoveCommand(targetCube, newPos); CommandManager.Instance.ExecuteCommand(moveCommand); } if (Input.GetKeyDown(KeyCode.DownArrow)) { Vector3 newPos = targetCube.transform.position + Vector3.back * moveDistance; ICommand moveCommand = new MoveCommand(targetCube, newPos); CommandManager.Instance.ExecuteCommand(moveCommand); } // 可以类似地添加左、右键 } }
  1. 运行测试:运行游戏,按上下方向键,Cube会移动。按Ctrl+Z可以撤销移动,按Ctrl+Y可以重做。你会发现,命令的发起(CommandTestController)和执行(Cube.transform.position)被完全解耦了。控制器只负责创建命令并交给管理器,它不关心Cube是如何移动的。

4. 高级应用与性能优化

4.1 使用ScriptableObject创建可配置命令资产

基础实现中的命令是通过代码new出来的。对于需要策划或设计师配置的复杂命令(如剧情对话命令、技能释放命令),我们可以利用ScriptableObject

// ScriptableCommand.cs using UnityEngine; public abstract class ScriptableCommand : ScriptableObject, ICommand { public abstract void Execute(); public abstract void Undo(); } // ScriptableMoveCommand.cs [CreateAssetMenu(fileName = "NewMoveCommand", menuName = "Commands/Move Command")] public class ScriptableMoveCommand : ScriptableCommand { public GameObject targetPrefab; // 或通过其他方式在运行时确定目标 public Vector3 targetPosition; // 运行时需要注入接收者 private GameObject _runtimeTarget; private Vector3 _originalPosition; public void SetTarget(GameObject target) { _runtimeTarget = target; } public override void Execute() { if (_runtimeTarget == null) { Debug.LogError("ScriptableMoveCommand: 未设置运行时目标!"); return; } _originalPosition = _runtimeTarget.transform.position; _runtimeTarget.transform.position = targetPosition; } public override void Undo() { if (_runtimeTarget == null) return; _runtimeTarget.transform.position = _originalPosition; } }

使用方式:

  1. 在Project窗口右键Create -> Commands -> Move Command,创建一个命令资产。
  2. 在Inspector中配置targetPosition
  3. 在运行时脚本中,获取该资产,通过SetTarget方法注入当前要操作的游戏对象,然后交给CommandManager执行。

这样做的好处是,复杂的命令链(比如一个关卡开始时触发的一系列事件)可以被配置成一组ScriptableObject资产,由策划在编辑器里像搭积木一样组合,无需程序员介入。

4.2 实现宏命令(Macro Command)

宏命令是将多个命令组合成一个复合命令。执行一个宏命令,就等于按顺序执行它包含的所有子命令。

// MacroCommand.cs using System.Collections.Generic; public class MacroCommand : ICommand { private List<ICommand> _subCommands = new List<ICommand>(); public void AddCommand(ICommand command) { if (command != null) _subCommands.Add(command); } public void Execute() { // 顺序执行所有子命令 foreach (var cmd in _subCommands) { cmd.Execute(); } } public void Undo() { // 逆序撤销所有子命令(这是关键!) for (int i = _subCommands.Count - 1; i >= 0; i--) { _subCommands[i].Undo(); } } }

使用场景:想象一个“建造建筑”的RTS操作。它可能包含:1. 从资源中扣除金币。2. 在指定位置实例化建筑模型。3. 播放建造音效。4. 为建筑注册到生产队列。你可以将这些步骤分别封装成DeductResourceCommandSpawnBuildingCommandPlaySoundCommandRegisterToQueueCommand,然后用一个BuildMacroCommand将它们组合起来。这样,撤销建造时,只需要调用宏命令的Undo(),所有步骤都会按正确顺序回退。

4.3 命令对象池化以优化性能

在需要高频创建命令的场景(如每帧都可能产生移动指令的RTS),频繁的new和垃圾回收会成为性能瓶颈。解决方案是实现一个简单的命令对象池。

// CommandPool.cs using System.Collections.Generic; using UnityEngine; public class CommandPool<T> where T : ICommand, new() { private Stack<T> _pool = new Stack<T>(); public T Get() { if (_pool.Count > 0) { return _pool.Pop(); } else { return new T(); } } public void Release(T command) { // 这里可以添加重置命令状态的逻辑 // 例如,如果命令有Reset方法:command.Reset(); _pool.Push(command); } } // 修改MoveCommand,使其支持池化 public class PoolableMoveCommand : ICommand { public GameObject Target { get; private set; } public Vector3 TargetPosition { get; private set; } private Vector3 _originalPosition; // 初始化方法,替代构造函数 public void Init(GameObject target, Vector3 targetPos) { this.Target = target; this.TargetPosition = targetPos; this._originalPosition = target.transform.position; } // 重置方法,放回池子前调用 public void Reset() { Target = null; TargetPosition = Vector3.zero; _originalPosition = Vector3.zero; } public void Execute() { /* ... 同前 ... */ } public void Undo() { /* ... 同前 ... */ } } // 使用方式 public class EfficientUnitController : MonoBehaviour { private CommandPool<PoolableMoveCommand> _moveCommandPool = new CommandPool<PoolableMoveCommand>(); void IssueMoveOrder(GameObject unit, Vector3 destination) { var cmd = _moveCommandPool.Get(); cmd.Init(unit, destination); CommandManager.Instance.ExecuteCommand(cmd); // 注意:命令执行后不能立刻Release! // 因为命令可能还在历史栈中,需要等待其被历史栈完全丢弃(如清空历史时)再回收。 // 这需要更复杂的管理,例如让CommandManager在从历史栈移除命令时通知池子。 } }

对象池的实现引入了复杂性,因为它需要管理命令的生命周期。一个常见的做法是,不在命令执行后立即回收,而是由CommandManager在清空历史记录或确认某个命令不再被需要时,调用一个特定的清理接口来回收命令对象。对于大多数中小型项目,如果命令产生频率不高,可以暂不考虑池化,以代码简洁性优先。

4.4 异步命令与协程集成

有些命令的执行不是瞬时的,比如一个播放一段2秒动画的命令,或者一个需要等待网络响应的命令。我们可以通过协程(Coroutine)来实现异步命令。

// IAsyncCommand.cs public interface IAsyncCommand : ICommand { // 是否正在执行 bool IsExecuting { get; } // 开始异步执行 System.Collections.IEnumerator ExecuteAsync(); // 可以取消异步执行 void Cancel(); } // 扩展CommandManager支持异步命令 public class AsyncCommandManager : MonoBehaviour { // ... 单例等基础代码 ... public void ExecuteAsyncCommand(IAsyncCommand asyncCommand) { if (asyncCommand == null) return; StartCoroutine(RunAsyncCommand(asyncCommand)); } private IEnumerator RunAsyncCommand(IAsyncCommand asyncCommand) { _commandHistory.Push(asyncCommand); _redoStack.Clear(); yield return StartCoroutine(asyncCommand.ExecuteAsync()); // 异步命令执行完毕后的处理 Debug.Log("异步命令执行完毕。"); } // 注意:异步命令的撤销可能也需要特殊处理,尤其是当命令执行到一半时。 }

实现一个具体的异步命令,例如PlayAnimationCommand,在其ExecuteAsync方法中使用yield return new WaitForSeconds(animationLength);来等待动画播放完成。这样,命令管理器可以顺序或并行地执行一系列异步命令,非常适合用于制作游戏过场动画或复杂的任务序列。

5. 实战案例解析:构建一个简易的RTS单位控制系统

让我们综合运用以上知识,构建一个简化版的即时战略游戏(RTS)单位控制系统。这个系统需要实现:单位选择、移动命令下达、命令队列、以及可视化命令预览。

5.1 系统架构设计

  1. SelectableUnit 组件:挂载在可选择的单位上,处理鼠标点击选中逻辑,并持有UnitCommandExecutor引用。
  2. UnitCommandExecutor 组件:挂载在单位上,是命令的“接收者”。它有一个命令队列,能顺序执行接收到的移动、攻击等命令。它负责实际改变单位的位置、播放动画等。
  3. MoveToPointCommand 命令:一个具体的移动命令,包含目标点信息。
  4. CommandInputHandler 组件:挂载在摄像机或一个全局管理器上,是命令的“调用者”。它监听玩家的鼠标右键点击(在选中单位时),获取点击的世界坐标,生成MoveToPointCommand并发送给当前选中的所有单位的UnitCommandExecutor
  5. UI Command Preview:在玩家右键点击时,在地面生成一个临时的指示器(如一个圆圈或箭头),可视化显示即将下达的命令。

5.2 关键代码实现

UnitCommandExecutor.cs

using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; // 假设我们使用NavMeshAgent public class UnitCommandExecutor : MonoBehaviour { private Queue<ICommand> _commandQueue = new Queue<ICommand>(); private ICommand _currentCommand = null; private NavMeshAgent _navAgent; void Start() { _navAgent = GetComponent<NavMeshAgent>(); } void Update() { // 如果当前没有命令在执行,且队列中有命令,则执行下一个 if (_currentCommand == null && _commandQueue.Count > 0) { _currentCommand = _commandQueue.Dequeue(); _currentCommand.Execute(); // 对于瞬时命令,执行完立刻置空。对于持续命令(如移动),需要其他机制判断完成。 } // 检查移动命令是否完成(示例) if (_currentCommand is MoveToPointCommand moveCmd) { if (_navAgent != null && !_navAgent.pathPending && _navAgent.remainingDistance <= _navAgent.stoppingDistance) { _currentCommand = null; } } } public void IssueCommand(ICommand command, bool clearQueue = false) { if (clearQueue) { _commandQueue.Clear(); if (_currentCommand != null) { // 可能需要中断当前命令 // _currentCommand.Cancel(); // 如果命令支持取消 _currentCommand = null; } } _commandQueue.Enqueue(command); } }

MoveToPointCommand.cs

public class MoveToPointCommand : ICommand { private UnitCommandExecutor _executor; private Vector3 _destination; private Vector3 _originalPosition; public MoveToPointCommand(UnitCommandExecutor executor, Vector3 destination) { _executor = executor; _destination = destination; _originalPosition = executor.transform.position; } public void Execute() { var navAgent = _executor.GetComponent<NavMeshAgent>(); if (navAgent != null) { navAgent.SetDestination(_destination); } else { // 如果没有NavMeshAgent,则直接瞬移(仅作示例) _executor.transform.position = _destination; } // 这里可以触发移动动画 Debug.Log($"{_executor.gameObject.name} 收到移动命令,前往 {_destination}"); } public void Undo() { var navAgent = _executor.GetComponent<NavMeshAgent>(); if (navAgent != null) navAgent.ResetPath(); _executor.transform.position = _originalPosition; Debug.Log($"{_executor.gameObject.name} 撤销移动命令。"); } }

CommandInputHandler.cs

using UnityEngine; public class CommandInputHandler : MonoBehaviour { public GameObject commandPreviewPrefab; // 一个简单的粒子或Sprite预制体 private List<SelectableUnit> _selectedUnits = new List<SelectableUnit>(); void Update() { // 1. 选择单位(左键点击)逻辑略... // 2. 下达命令(右键点击地面) if (Input.GetMouseButtonDown(1) && _selectedUnits.Count > 0) // 右键 { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, Mathf.Infinity, LayerMask.GetMask("Ground"))) // 假设地面在"Ground"层 { Vector3 commandPoint = hit.point; // 生成命令预览 if (commandPreviewPrefab != null) { Instantiate(commandPreviewPrefab, commandPoint, Quaternion.identity); } // 为每个选中的单位创建并下达移动命令 foreach (var unit in _selectedUnits) { var executor = unit.GetComponent<UnitCommandExecutor>(); if (executor != null) { // 这里可以加入一些智能逻辑,比如让单位移动到目标点周围的不同位置,避免重叠 Vector3 finalDestination = CalculateFormationPosition(commandPoint, unit); ICommand moveCmd = new MoveToPointCommand(executor, finalDestination); executor.IssueCommand(moveCmd, true); // true 表示清空之前的命令队列 } } } } } private Vector3 CalculateFormationPosition(Vector3 center, SelectableUnit unit) { // 简单的偏移计算,实际项目会更复杂 int index = _selectedUnits.IndexOf(unit); float spacing = 2.0f; int rowSize = Mathf.CeilToInt(Mathf.Sqrt(_selectedUnits.Count)); int row = index / rowSize; int col = index % rowSize; return center + new Vector3(col * spacing, 0, row * spacing); } // 用于更新选中单位列表的方法 public void UpdateSelection(List<SelectableUnit> selected) { _selectedUnits = new List<SelectableUnit>(selected); } }

这个案例展示了如何将命令模式与Unity的组件系统、物理射线检测、NavMesh导航等紧密结合,构建出一个可扩展的单位控制系统。你可以轻松地添加新的命令类型(如AttackCommand,HoldPositionCommand,PatrolCommand),而无需修改UnitCommandExecutorCommandInputHandler的核心逻辑。

6. 常见问题、调试技巧与最佳实践

6.1 常见问题与解决方案

  1. 命令撤销后状态不一致

    • 问题:撤销一个命令后,游戏状态没有完全恢复到之前的样子。
    • 排查:检查Undo()方法中是否完整保存并恢复了所有受影响的状态。对于复杂的对象,考虑使用备忘录模式(Memento Pattern)来保存整个对象的状态快照,而不仅仅是几个字段。
    • 技巧:在命令的Execute()方法开头,强制记录一次完整状态。确保命令是幂等的(执行多次和执行一次效果相同)和可逆的。
  2. 命令历史内存泄漏

    • 问题:历史栈中保存的命令对象引用了场景中的GameObject,即使该GameObject被销毁,命令仍持有引用,导致内存无法释放。
    • 解决方案:在命令中存储对象的InstanceID而不是直接引用GameObject。在执行或撤销时,通过GameObject.FindObjectFromInstanceID(谨慎使用,性能差)或维护一个ID到对象的字典来获取对象。更好的做法是,在接收者(如Unit)被销毁时,通知CommandManager清理所有涉及该接收者的历史命令。
  3. 网络同步中的命令时序问题

    • 问题:在多人游戏中,不同客户端接收到的命令顺序可能不同,导致状态不一致。
    • 解决方案:采用命令帧同步状态同步。命令帧同步要求所有客户端在相同的逻辑帧执行相同的命令序列。你需要为每个命令附加一个帧号,并由服务器权威地广播命令和帧号。确保命令的执行是确定性的(即相同的初始状态+相同的命令输入=相同的最终状态)。
  4. ScriptableObject命令的运行时依赖

    • 问题:在编辑器中配置好的ScriptableObject命令,其引用的场景对象在运行时可能不存在或不同。
    • 解决方案:避免在ScriptableObject中直接引用场景实例。改为存储标识符(如字符串ID、GUID),在游戏启动时或命令执行前,通过一个注册表(Registry)或依赖注入(Dependency Injection)容器来解析并注入运行时对象。

6.2 调试技巧

  • 可视化命令历史:在CommandManager中创建一个调试UI,实时显示_commandHistory_redoStack中的命令类型和简要信息。这能让你清晰地看到命令的执行流。
  • 命令日志:为每个命令的Execute()Undo()方法添加详细的日志输出,包括时间戳、命令类型、接收者、关键参数等。这有助于复现和定位bug。
  • 使用断点检查命令状态:在怀疑命令执行有误时,在Execute()Undo()方法开始处设置断点,检查命令对象内部保存的状态数据是否正确。

6.3 最佳实践总结

  1. 保持命令轻量:命令对象应该只保存必要的数据和引用,避免包含庞大的业务逻辑。复杂的逻辑应该放在接收者(如Unit,ResourceManager)中。
  2. 区分瞬时命令与持续命令:像“移动”这样的命令是持续性的,其Execute()只是开始移动,需要额外的机制(如NavMeshAgent到达目的地)来判断完成。而“播放音效”是瞬时的。在设计命令接口和管理器时需要考虑这种差异。
  3. 为复杂操作使用宏命令:将一系列原子操作组合成宏命令,可以简化高层逻辑,并保证这些操作的原子性(要么全部成功,要么通过撤销全部回退)。
  4. 考虑使用事件(Event)替代简单命令:如果只是简单的“通知”而不需要撤销、队列、参数封装等高级特性,使用C#的event或Unity的UnityEvent可能更简单直接。
  5. 在项目早期引入:命令模式在后期重构加入的代价较高。如果你预见到项目需要撤销/重做、录制回放、网络命令同步或复杂的AI行为树,尽早设计基于命令模式的架构会省去大量后期麻烦。

命令模式在Unity中就像给你的游戏逻辑安装了一个“时间机器”和“遥控器”。它通过将操作抽象化,极大地提升了代码的灵活性、可测试性和可维护性。从简单的撤销重做到复杂的战略AI,其应用范围仅受你的想象力限制。

← 返回列表