尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity游戏开发:Command模式实现撤销重做与逻辑解耦

Unity游戏开发:Command模式实现撤销重做与逻辑解耦 1. 项目概述为什么你的Unity项目需要Command模式如果你在Unity里写过游戏逻辑尤其是涉及到玩家输入、角色移动、技能释放或者任何需要“撤销/重做”功能的地方大概率遇到过这样的场景一堆if-else或者switch语句散落在Update或事件回调里代码越写越乱想加个“回退上一步”的功能发现得把整个输入处理逻辑重写一遍。我接手过不少项目初期为了赶进度逻辑直接写在PlayerController的Update里后期想加个“录像回放”或者“AI复盘”功能团队直接傻眼重构成本高到令人绝望。Command模式或者说命令模式就是来解决这个痛点的。它不是什么高深莫测的黑科技而是一种极其务实的设计思想把“动作”或“请求”封装成一个独立的对象。听起来简单但带来的改变是巨大的。这意味着你点击一个按钮、按下一个按键产生的不是一个直接修改游戏状态的函数调用而是一个可以被存储、传递、排队、延迟执行甚至撤销的“命令对象”。看看那些热搜词“unity程序打开黑屏无响应”、“unity性能优化”、“unity ui框架”……很多问题根源不在于Unity引擎本身而在于混乱的代码结构。Command模式正是“游戏逻辑更清晰”这个命题的核心解药之一。它特别适合需要复杂输入处理如格斗游戏的连招、回合制策略如预先规划行动、以及任何需要撤销/重做、录像、网络同步通过同步命令序列的系统。无论你是独立开发者还是团队协作引入Command模式都能让代码的维护性和扩展性提升一个档次。2. Command模式核心思想拆解从“直接干”到“发指令”2.1 传统做法的局限紧耦合的困境在深入Command之前我们先看看典型的“反面教材”。假设我们有一个简单的玩家移动控制public class PlayerController : MonoBehaviour { public float speed 5f; void Update() { float h Input.GetAxis(Horizontal); float v Input.GetAxis(Vertical); Vector3 move new Vector3(h, 0, v) * speed * Time.deltaTime; transform.Translate(move); // 突然需求要加一个“闪现”技能按空格键向前瞬移 if (Input.GetKeyDown(KeyCode.Space)) { transform.Translate(transform.forward * 10f); } } }这段代码在原型阶段没问题但隐患重重逻辑与输入强耦合移动逻辑直接写在Update里与Unity的输入系统绑死。想换成新的输入系统如Input System Package重写吧。无法撤销玩家移动后状态直接改变了。你想实现“悔棋”除非你记录每一帧的位置然后回溯但这和游戏逻辑混在一起非常混乱。难以扩展现在要加一个“冲锋”技能冲锋过程中无视障碍。你可能会在Update里加更多标志位和状态判断代码迅速变得臃肿。不利于AI复用你想让AI也控制这个角色。AI的逻辑是“决定一个移动指令”但你的代码只响应直接的输入事件。AI系统不得不模拟输入或者直接操作Transform破坏了封装。2.2 Command模式的救赎解耦与封装Command模式的核心是引入一个中间层。它定义了一个所有命令都必须遵守的契约通常是一个接口具体的命令如移动、跳跃、攻击是这个契约的实现。同时需要一个“调用者”Invoker来负责执行这些命令对象。关键角色解析ICommand接口这是命令的“宪法”。它规定了每个命令对象必须有能力“执行”Execute和“撤销”Undo。正是这两个方法赋予了命令对象可逆的特性。具体命令类如MoveCommand这是“宪法”下的具体“法律条文”。它封装了执行一个具体动作所需的所有信息比如哪个玩家、向哪个方向移动和逻辑。命令调用者CommandInvoker这是“指令发布中心”。它不关心命令具体是干什么的只负责接收命令对象调用它的Execute方法并把它记录下来为了撤销。它通常维护着命令的历史栈。客户端Client这是“发出指令的人”。在我们的游戏里通常是InputManager、UI按钮回调或者AI决策模块。它负责创建具体的命令对象并交给调用者去执行。生活化类比想象一下餐厅。顾客客户端点餐创建命令对象一份牛排、一份沙拉。服务员调用者接过点菜单命令对象不关心菜怎么做只把单子送到后厨执行命令。后厨根据不同的单子具体命令进行不同的烹饪执行具体逻辑。如果顾客说“牛排不要了”撤销服务员可以找到对应的单子通知后厨取消执行撤销逻辑。整个过程中服务员调用者和后厨具体命令执行者是解耦的服务员不需要知道牛排怎么煎。3. 在Unity中实现Command模式的完整流程3.1 第一步定义命令契约ICommand接口这是整个模式的基石。我们创建一个最简单的命令接口。// ICommand.cs public interface ICommand { /// summary /// 执行命令 /// /summary void Execute(); /// summary /// 撤销该命令造成的影响 /// /summary void Undo(); }注意这里使用接口而非抽象类是为了保持最大的灵活性。任何类都可以实现这个接口而不必继承自某个特定的基类符合“组合优于继承”的原则。如果你的所有命令都需要一些公共数据如时间戳、执行者ID可以考虑使用抽象类。3.2 第二步实现具体命令以MoveCommand为例现在我们把玩家移动这个动作封装成一个命令。// MoveCommand.cs public class MoveCommand : ICommand { // 命令执行的目标对象 private PlayerMover _playerMover; // 命令执行的参数移动向量 private Vector3 _movement; /// summary /// 构造函数用于注入命令执行所需的所有依赖和数据 /// /summary /// param nameplayerMover移动执行组件/param /// param namemovement移动方向和距离/param public MoveCommand(PlayerMover playerMover, Vector3 movement) { this._playerMover playerMover; this._movement movement; } public void Execute() { // 执行时调用PlayerMover的移动方法 _playerMover.Move(_movement); } public void Undo() { // 撤销时向反方向移动 _playerMover.Move(-_movement); } }关键点解析数据封装MoveCommand在创建时构造函数就捕获了所有必要信息谁移动_playerMover和怎么移动_movement。这意味着命令对象在创建后就是自包含的、不可变的理想情况下这非常利于存储和序列化。逻辑隔离MoveCommand本身不实现移动的物理逻辑它只是调用PlayerMover组件的方法。这保持了单一职责原则PlayerMover负责“如何移动”可能包含碰撞检测MoveCommand负责“记录一次移动的意图”。对称的UndoUndo方法的设计至关重要。这里采用了最简单的“逆操作”方式。但不是所有命令的撤销都这么简单比如“使用消耗品”命令撤销时需要归还物品需要根据业务逻辑仔细设计。3.3 第三步创建命令调用者与管理历史CommandInvoker这是模式的大脑负责调度和记忆。// CommandInvoker.cs using System.Collections.Generic; using UnityEngine; public class CommandInvoker : MonoBehaviour { // 单例模式方便全局访问。也可以使用依赖注入。 public static CommandInvoker Instance { get; private set; } // 撤销栈后进先出LIFO记录已执行的命令 private StackICommand _undoStack new StackICommand(); // 重做栈记录被撤销的命令用于重做 private StackICommand _redoStack new StackICommand(); // 可选限制历史记录长度防止内存无限增长 public int maxHistoryLength 100; void Awake() { if (Instance ! null Instance ! this) { Destroy(this); } else { Instance this; } } /// summary /// 执行一个新命令 /// /summary public void ExecuteCommand(ICommand command) { if (command null) return; command.Execute(); _undoStack.Push(command); // 每当执行一个新命令清空重做栈。 // 因为新的命令分支开始了旧的重做历史不再有效。 _redoStack.Clear(); // 维护栈大小移除最旧的命令 MaintainStackSize(); } /// summary /// 撤销上一个命令 /// /summary public void Undo() { if (_undoStack.Count 0) { ICommand command _undoStack.Pop(); command.Undo(); _redoStack.Push(command); // 被撤销的命令进入重做栈 } else { Debug.Log(没有可以撤销的命令。); } } /// summary /// 重做上一个被撤销的命令 /// /summary public void Redo() { if (_redoStack.Count 0) { ICommand command _redoStack.Pop(); command.Execute(); _undoStack.Push(command); // 重做后命令再次进入撤销栈 } else { Debug.Log(没有可以重做的命令。); } } /// summary /// 清空所有历史记录 /// /summary public void ClearHistory() { _undoStack.Clear(); _redoStack.Clear(); } /// summary /// 获取是否可以撤销/重做 /// /summary public bool CanUndo() _undoStack.Count 0; public bool CanRedo() _redoStack.Count 0; private void MaintainStackSize() { // 如果撤销栈超过最大长度移除底部的命令栈无法直接移除底部这里需要转换为列表或队列处理 // 更简单的做法是使用Queue和LinkedList的组合来维护固定长度的历史但栈的LIFO特性对撤销最直观。 // 此处提供一个思路当栈太大时转移到列表移除头部再转回栈。实际项目可根据性能要求优化。 if (_undoStack.Count maxHistoryLength) { // 示例性代码实际中可能需要更高效的数据结构如LinkedListICommand var list new ListICommand(_undoStack); list.RemoveAt(0); // 移除最旧的命令列表的第一个元素是栈底 _undoStack new StackICommand(list); } } }设计决策深度解析为何使用栈Stack撤销操作本质是“后进先出”。玩家最近的操作最先被撤销。栈完美匹配这个语义。Push和Pop操作的时间复杂度都是O(1)效率很高。双栈结构Undo/Redo这是实现撤销/重做的经典结构。_undoStack保存已执行的操作_redoStack保存被撤销的操作。执行新命令时清空_redoStack是关键这符合大多数编辑器的逻辑新操作会切断未来的重做历史。单例模式 vs 依赖注入这里用了简单的MonoBehaviour单例是为了教程清晰。在大型项目中更推荐使用一个纯C#类非MonoBehaviour并通过依赖注入框架如Zenject、VContainer将其注入到需要的地方这样耦合度更低更易于测试。历史记录限制在长时间运行的游戏中尤其是策略游戏无限制保存命令历史可能导致内存泄漏。MaintainStackSize方法展示了如何限制历史长度。你需要根据命令对象的大小和游戏需求来决定这个上限。3.4 第四步构建实际执行单元PlayerMover命令对象委托实际工作给像PlayerMover这样的组件。这个组件专注于“移动”这个领域逻辑。// PlayerMover.cs using UnityEngine; public class PlayerMover : MonoBehaviour { [SerializeField] private LayerMask _obstacleLayerMask; [SerializeField] private float _gridSize 1.0f; // 假设是网格移动 [SerializeField] private float _moveDuration 0.2f; // 移动动画时间 private bool _isMoving false; /// summary /// 尝试移动包含障碍物检测 /// /summary /// param namemovement移动向量应是网格大小的整数倍/param public bool TryMove(Vector3 movement) { if (_isMoving) return false; if (!IsValidMove(movement)) return false; // 这里可以触发移动动画或音效 StartCoroutine(MoveRoutine(movement)); return true; } /// summary /// 立即移动用于命令的快速执行/撤销可能跳过动画 /// /summary public void Move(Vector3 movement) { // 注意这个版本的Move是即时完成的用于命令的Execute/Undo。 // 它与TryMove可能不同TryMove可能包含动画和玩家输入验证。 if (IsValidMove(movement)) { transform.position movement; // 触发移动事件供其他系统如音频、脚印特效监听 OnMoved?.Invoke(movement); } } /// summary /// 检测移动是否合法 /// /summary public bool IsValidMove(Vector3 movement) { RaycastHit hit; // 从当前位置向移动方向发射射线检测距离为网格大小 if (Physics.Raycast(transform.position, movement.normalized, out hit, _gridSize, _obstacleLayerMask)) { Debug.DrawRay(transform.position, movement.normalized * _gridSize, Color.red, 1f); return false; } Debug.DrawRay(transform.position, movement.normalized * _gridSize, Color.green, 1f); return true; } // 协程用于播放平滑移动动画 private System.Collections.IEnumerator MoveRoutine(Vector3 movement) { _isMoving true; Vector3 startPos transform.position; Vector3 endPos startPos movement; float elapsed 0f; while (elapsed _moveDuration) { transform.position Vector3.Lerp(startPos, endPos, elapsed / _moveDuration); elapsed Time.deltaTime; yield return null; } transform.position endPos; _isMoving false; OnMoved?.Invoke(movement); } // 事件通知其他系统移动已完成 public event System.ActionVector3 OnMoved; }经验之谈这里我刻意区分了TryMove和Move。TryMove是给玩家输入或AI用的它包含状态检查是否正在移动和动画。而Move是给MoveCommand用的它更“原子”只负责最终的位置改变和触发事件。这种分离保证了命令执行的确定性和可逆性撤销时不需要再播放一遍动画。3.5 第五步整合输入与控制InputManager最后我们把所有部分连接起来。InputManager作为客户端监听输入创建命令并提交给调用者。// InputManager.cs using UnityEngine; using UnityEngine.UI; // 如果使用UI按钮 public class InputManager : MonoBehaviour { [SerializeField] private PlayerMover _playerMover; [SerializeField] private Button _undoButton; [SerializeField] private Button _redoButton; void Start() { if (_undoButton ! null) _undoButton.onClick.AddListener(OnUndoButtonClicked); if (_redoButton ! null) _redoButton.onClick.AddListener(OnRedoButtonClicked); } void Update() { HandleKeyboardInput(); } private void HandleKeyboardInput() { Vector3 movement Vector3.zero; bool hasInput false; // 示例WASD控制 if (Input.GetKeyDown(KeyCode.W)) { movement Vector3.forward; hasInput true; } else if (Input.GetKeyDown(KeyCode.S)) { movement Vector3.back; hasInput true; } else if (Input.GetKeyDown(KeyCode.A)) { movement Vector3.left; hasInput true; } else if (Input.GetKeyDown(KeyCode.D)) { movement Vector3.right; hasInput true; } if (hasInput _playerMover ! null) { // 关键步骤不直接调用_playerMover.Move而是创建命令 ExecuteMoveCommand(movement); } // 键盘撤销/重做如CtrlZ, CtrlY if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) { if (Input.GetKeyDown(KeyCode.Z)) { OnUndoButtonClicked(); } else if (Input.GetKeyDown(KeyCode.Y)) { OnRedoButtonClicked(); } } } /// summary /// 执行移动命令的公共方法也可被UI按钮调用 /// /summary public void ExecuteMoveCommand(Vector3 movement) { if (_playerMover null || !_playerMover.IsValidMove(movement)) { Debug.Log(移动无效或PlayerMover未设置。); return; } // 1. 创建具体的命令对象 ICommand moveCommand new MoveCommand(_playerMover, movement); // 2. 交给调用者执行 CommandInvoker.Instance.ExecuteCommand(moveCommand); } // UI按钮回调 public void OnMoveButtonClicked(string direction) { Vector3 move Vector3.zero; switch (direction.ToLower()) { case up: move Vector3.forward; break; case down: move Vector3.back; break; case left: move Vector3.left; break; case right: move Vector3.right; break; } ExecuteMoveCommand(move); } private void OnUndoButtonClicked() { if (CommandInvoker.Instance.CanUndo()) { CommandInvoker.Instance.Undo(); } } private void OnRedoButtonClicked() { if (CommandInvoker.Instance.CanRedo()) { CommandInvoker.Instance.Redo(); } } }至此一个完整的、支持撤销/重做的移动系统就搭建好了。你可以看到输入管理、游戏逻辑移动、命令控制已经完全解耦。InputManager只负责创建命令CommandInvoker只负责调度命令MoveCommand封装了“移动意图”PlayerMover专心做“移动”这件事。4. 高级应用与模式变体4.1 实现复杂命令与组合命令命令模式不限于简单移动。它可以封装任何操作。示例1攻击命令public class AttackCommand : ICommand { private IAttackable _attacker; private IDamageable _target; private int _damage; public AttackCommand(IAttackable attacker, IDamageable target, int damage) { _attacker attacker; _target target; _damage damage; } public void Execute() { if (_attacker.CanAttack(_target)) { _attacker.PerformAttackAnimation(); _target.TakeDamage(_damage); Debug.Log(${_attacker.Name} 攻击了 {_target.Name}造成 {_damage} 点伤害。); } } public void Undo() { // 撤销攻击恢复目标生命值可能还需要重置攻击者状态 _target.Heal(_damage); Debug.Log($撤销攻击{_target.Name} 恢复了 {_damage} 点生命。); } }示例2宏命令组合命令一个命令可以包含多个子命令实现复杂操作的原子性撤销/重做。public class MacroCommand : ICommand { private ListICommand _subCommands new ListICommand(); public void AddCommand(ICommand command) _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(); } } } // 使用方式 MacroCommand turnActions new MacroCommand(); turnActions.AddCommand(new MoveCommand(player, Vector3.forward)); turnActions.AddCommand(new AttackCommand(player, enemy, 10)); turnActions.AddCommand(new UseItemCommand(player, healthPotion)); CommandInvoker.Instance.ExecuteCommand(turnActions); // 一键执行/撤销整个回合行动4.2 命令队列与延迟执行命令模式天然支持队列。这对于实现“指令序列”、“回合制行动预输入”或“网络命令缓冲”至关重要。public class CommandQueue { private QueueICommand _commandQueue new QueueICommand(); private bool _isProcessing false; public void EnqueueCommand(ICommand command) { _commandQueue.Enqueue(command); Debug.Log($命令入队当前队列长度{_commandQueue.Count}); } public void ProcessNextCommand() { if (_isProcessing || _commandQueue.Count 0) return; _isProcessing true; ICommand cmd _commandQueue.Dequeue(); Debug.Log($正在执行队列命令...); cmd.Execute(); // 假设命令执行是即时的完成后重置状态。如果是异步命令需要回调。 _isProcessing false; // 可以在这里触发事件通知UI更新队列显示 } public void ClearQueue() _commandQueue.Clear(); }应用场景在RTS游戏中玩家可以连续点击多个位置让单位依次移动。每个移动点都生成一个MoveCommand并放入CommandQueue。游戏每帧或每个时间片从队列中取出一个命令执行直到队列为空。这比让单位同时处理多个路径点要清晰得多。4.3 命令模式与Unity新输入系统Input System Package的集成新的Input System基于事件与Command模式是天作之合。using UnityEngine; using UnityEngine.InputSystem; public class NewInputCommandHandler : MonoBehaviour { public PlayerInput playerInput; // 分配的PlayerInput组件 private CommandInvoker _invoker; private PlayerMover _mover; void Awake() { _invoker CommandInvoker.Instance; _mover GetComponentPlayerMover(); } void OnEnable() { if (playerInput ! null) { playerInput.actions[Move].performed OnMovePerformed; playerInput.actions[Undo].performed OnUndoPerformed; playerInput.actions[Redo].performed OnRedoPerformed; } } void OnDisable() { if (playerInput ! null) { playerInput.actions[Move].performed - OnMovePerformed; playerInput.actions[Undo].performed - OnUndoPerformed; playerInput.actions[Redo].performed - OnRedoPerformed; } } private void OnMovePerformed(InputAction.CallbackContext context) { Vector2 input context.ReadValueVector2(); // 将2D输入转换为3D移动假设是俯视角 Vector3 movement new Vector3(input.x, 0, input.y).normalized; if (movement.magnitude 0.1f _mover.IsValidMove(movement)) { ICommand cmd new MoveCommand(_mover, movement); _invoker.ExecuteCommand(cmd); } } private void OnUndoPerformed(InputAction.CallbackContext context) _invoker.Undo(); private void OnRedoPerformed(InputAction.CallbackContext context) _invoker.Redo(); }这样输入系统的配置按键映射就和具体的命令执行逻辑完全分离了。你可以在Input Asset里随意修改按键绑定而代码无需改动。5. 实战避坑指南与性能优化5.1 常见问题与解决方案问题1命令对象创建频繁可能引发GC垃圾回收压力。现象在高速动作游戏中每帧都可能产生大量命令如移动微调频繁的new MoveCommand()会导致托管堆内存快速增长触发GC引起卡顿。解决方案使用对象池模式来管理命令对象。public class MoveCommandPool { private StackMoveCommand _pool new StackMoveCommand(); public MoveCommand Get(PlayerMover mover, Vector3 movement) { MoveCommand cmd; if (_pool.Count 0) { cmd _pool.Pop(); // 重置命令状态注意如果MoveCommand有状态需要重置 // 因为我们的MoveCommand在构造时注入数据且本身无状态所以池化时需要重新创建。 // 对于无状态或状态可重置的命令池化才有意义。 } else { cmd new MoveCommand(mover, movement); } // 对于需要构造参数的池化可能不直接。一种变体是使用“工厂方法初始化”模式。 return cmd; } public void Release(MoveCommand cmd) { // 清理命令状态如有 _pool.Push(cmd); } }重要提示并非所有命令都适合池化。只有那些构造和销毁成本高、且使用极其频繁的命令才需要考虑。对于大多数游戏每帧几个命令的创建开销可以忽略不计。过早优化是万恶之源先用起来用性能分析工具Unity Profiler确认有GC问题后再考虑池化。问题2撤销/重做栈可能包含对场景中已销毁对象的引用导致空引用异常。现象你执行了一个“攻击怪物A”的命令然后怪物A被其他方式销毁了。当你撤销这个命令时命令对象里的_target引用就变成了null调用_target.Heal(_damage)会抛出异常。解决方案弱引用Weak Reference使用WeakReference来存储对场景对象的引用。但C#的WeakReference在Unity中并不常用因为Unity的GameObject销毁机制特殊。命令失效检查在执行Undo或Redo前检查命令内部引用的对象是否仍然有效。public void Undo() { // 检查_target是否为空或已被销毁 if (_target null || (_target is MonoBehaviour mb mb null)) { Debug.LogWarning(无法撤销命令目标已不存在。); // 可以选择从历史栈中移除这个无效命令或者只是跳过 return; } _target.Heal(_damage); }使用唯一标识符而非直接引用存储对象的唯一ID如InstanceID或自定义ID。撤销时通过一个管理器如UnitManager根据ID查找对象。如果找不到则命令失效。这更适用于大型项目。问题3网络同步中如何保证命令执行顺序一致场景在多人游戏中所有客户端需要同步游戏状态。命令模式是解决这个问题的经典方案。解决方案采用确定性锁步Deterministic Lockstep或命令同步。每个客户端维护一个相同的命令历史列表。玩家的输入被封装成命令并加上一个帧编号或时间戳。通过网络或权威服务器将命令广播给所有客户端。所有客户端在相同的帧执行相同的命令列表。由于所有客户端的初始状态相同且执行的命令序列相同理论上游戏状态会保持一致。public class NetworkedCommand : ICommand { public int FrameNumber { get; private set; } public int PlayerID { get; private set; } public CommandType Type { get; private set; } public byte[] Data { get; private set; } // 序列化的命令参数 public void Execute() { // 根据Type和反序列化后的Data来执行逻辑 // 所有客户端这里的逻辑必须完全一致确定性物理/逻辑 } public void Undo() { /* 网络游戏通常不允许本地撤销 */ } }这要求游戏逻辑必须是确定性的即相同的输入必然产生相同的结果。要避免使用UnityEngine.Random改用自定义的确定性随机数生成器。5.2 性能考量与最佳实践命令对象的轻量化尽量让命令对象只存储数据和引用不包含复杂的计算逻辑。计算逻辑应放在PlayerMover、AttackSystem这样的“服务”类中。历史记录的长度管理如前所述一定要限制撤销栈的大小。对于不需要无限撤销的游戏如回合制策略可能只保留最近50个回合的命令定期清理旧历史。序列化支持如果你需要将游戏状态包括命令历史保存到硬盘存档/读档或者用于网络同步那么你的命令类需要支持序列化。可以使用[System.Serializable]标记并确保所有引用的类型也是可序列化的。或者设计一个专门的命令数据类纯数据与命令执行类分离。区分客户端与服务器命令在网络游戏中有些命令只在客户端本地有效如打开菜单有些则需要发送到服务器验证后广播如移动、攻击。在设计命令体系时就要考虑这种区分。5.3 架构扩展思考命令模式与其他模式的联用与状态模式State Pattern结合角色的不同状态站立、移动、攻击可以决定它能执行哪些命令。例如在“眩晕”状态下InputManager创建的所有移动命令可能都会被忽略或替换为“眩晕抖动”命令。与观察者模式Observer Pattern结合CommandInvoker在执行或撤销命令后可以发布事件如OnCommandExecuted、OnCommandUndone。UI层如显示历史记录的界面可以监听这些事件来更新显示。与工厂模式Factory Pattern结合为了更方便地创建命令可以有一个CommandFactory类根据输入类型或配置数据来生成对应的命令对象。这在支持自定义快捷键或技能宏的游戏中非常有用。6. 从入门到精通你的Command模式实践清单起步在你的下一个小型Unity项目中找一个最简单的功能点比如一个按钮点击切换场景颜色尝试用Command模式实现它并加上撤销功能。感受一下“封装变化”的好处。进阶实现一个简单的回合制战棋Demo。用Command模式处理单位的移动、攻击。实现“回合回退”撤销上一个单位的所有行动功能。挑战尝试用Command模式重构一个你旧项目中的输入处理模块。将散落在各处的Input.GetKeyDown逻辑收拢创建对应的命令类。体会代码可读性和可维护性的提升。深入研究一些开源游戏框架或项目如Unity官方的Unite案例、一些策略游戏的开源实现看它们是如何应用和变种Command模式的。Command模式不是银弹它会增加一些前期的代码量需要多写一些类。但对于任何逻辑复杂度超过“Hello World”的游戏项目尤其是在需要撤销、重做、回放、网络同步或复杂输入编排的场景下它带来的结构清晰度和长期维护成本的降低绝对是值得的。它强迫你思考“动作”的边界和生命周期这是一种极其有益的架构训练。下次当你发现Update方法又膨胀到几百行时不妨停下来想想“这里面的操作能不能抽象成一个命令”
返回列表