各位同仁,各位技术爱好者,大家好!
今天,我们将深入探讨一个在游戏开发中至关重要的设计模式——命令模式(Command Pattern),并以此为核心,构建一个具备“完美撤销”(Undo)功能的指令引擎。在复杂的游戏系统中,玩家的每一次操作,从移动角色、施放技能到开启菜单,本质上都是一个指令。如何优雅地处理这些指令,特别是如何实现一个健壮、灵活且能够轻松回溯的撤销机制,是衡量一个游戏引擎设计水平的关键指标之一。
我们将从命令模式的基础原理出发,逐步深入到如何为其赋予撤销能力,并最终解决在真实游戏场景中遇到的各种挑战,以实现我们所追求的“完美撤销”。
游戏指令的困境与命令模式的诞生
想象一个实时策略游戏,玩家可以选取单位、下达移动指令、攻击指令、建造指令等等。每个指令都会改变游戏世界的状态。如果玩家不小心下错了指令,或者希望尝试不同的策略,一个能够撤销操作的功能就变得极其宝贵。
传统的做法,往往是将这些操作逻辑直接写在输入处理函数中,或者分散在各个游戏对象的方法里。这种方式在简单场景下尚可,但很快就会暴露出问题:
- 耦合度高: 客户端(玩家输入、UI按钮)与具体操作逻辑紧密耦合。
- 难以扩展: 增加新的操作或修改现有操作,可能需要修改多处代码。
- 撤销/重做困难: 要实现撤销,需要手动记录每次状态变化,并在撤销时逆转,这会使代码变得异常复杂且容易出错。
- 序列化/网络同步复杂: 难以将操作序列化进行保存、加载或通过网络传输。
命令模式正是为解决这类问题而生。它将一个请求(或操作)封装成一个独立的、可传递的对象,从而将发出请求的对象(客户端或调用者)与执行请求的对象(接收者)解耦。
命令模式的核心理念
命令模式的本质是将“动作”本身对象化。不再是直接调用某个方法,而是创建一个代表这个方法的对象,然后由一个中间人来执行它。
让我们先看看命令模式的几个关键参与者:
| 参与者 | 职责 |
|---|---|
| Command | 声明执行操作的接口,通常只有一个 Execute() 方法。对于撤销功能,我们会增加 Undo() 方法。 |
| ConcreteCommand | 实现 Command 接口。它将一个接收者对象与一个或一组动作绑定,并封装了执行这些动作所需的所有信息。 |
| Client | 创建一个 ConcreteCommand 对象,并设置它的接收者。 |
| Invoker | 持有 Command 对象,并在适当的时候调用 Command 的 Execute() 方法。它不知道具体操作的细节。 |
| Receiver | 知道如何执行与请求相关的操作。任何类都可以作为接收者。 |
基础命令模式示例:移动单位
假设我们有一个 Unit 类,它可以在游戏世界中移动。
// 接收者:知道如何执行具体操作
public class Unit
{
public string Name { get; private set; }
public int X { get; private set; }
public int Y { get; private set; }
public Unit(string name, int x, int y)
{
Name = name;
X = x;
Y = y;
Console.WriteLine($"Unit '{Name}' created at ({X}, {Y}).");
}
public void Move(int newX, int newY)
{
Console.WriteLine($"Unit '{Name}' moving from ({X}, {Y}) to ({newX}, {newY}).");
X = newX;
Y = newY;
}
public void ReportPosition()
{
Console.WriteLine($"Unit '{Name}' is currently at ({X}, {Y}).");
}
}
// 命令接口
public interface ICommand
{
void Execute();
}
// 具体命令:移动单位
public class MoveUnitCommand : ICommand
{
private Unit _unit;
private int _targetX;
private int _targetY;
// 构造函数接收接收者和操作所需参数
public MoveUnitCommand(Unit unit, int targetX, int targetY)
{
_unit = unit;
_targetX = targetX;
_targetY = targetY;
}
public void Execute()
{
_unit.Move(_targetX, _targetY);
}
}
// 调用者:触发命令执行
public class GameInputHandler
{
private ICommand _command;
public void SetCommand(ICommand command)
{
_command = command;
}
public void PressButton() // 模拟玩家按下按钮
{
if (_command != null)
{
_command.Execute();
}
}
}
// 客户端:创建命令并设置给调用者
public class GameClient
{
public static void Main(string[] args)
{
Unit playerUnit = new Unit("Hero", 0, 0);
GameInputHandler inputHandler = new GameInputHandler();
// 玩家决定移动单位
ICommand moveCommand = new MoveUnitCommand(playerUnit, 10, 5);
inputHandler.SetCommand(moveCommand);
inputHandler.PressButton(); // 执行移动
playerUnit.ReportPosition();
// 玩家再次移动
moveCommand = new MoveUnitCommand(playerUnit, 20, 10);
inputHandler.SetCommand(moveCommand);
inputHandler.PressButton(); // 执行第二次移动
playerUnit.ReportPosition();
}
}
运行上述代码,我们会看到单位的移动轨迹和最终位置。这里,MoveUnitCommand 将 Unit 对象的 Move 操作封装起来,GameInputHandler 作为调用者,只负责执行 ICommand 接口,而无需知道具体是哪个单位在如何移动。这就是命令模式的基础。
迈向完美撤销:扩展命令模式
基础的命令模式已经为我们解耦了操作。但要实现撤销,我们需要为 ICommand 接口添加一个 Undo() 方法,并且 ConcreteCommand 需要存储足够的信息来逆转 Execute() 所做的改变。
带有撤销功能的 ICommand 接口
public interface ICommand
{
void Execute();
void Undo();
string GetDescription(); // 用于UI显示或调试
bool IsUndoable { get; } // 标识该命令是否可撤销
}
带有撤销功能的 MoveUnitCommand
为了撤销移动,MoveUnitCommand 需要记录单位的旧位置。
public class MoveUnitCommand : ICommand
{
private Unit _unit;
private int _oldX, _oldY; // 用于撤销
private int _newX, _newY; // 用于执行
public string GetDescription() => $"Move unit '{_unit.Name}' from ({_oldX},{_oldY}) to ({_newX},{_newY})";
public bool IsUndoable => true;
public MoveUnitCommand(Unit unit, int targetX, int targetY)
{
_unit = unit;
_newX = targetX;
_newY = targetY;
// 注意:_oldX, _oldY 必须在 Execute() 时捕获,因为命令创建时单位可能尚未移动
// 或者,可以在构造函数中捕获当前位置,但这要求命令在当前状态下立即创建
// 为了更通用的撤销,我们选择在 Execute() 中捕获
}
public void Execute()
{
// 在执行前捕获当前状态,以便撤销
_oldX = _unit.X;
_oldY = _unit.Y;
_unit.Move(_newX, _newY);
Console.WriteLine($"[Executed] {GetDescription()}");
}
public void Undo()
{
// 将单位移回旧位置
_unit.Move(_oldX, _oldY);
Console.WriteLine($"[Undone] {GetDescription()} (Back to ({_unit.X},{_unit.Y}))");
}
}
命令处理器(CommandProcessor)
为了管理命令的执行和撤销历史,我们需要一个中央处理器。这个处理器通常会维护两个堆栈:一个用于已执行的命令,一个用于已撤销的命令。
using System;
using System.Collections.Generic;
public class CommandProcessor
{
private Stack<ICommand> _executedCommands = new Stack<ICommand>();
private Stack<ICommand> _undoneCommands = new Stack<ICommand>();
public void ExecuteCommand(ICommand command)
{
if (command == null)
{
Console.WriteLine("Cannot execute null command.");
return;
}
command.Execute();
_executedCommands.Push(command);
// 每当执行新命令时,清空重做堆栈,因为新操作会覆盖之前的重做历史
_undoneCommands.Clear();
Console.WriteLine($"Command executed: {command.GetDescription()}");
PrintHistoryState();
}
public void UndoLastCommand()
{
if (_executedCommands.Count > 0)
{
ICommand commandToUndo = _executedCommands.Pop();
if (commandToUndo.IsUndoable)
{
commandToUndo.Undo();
_undoneCommands.Push(commandToUndo);
Console.WriteLine($"Command undone: {commandToUndo.GetDescription()}");
}
else
{
Console.WriteLine($"Cannot undo command: {commandToUndo.GetDescription()} (Not undoable)");
// 如果命令不可撤销,我们可能需要将其重新放回已执行堆栈,或者记录此情况
// 这里为了简化,我们假设不可撤销的命令不会被推入可撤销的堆栈。
// 真实场景可能需要更复杂的策略。
}
}
else
{
Console.WriteLine("No commands to undo.");
}
PrintHistoryState();
}
public void RedoLastCommand()
{
if (_undoneCommands.Count > 0)
{
ICommand commandToRedo = _undoneCommands.Pop();
// 重做时,我们重新执行命令。Execute会再次捕获当前状态,但对于Undo/Redo,
// 我们的目标是恢复到之前执行的状态,所以这里要小心。
// 理想情况下,Redo应该只是逆转Undo,而不是重新执行。
// 如果Execute()在执行时捕获当前状态,那么Redo()也应该使用Execute()。
// 这里的实现假设Execute()是幂等的,或者其状态捕获逻辑正确处理了重做。
commandToRedo.Execute(); // 重新执行
_executedCommands.Push(commandToRedo);
Console.WriteLine($"Command redo: {commandToRedo.GetDescription()}");
}
else
{
Console.WriteLine("No commands to redo.");
}
PrintHistoryState();
}
public bool CanUndo() => _executedCommands.Count > 0 && _executedCommands.Peek().IsUndoable;
public bool CanRedo() => _undoneCommands.Count > 0;
public void ClearHistory()
{
_executedCommands.Clear();
_undoneCommands.Clear();
Console.WriteLine("Command history cleared.");
PrintHistoryState();
}
private void PrintHistoryState()
{
Console.WriteLine("--- History State ---");
Console.WriteLine($"Executed Commands: {_executedCommands.Count}");
foreach (var cmd in _executedCommands)
{
Console.WriteLine($" - {cmd.GetDescription()} (Executed)");
}
Console.WriteLine($"Undone Commands: {_undoneCommands.Count}");
foreach (var cmd in _undoneCommands)
{
Console.WriteLine($" - {cmd.GetDescription()} (Undone)");
}
Console.WriteLine("---------------------");
}
}
测试带撤销功能的指令引擎
public class Game
{
public static void Main(string[] args)
{
Unit playerUnit = new Unit("Knight", 0, 0);
CommandProcessor processor = new CommandProcessor();
Console.WriteLine("n--- Step 1: Execute first move ---");
ICommand move1 = new MoveUnitCommand(playerUnit, 10, 5);
processor.ExecuteCommand(move1);
playerUnit.ReportPosition();
Console.WriteLine("n--- Step 2: Execute second move ---");
ICommand move2 = new MoveUnitCommand(playerUnit, 20, 10);
processor.ExecuteCommand(move2);
playerUnit.ReportPosition();
Console.WriteLine("n--- Step 3: Undo last move ---");
processor.UndoLastCommand();
playerUnit.ReportPosition();
Console.WriteLine("n--- Step 4: Redo last undone move ---");
processor.RedoLastCommand();
playerUnit.ReportPosition();
Console.WriteLine("n--- Step 5: Undo twice ---");
processor.UndoLastCommand();
processor.UndoLastCommand();
playerUnit.ReportPosition();
Console.WriteLine("n--- Step 6: Execute new command after undo ---");
ICommand move3 = new MoveUnitCommand(playerUnit, 5, 5);
processor.ExecuteCommand(move3); // 这会清空重做堆栈
playerUnit.ReportPosition();
Console.WriteLine("n--- Step 7: Try to redo (should fail) ---");
processor.RedoLastCommand(); // 此时Redo堆栈已清空
playerUnit.ReportPosition();
}
}
这段代码展示了一个基本的撤销/重做机制。MoveUnitCommand 在 Execute 时保存了旧状态,在 Undo 时恢复旧状态。CommandProcessor 负责管理命令的历史。
实现“完美撤销”的挑战与策略
上述基础实现虽然有效,但在真实的游戏场景中,"完美撤销"远比这复杂。完美的撤销意味着无论操作多么复杂,涉及多少游戏对象,都能将其准确无误地还原到之前的状态,且不留下任何副作用。
挑战一:状态捕获的深度与广度
问题: 简单的值类型(如 int X, Y)易于捕获,但复杂对象、引用类型、嵌套结构或大量属性的变化,如何高效且准确地捕获旧状态?
策略:
- 直接在命令中存储必要信息: 对于简单命令,直接在
ConcreteCommand内部存储Execute()之前和Undo()之后需要的所有状态变量。这是最直接也最常用的方法。- 优点: 简单直观,每个命令自给自足。
- 缺点: 可能会导致命令对象过大,如果需要回溯的状态信息很多,或者涉及深层对象图,实现起来会很繁琐。
- Mementos (备忘录模式): 当接收者对象的状态非常复杂时,可以让接收者提供一个方法来创建其状态的“备忘录”对象。命令只存储这个备忘录,在
Undo()时将备忘录还原给接收者。- 优点: 将状态捕获的责任从命令转移到接收者,接收者更了解自己的内部结构。
- 缺点: 引入了额外的备忘录对象和管理逻辑。
- 差量状态记录: 不记录整个对象,只记录发生改变的属性及其旧值。
- 优点: 节省内存。
- 缺点: 复杂性增加,需要一个通用机制来识别和记录属性变化。
代码示例:AttackCommand 与状态捕获
一个攻击命令不仅改变了被攻击单位的生命值,可能还会触发动画、音效,甚至改变攻击者的状态(如消耗能量)。
// 接收者:一个更复杂的Unit
public class AdvancedUnit : Unit // 继承自之前的Unit,增加更多属性
{
public int Health { get; private set; }
public int MaxHealth { get; private set; }
public int Mana { get; private set; }
public int AttackPower { get; private set; }
public AdvancedUnit(string name, int x, int y, int health, int mana, int attackPower)
: base(name, x, y)
{
MaxHealth = health;
Health = health;
Mana = mana;
AttackPower = attackPower;
Console.WriteLine($"Advanced Unit '{Name}' created at ({X}, {Y}) with {Health} HP, {Mana} MP.");
}
public void TakeDamage(int amount)
{
Health -= amount;
Console.WriteLine($"Unit '{Name}' took {amount} damage. Current HP: {Health}");
}
public void Heal(int amount)
{
Health = Math.Min(MaxHealth, Health + amount);
Console.WriteLine($"Unit '{Name}' healed {amount} HP. Current HP: {Health}");
}
public void ConsumeMana(int amount)
{
Mana -= amount;
Console.WriteLine($"Unit '{Name}' consumed {amount} Mana. Current MP: {Mana}");
}
public void RestoreMana(int amount)
{
Mana += amount; // 简化,不考虑MaxMana
Console.WriteLine($"Unit '{Name}' restored {amount} Mana. Current MP: {Mana}");
}
public void ReportStatus()
{
ReportPosition();
Console.WriteLine($" HP: {Health}/{MaxHealth}, MP: {Mana}");
}
}
// 攻击命令:需要记录多个状态
public class AttackCommand : ICommand
{
private AdvancedUnit _attacker;
private AdvancedUnit _target;
private int _damageDealt;
private int _manaCost;
// 撤销所需状态
private int _targetOldHealth;
private int _attackerOldMana;
public string GetDescription() => $"'{_attacker.Name}' attacks '{_target.Name}' for {_damageDealt} damage (cost {_manaCost} mana)";
public bool IsUndoable => true;
public AttackCommand(AdvancedUnit attacker, AdvancedUnit target, int manaCost)
{
_attacker = attacker;
_target = target;
_manaCost = manaCost;
}
public void Execute()
{
// 捕获旧状态
_targetOldHealth = _target.Health;
_attackerOldMana = _attacker.Mana;
// 执行操作
_damageDealt = _attacker.AttackPower; // 伤害基于攻击者攻击力
_target.TakeDamage(_damageDealt);
_attacker.ConsumeMana(_manaCost);
Console.WriteLine($"[Executed] {GetDescription()}");
}
public void Undo()
{
// 逆转操作
_target.Heal(_damageDealt); // 恢复目标生命
_attacker.RestoreMana(_manaCost); // 恢复攻击者法力
// 确保状态完全恢复到Execute前
// 这里只是简单的加减,如果涉及到更复杂的逻辑(如触发被动技能),需要更细致的逆转
Console.WriteLine($"[Undone] {GetDescription()} (Target HP: {_target.Health}, Attacker MP: {_attacker.Mana})");
}
}
挑战二:副作用与依赖的处理
问题: 游戏操作往往伴随着各种副作用,如播放动画、音效、粒子效果、更新UI、触发事件等。仅仅撤销核心状态不足以实现“完美撤销”,这些副作用也需要被撤销或重做。
策略:
- 命令内管理副作用: 让
Execute()和Undo()方法直接处理副作用。例如,Execute()播放攻击动画,Undo()停止动画或播放反向动画。- 优点: 简单,副作用与命令紧密耦合。
- 缺点: 耦合度高,命令变得臃肿,难以复用副作用逻辑。
- 事件驱动: 命令只负责改变游戏状态,然后发出事件(如
UnitAttackedEvent)。事件监听器(如AnimationPlayer,SoundManager,UIManager)响应事件并处理副作用。撤销时,命令发出UnitAttackedUndoneEvent,监听器响应并逆转副作用。- 优点: 解耦,命令更纯粹,副作用处理逻辑集中。
- 缺点: 引入事件系统,需要设计撤销事件,管理事件的逆转顺序可能复杂。
- 集中式副作用管理器: 专门的管理器监听所有命令的执行,并根据命令类型来处理副作用。
- 优点: 副作用处理逻辑集中管理。
- 缺点: 管理器可能变得庞大,需要知道所有命令的细节。
代码示例:事件驱动的副作用处理
假设我们有一个简单的事件管理器。
// 简单的事件系统
public interface IGameEvent {}
public class UnitAttackedEvent : IGameEvent
{
public AdvancedUnit Attacker { get; }
public AdvancedUnit Target { get; }
public int Damage { get; }
public bool IsUndo { get; } // 标识是否是撤销事件
public UnitAttackedEvent(AdvancedUnit attacker, AdvancedUnit target, int damage, bool isUndo = false)
{
Attacker = attacker;
Target = target;
Damage = damage;
IsUndo = isUndo;
}
}
public static class EventBus
{
private static Dictionary<Type, List<Action<IGameEvent>>> _subscribers = new Dictionary<Type, List<Action<IGameEvent>>>();
public static void Subscribe<T>(Action<T> handler) where T : IGameEvent
{
Type eventType = typeof(T);
if (!_subscribers.ContainsKey(eventType))
{
_subscribers[eventType] = new List<Action<IGameEvent>>();
}
_subscribers[eventType].Add(e => handler((T)e));
}
public static void Publish(IGameEvent gameEvent)
{
Type eventType = gameEvent.GetType();
if (_subscribers.ContainsKey(eventType))
{
foreach (var handler in _subscribers[eventType])
{
handler(gameEvent);
}
}
}
}
// 副作用处理器示例
public class EffectManager
{
public EffectManager()
{
EventBus.Subscribe<UnitAttackedEvent>(HandleUnitAttacked);
}
private void HandleUnitAttacked(UnitAttackedEvent e)
{
if (e.IsUndo)
{
Console.WriteLine($"[EffectManager] Reverting attack animation/sound for {e.Attacker.Name} -> {e.Target.Name}");
// 停止之前的攻击动画,或者播放反向动画,或者清除粒子效果
}
else
{
Console.WriteLine($"[EffectManager] Playing attack animation/sound for {e.Attacker.Name} -> {e.Target.Name}");
// 播放攻击动画,触发粒子效果,播放音效
}
}
}
// 修改后的AttackCommand,发布事件
public class AttackCommandWithEvents : ICommand
{
private AdvancedUnit _attacker;
private AdvancedUnit _target;
private int _manaCost;
private int _damageDealt;
private int _targetOldHealth;
private int _attackerOldMana;
public string GetDescription() => $"'{_attacker.Name}' attacks '{_target.Name}' for {_damageDealt} damage (cost {_manaCost} mana)";
public bool IsUndoable => true;
public AttackCommandWithEvents(AdvancedUnit attacker, AdvancedUnit target, int manaCost)
{
_attacker = attacker;
_target = target;
_manaCost = manaCost;
}
public void Execute()
{
_targetOldHealth = _target.Health;
_attackerOldMana = _attacker.Mana;
_damageDealt = _attacker.AttackPower;
_target.TakeDamage(_damageDealt);
_attacker.ConsumeMana(_manaCost);
EventBus.Publish(new UnitAttackedEvent(_attacker, _target, _damageDealt, isUndo: false));
Console.WriteLine($"[Executed] {GetDescription()}");
}
public void Undo()
{
_target.Heal(_damageDealt);
_attacker.RestoreMana(_manaCost);
EventBus.Publish(new UnitAttackedEvent(_attacker, _target, _damageDealt, isUndo: true));
Console.WriteLine($"[Undone] {GetDescription()}");
}
}
挑战三:事务性命令(宏命令)
问题: 玩家的一个操作可能由多个原子命令组成。例如,“施放火球术”可能包括“消耗法力”、“造成伤害”、“播放施法动画”。这些原子命令必须作为一个整体被撤销或重做。
策略:
组合命令模式(Composite Command Pattern):创建一个 CompositeCommand 类,它本身也是一个 ICommand,但其内部包含一个 ICommand 列表。
Execute()方法:按顺序执行所有内部命令。Undo()方法:按逆序撤销所有内部命令。
public class CompositeCommand : ICommand
{
private List<ICommand> _commands = new List<ICommand>();
public string Name { get; private set; }
public string GetDescription() => $"Composite Command: {Name} ({_commands.Count} sub-commands)";
public bool IsUndoable => _commands.TrueForAll(c => c.IsUndoable); // 只有所有子命令都可撤销,宏命令才可撤销
public CompositeCommand(string name)
{
Name = name;
}
public void AddCommand(ICommand command)
{
if (command != null)
{
_commands.Add(command);
}
}
public void Execute()
{
Console.WriteLine($"[Executing Composite Command] {Name}");
foreach (var command in _commands)
{
command.Execute();
}
Console.WriteLine($"[Finished Composite Command] {Name}");
}
public void Undo()
{
Console.WriteLine($"[Undoing Composite Command] {Name}");
// 撤销顺序与执行顺序相反
for (int i = _commands.Count - 1; i >= 0; i--)
{
if (_commands[i].IsUndoable)
{
_commands[i].Undo();
}
else
{
Console.WriteLine($"Warning: Sub-command '{_commands[i].GetDescription()}' in composite command '{Name}' is not undoable.");
}
}
Console.WriteLine($"[Finished Undoing Composite Command] {Name}");
}
}
// 示例:施放火球术
public class CastFireballSpellCommand : CompositeCommand
{
public CastFireballSpellCommand(AdvancedUnit caster, AdvancedUnit target) : base("Cast Fireball Spell")
{
// 创建并添加子命令
AddCommand(new ConsumeManaCommand(caster, 20)); // 消耗20法力
AddCommand(new AttackCommand(caster, target, 0)); // 造成伤害 (manaCost=0, 因为已经在ConsumeManaCommand中处理)
// AddCommand(new PlayAnimationCommand(caster, "FireballCast")); // 播放动画(这里省略具体实现)
}
}
// 辅助命令:消耗法力
public class ConsumeManaCommand : ICommand
{
private AdvancedUnit _unit;
private int _amount;
private int _oldMana;
public string GetDescription() => $"Consume {_amount} mana from '{_unit.Name}'";
public bool IsUndoable => true;
public ConsumeManaCommand(AdvancedUnit unit, int amount)
{
_unit = unit;
_amount = amount;
}
public void Execute()
{
_oldMana = _unit.Mana;
_unit.ConsumeMana(_amount);
Console.WriteLine($"[Executed] {GetDescription()}");
}
public void Undo()
{
_unit.RestoreMana(_amount);
Console.WriteLine($"[Undone] {GetDescription()} (Back to {_unit.Mana} MP)");
}
}
挑战四:命令队列与异步执行
问题: 实时游戏中,命令可能不会立即执行,而是被放入队列,在游戏循环的特定阶段异步处理。这会影响撤销的语义。
策略:
- 撤销只针对已执行命令:
CommandProcessor应该只管理那些已经Execute()过的命令。队列中的待执行命令如果被取消,则不应进入撤销历史。 - 命令状态: 命令可以有
Pending,Executing,Executed,Cancelled等状态。只有Executed的命令才会被推入撤销堆栈。 - 同步与异步的边界: 明确哪些命令是立即执行的,哪些是异步的。通常,玩家的直接操作(如UI点击)会创建命令并立即推送到
CommandProcessor执行,而AI或长期效果的命令可能通过其他队列处理。
挑战五:性能与内存管理
问题: 大量的命令对象和冗长的历史堆栈会消耗大量内存,频繁的对象创建也会带来GC开销。
策略:
- 限制历史深度:
CommandProcessor可以限制_executedCommands堆栈的最大容量。当达到上限时,最老的命令会被移除。 - 命令对象池: 对于频繁创建的命令,可以使用对象池(Object Pool Pattern)来复用命令对象,减少GC压力。
- 差量记录与压缩: 对于复杂状态,只记录状态的差异,而不是完整副本。或者对历史记录进行压缩。
- 按需加载: 如果历史非常长,可以考虑将部分历史序列化到磁盘,只在需要时加载。
挑战六:不可逆操作
问题: 有些操作本质上是不可逆的,例如发送网络消息给服务器、真实地扣除玩家的钱(在某些设计中)。
策略:
- 明确标识: 在
ICommand接口中添加IsUndoable属性。CommandProcessor在Undo()时检查此属性。 - 分离设计: 不可逆操作可能根本不应该被设计为可撤销的
ICommand。它们可能是独立的函数调用,或者属于另一个不参与撤销系统的命令体系。 - 用户界面提示: 对于不可逆操作,UI应明确提示玩家。
// 示例:一个不可撤销的命令
public class SendChatMessageCommand : ICommand
{
private string _message;
public string GetDescription() => $"Send chat message: '{_message}'";
public bool IsUndoable => false; // 明确标识为不可撤销
public SendChatMessageCommand(string message)
{
_message = message;
}
public void Execute()
{
Console.WriteLine($"[Executed] Sending chat message: '{_message}' to server...");
// 实际的网络发送逻辑
}
public void Undo()
{
// 尝试撤销一个网络消息是没有意义的,也不能实现
Console.WriteLine($"[WARNING] Attempted to undo non-undoable command: '{_message}'");
throw new NotSupportedException("This command cannot be undone.");
}
}
游戏指令引擎的完整设计
综合上述挑战与策略,我们可以勾勒出一个更为完善的游戏指令引擎架构。
核心组件概览
| 组件名称 | 主要职责 |
|---|---|
ICommand |
定义命令接口:Execute(), Undo(), GetDescription(), IsUndoable。 |
CommandProcessor |
命令调度中心。管理 _executedCommands 和 _undoneCommands 堆栈,提供 ExecuteCommand, UndoLastCommand, RedoLastCommand 方法。负责历史的维护和清理。 |
GameWorld |
游戏世界的核心状态,包含所有游戏对象(Unit, Item, Map 等)。是大多数命令的 Receiver。 |
InputHandler |
负责捕获玩家输入(键盘、鼠标、触摸屏),并将其转换为具体的 ICommand 对象。 |
UIManager |
处理游戏UI,包括显示命令历史、提供撤销/重做按钮,并根据 CommandProcessor 的 CanUndo/CanRedo 状态更新按钮可用性。 |
EffectManager |
监听 EventBus 中的事件,处理视觉、听觉等副作用。在接收到撤销事件时,逆转相应的副作用。 |
EventBus |
简单的发布/订阅系统,用于命令与副作用处理器之间的解耦。命令发布事件,副作用处理器订阅事件。 |
ConcreteCommands |
各种具体的游戏操作命令,如 MoveUnitCommand, AttackCommand, UseItemCommand, CastSpellCommand 等。它们在 Execute() 时捕获状态,在 Undo() 时还原状态,并在需要时发布事件。 |
CompositeCommand |
特殊的 ICommand,用于组合多个子命令为一个宏操作,实现事务性撤销。 |
整合到游戏循环
// 游戏主类
public class GameEngine
{
private CommandProcessor _commandProcessor;
private GameWorld _gameWorld;
private InputHandler _inputHandler;
private UIManager _uiManager;
private EffectManager _effectManager;
public GameEngine()
{
_gameWorld = new GameWorld(); // 假设GameWorld管理所有游戏对象
_commandProcessor = new CommandProcessor();
_inputHandler = new InputHandler(_gameWorld, _commandProcessor); // InputHandler需要知道GameWorld和CommandProcessor
_uiManager = new UIManager(_commandProcessor); // UIManager需要知道CommandProcessor
_effectManager = new EffectManager(); // 注册事件监听器
}
public void Init()
{
// 初始化一些游戏对象
AdvancedUnit hero = new AdvancedUnit("Hero", 0, 0, 100, 50, 20);
AdvancedUnit enemy = new AdvancedUnit("Goblin", 10, 10, 50, 0, 10);
_gameWorld.AddUnit(hero);
_gameWorld.AddUnit(enemy);
}
public void RunGameLoop()
{
Console.WriteLine("n--- Game Loop Started ---");
// 模拟游戏循环
// 1. 处理输入
_inputHandler.HandleInputs(); // 玩家输入触发命令
// 2. 更新游戏世界状态 (命令执行后,世界状态已经改变)
// 这里可以有其他非命令相关的更新逻辑
// 3. 渲染 (UI更新和动画播放由EffectManager处理)
_uiManager.UpdateUI();
// 模拟玩家操作
var hero = _gameWorld.GetUnit("Hero");
var goblin = _gameWorld.GetUnit("Goblin");
Console.WriteLine("n=== Player Action: Hero moves ===");
_commandProcessor.ExecuteCommand(new MoveUnitCommand(hero, 5, 0));
hero.ReportStatus();
Console.WriteLine("n=== Player Action: Hero attacks Goblin ===");
_commandProcessor.ExecuteCommand(new AttackCommandWithEvents(hero, goblin, 5)); // 攻击消耗5法力
hero.ReportStatus();
goblin.ReportStatus();
Console.WriteLine("n=== Player Action: Hero casts Fireball ===");
_commandProcessor.ExecuteCommand(new CastFireballSpellCommand(hero, goblin));
hero.ReportStatus();
goblin.ReportStatus();
Console.WriteLine("n=== Player Action: Undo Last ===");
_commandProcessor.UndoLastCommand();
hero.ReportStatus();
goblin.ReportStatus();
Console.WriteLine("n=== Player Action: Undo Last (again) ===");
_commandProcessor.UndoLastCommand();
hero.ReportStatus();
goblin.ReportStatus();
Console.WriteLine("n=== Player Action: Redo ===");
_commandProcessor.RedoLastCommand();
hero.ReportStatus();
goblin.ReportStatus();
Console.WriteLine("n--- Game Loop Finished ---");
}
}
// 模拟GameWorld类
public class GameWorld
{
private Dictionary<string, AdvancedUnit> _units = new Dictionary<string, AdvancedUnit>();
public void AddUnit(AdvancedUnit unit)
{
_units[unit.Name] = unit;
}
public AdvancedUnit GetUnit(string name)
{
return _units.ContainsKey(name) ? _units[name] : null;
}
}
// 模拟InputHandler
public class InputHandler
{
private GameWorld _world;
private CommandProcessor _processor;
public InputHandler(GameWorld world, CommandProcessor processor)
{
_world = world;
_processor = processor;
}
public void HandleInputs()
{
// 实际游戏中会监听键盘鼠标事件,这里简化
// 例如,玩家点击了撤销按钮
// if (Input.GetKeyDown(KeyCode.Z)) _processor.UndoLastCommand();
// if (Input.GetKeyDown(KeyCode.Y)) _processor.RedoLastCommand();
}
}
// 模拟UIManager
public class UIManager
{
private CommandProcessor _processor;
public UIManager(CommandProcessor processor)
{
_processor = processor;
}
public void UpdateUI()
{
// 更新UI按钮状态
// 例えば、撤销按钮的可用性取决于 _processor.CanUndo()
Console.WriteLine($"[UI] Undo button {( _processor.CanUndo() ? "enabled" : "disabled" )}");
Console.WriteLine($"[UI] Redo button {( _processor.CanRedo() ? "enabled" : "disabled" )}");
}
}
// 主入口
public class Application
{
public static void Main(string[] args)
{
GameEngine game = new GameEngine();
game.Init();
game.RunGameLoop();
}
}
高级考量与最佳实践
序列化与持久化
为了支持游戏存档/读档,或者在调试时重现特定状态,命令历史也需要能够被序列化和反序列化。这要求所有 ICommand 实现及其内部引用的状态信息都是可序列化的。可以采用 JSON、XML 或二进制格式。
调试与日志
命令模式天然地提供了一个操作日志。CommandProcessor 可以记录每个执行、撤销、重做操作,这对于调试、分析玩家行为和重现Bug非常有帮助。GetDescription() 方法在这里发挥了重要作用。
测试
每个 ConcreteCommand 都是一个独立的单元,可以非常方便地进行单元测试。测试其 Execute() 和 Undo() 方法是否正确地改变和还原了 Receiver 的状态,以及是否正确处理了副作用。CommandProcessor 也可以作为一个独立单元进行集成测试。
幂等性
一个理想的 Execute() 和 Undo() 方法应该是幂等的,即多次调用不会产生额外的副作用或错误状态(在合理范围内)。虽然这并非强制,但在某些复杂场景下,幂等性可以简化设计和错误恢复。
隔离领域逻辑与命令逻辑
尽量保持 Receiver(游戏世界对象)的领域逻辑纯粹,不包含撤销相关的代码。撤销逻辑应主要体现在 ConcreteCommand 中。
通过命令模式,我们不仅实现了游戏指令与执行者的解耦,更重要的是,为游戏带来了强大的撤销和重做功能。从捕获状态到处理副作用,再到组合复杂操作,命令模式提供了一套优雅且可扩展的框架。虽然实现“完美撤销”需要细致的思考和周全的设计,但其带来的灵活性和用户体验提升,无疑是值得投入的。这个指令引擎,如同游戏世界的心脏,每一次跳动都记录着历史,每一次回溯都重塑着可能。