当前位置: 首页 > news >正文

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

1. 项目概述:为什么你的Unity项目需要Command模式?

如果你在Unity里写过游戏逻辑,尤其是涉及到玩家输入、角色移动、技能释放或者任何需要“撤销/重做”功能的地方,大概率遇到过这样的场景:一堆if-else或者switch语句散落在Update或事件回调里,代码越写越乱,想加个“回退上一步”的功能,发现得把整个输入处理逻辑重写一遍。我接手过不少项目,初期为了赶进度,逻辑直接写在PlayerControllerUpdate里,后期想加个“录像回放”或者“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); } } }

这段代码在原型阶段没问题,但隐患重重:

  1. 逻辑与输入强耦合:移动逻辑直接写在Update里,与Unity的输入系统绑死。想换成新的输入系统(如Input System Package)?重写吧。
  2. 无法撤销:玩家移动后,状态直接改变了。你想实现“悔棋”?除非你记录每一帧的位置然后回溯,但这和游戏逻辑混在一起,非常混乱。
  3. 难以扩展:现在要加一个“冲锋”技能,冲锋过程中无视障碍。你可能会在Update里加更多标志位和状态判断,代码迅速变得臃肿。
  4. 不利于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 name="playerMover">移动执行组件</param> /// <param name="movement">移动方向和距离</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); } }

关键点解析

  1. 数据封装MoveCommand在创建时(构造函数)就捕获了所有必要信息:谁移动(_playerMover)和怎么移动(_movement)。这意味着命令对象在创建后就是自包含的、不可变的(理想情况下),这非常利于存储和序列化。
  2. 逻辑隔离MoveCommand本身不实现移动的物理逻辑,它只是调用PlayerMover组件的方法。这保持了单一职责原则:PlayerMover负责“如何移动”(可能包含碰撞检测),MoveCommand负责“记录一次移动的意图”。
  3. 对称的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 Stack<ICommand> _undoStack = new Stack<ICommand>(); // 重做栈:记录被撤销的命令,用于重做 private Stack<ICommand> _redoStack = new Stack<ICommand>(); // 可选:限制历史记录长度,防止内存无限增长 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) { // 示例性代码:实际中可能需要更高效的数据结构,如LinkedList<ICommand> var list = new List<ICommand>(_undoStack); list.RemoveAt(0); // 移除最旧的命令(列表的第一个元素是栈底) _undoStack = new Stack<ICommand>(list); } } }

设计决策深度解析

  1. 为何使用栈(Stack)?撤销操作本质是“后进先出”。玩家最近的操作最先被撤销。栈完美匹配这个语义。PushPop操作的时间复杂度都是O(1),效率很高。
  2. 双栈结构(Undo/Redo):这是实现撤销/重做的经典结构。_undoStack保存已执行的操作,_redoStack保存被撤销的操作。执行新命令时清空_redoStack是关键,这符合大多数编辑器的逻辑(新操作会切断未来的重做历史)。
  3. 单例模式 vs 依赖注入:这里用了简单的MonoBehaviour单例是为了教程清晰。在大型项目中,更推荐使用一个纯C#类(非MonoBehaviour)并通过依赖注入框架(如Zenject、VContainer)将其注入到需要的地方,这样耦合度更低,更易于测试。
  4. 历史记录限制:在长时间运行的游戏中(尤其是策略游戏),无限制保存命令历史可能导致内存泄漏。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 name="movement">移动向量(应是网格大小的整数倍)</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.Action<Vector3> OnMoved; }

经验之谈:这里我刻意区分了TryMoveMoveTryMove是给玩家输入或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); } // 键盘撤销/重做(如Ctrl+Z, Ctrl+Y) 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 List<ICommand> _subCommands = new List<ICommand>(); 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 Queue<ICommand> _commandQueue = new Queue<ICommand>(); 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 = GetComponent<PlayerMover>(); } 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.ReadValue<Vector2>(); // 将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 Stack<MoveCommand> _pool = new Stack<MoveCommand>(); 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)会抛出异常。
  • 解决方案
    1. 弱引用(Weak Reference):使用WeakReference来存储对场景对象的引用。但C#的WeakReference在Unity中并不常用,因为Unity的GameObject销毁机制特殊。
    2. 命令失效检查:在执行UndoRedo前,检查命令内部引用的对象是否仍然有效。
      public void Undo() { // 检查_target是否为空或已被销毁 if (_target == null || (_target is MonoBehaviour mb && mb == null)) { Debug.LogWarning("无法撤销命令,目标已不存在。"); // 可以选择从历史栈中移除这个无效命令,或者只是跳过 return; } _target.Heal(_damage); }
    3. 使用唯一标识符而非直接引用:存储对象的唯一ID(如InstanceID或自定义ID)。撤销时,通过一个管理器(如UnitManager)根据ID查找对象。如果找不到,则命令失效。这更适用于大型项目。

问题3:网络同步中,如何保证命令执行顺序一致?

  • 场景:在多人游戏中,所有客户端需要同步游戏状态。命令模式是解决这个问题的经典方案。
  • 解决方案:采用确定性锁步(Deterministic Lockstep)命令同步
    1. 每个客户端维护一个相同的命令历史列表。
    2. 玩家的输入被封装成命令,并加上一个帧编号(或时间戳)。
    3. 通过网络(或权威服务器)将命令广播给所有客户端。
    4. 所有客户端在相同的帧执行相同的命令列表
    5. 由于所有客户端的初始状态相同,且执行的命令序列相同,理论上游戏状态会保持一致。
    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 性能考量与最佳实践

  1. 命令对象的轻量化:尽量让命令对象只存储数据和引用,不包含复杂的计算逻辑。计算逻辑应放在PlayerMoverAttackSystem这样的“服务”类中。
  2. 历史记录的长度管理:如前所述,一定要限制撤销栈的大小。对于不需要无限撤销的游戏(如回合制策略,可能只保留最近50个回合的命令),定期清理旧历史。
  3. 序列化支持:如果你需要将游戏状态(包括命令历史)保存到硬盘(存档/读档),或者用于网络同步,那么你的命令类需要支持序列化。可以使用[System.Serializable]标记,并确保所有引用的类型也是可序列化的。或者,设计一个专门的命令数据类(纯数据),与命令执行类分离。
  4. 区分客户端与服务器命令:在网络游戏中,有些命令只在客户端本地有效(如打开菜单),有些则需要发送到服务器验证后广播(如移动、攻击)。在设计命令体系时就要考虑这种区分。

5.3 架构扩展思考:命令模式与其他模式的联用

  • 与状态模式(State Pattern)结合:角色的不同状态(站立、移动、攻击)可以决定它能执行哪些命令。例如,在“眩晕”状态下,InputManager创建的所有移动命令可能都会被忽略或替换为“眩晕抖动”命令。
  • 与观察者模式(Observer Pattern)结合CommandInvoker在执行或撤销命令后,可以发布事件(如OnCommandExecutedOnCommandUndone)。UI层(如显示历史记录的界面)可以监听这些事件来更新显示。
  • 与工厂模式(Factory Pattern)结合:为了更方便地创建命令,可以有一个CommandFactory类,根据输入类型或配置数据来生成对应的命令对象。这在支持自定义快捷键或技能宏的游戏中非常有用。

6. 从入门到精通:你的Command模式实践清单

  1. 起步:在你的下一个小型Unity项目中,找一个最简单的功能点(比如一个按钮点击切换场景颜色),尝试用Command模式实现它,并加上撤销功能。感受一下“封装变化”的好处。
  2. 进阶:实现一个简单的回合制战棋Demo。用Command模式处理单位的移动、攻击。实现“回合回退”(撤销上一个单位的所有行动)功能。
  3. 挑战:尝试用Command模式重构一个你旧项目中的输入处理模块。将散落在各处的Input.GetKeyDown逻辑收拢,创建对应的命令类。体会代码可读性和可维护性的提升。
  4. 深入:研究一些开源游戏框架或项目(如Unity官方的Unite案例、一些策略游戏的开源实现),看它们是如何应用和变种Command模式的。

Command模式不是银弹,它会增加一些前期的代码量(需要多写一些类)。但对于任何逻辑复杂度超过“Hello World”的游戏项目,尤其是在需要撤销、重做、回放、网络同步或复杂输入编排的场景下,它带来的结构清晰度和长期维护成本的降低,绝对是值得的。它强迫你思考“动作”的边界和生命周期,这是一种极其有益的架构训练。下次当你发现Update方法又膨胀到几百行时,不妨停下来想想:“这里面的操作,能不能抽象成一个命令?”

http://www.jsqmd.com/news/1335689/

相关文章:

  • 从30天到7天!电商数据分析SaaS产品效率实测全记录
  • 产品经理3天掌握SQL:数据驱动必备技能
  • 最大割问题:从NP难理论到工程实践的算法全解析
  • PCB布局布线实战:从信号完整性与电源完整性到可靠电路板设计
  • 2026成都整装装修公司全维度盘点 靠谱服务商选型攻略+避坑FAQ - U渠道
  • U盘无法识别故障排查与数据恢复实战指南
  • 日照靠谱导游怎么选?正规日照导游公司哪家靠谱|纯玩导游、私家导游、定制导游管家、导游团队找导游带着玩避坑全攻略 - 滚动商讯
  • 从SPI到eSPI:嵌入式系统总线演进、核心差异与实战指南
  • TextGrocery安装与部署:Unix系统下的快速配置指南
  • 从零搭建Linux Mint虚拟机:VMware配置、系统安装与开发环境部署全指南
  • Blender到Unreal Engine资产迁移:Datasmith高效工作流与5步实战指南
  • 从零构建数据分析项目:Python+MySQL+可视化全流程实战
  • Redis未授权访问漏洞:从原理到实战防护指南
  • Python实战:构建多平台音乐榜单数据聚合与可视化监控系统
  • 选移民公司哪家价格透明?进度查不到的,钱早花出去了 - 北极星移民
  • Origin科研绘图高清导出全攻略:从原理到实践,解决图片模糊与DPI难题
  • Python盲水印技术:在数字图像中隐藏不可见的信息指纹
  • 四元数乘法计算:从原理到IMU姿态解算的工程实践
  • 2026成都本土别墅装修公司精选盘点:正规合规实力品牌测评,避坑指南及服务商选型全攻略 - 产业观察报
  • DeepResearcher快速上手指南:从环境配置到模型训练的完整步骤
  • springboot爱看漫画小程序的设计与实现
  • UE5 NDI插件安装与配置全攻略:从环境搭建到崩溃排错
  • Home Assistant Time Machine:终极配置时光机,让你的智能家居配置回滚无忧
  • C++继承机制全解析:从语法基础到多态实战应用
  • STM32驱动OLED从入门到精通:硬件选型、软件驱动与性能优化全解析
  • 原型设计工具选型指南:从Figma到Axure,8款主流工具深度解析
  • 2026成都一站式全包装修公司实力盘点:正规靠谱服务商甄选攻略 + 全流程避坑指南FAQ - 商业大观
  • springboot安心临期零食微信小程序
  • AI做联盟营销到底靠不靠谱?2024最新数据揭示:87%新手踩的4个致命陷阱及破解路径
  • 2026/8/5-暑期学习日报