解析 ‘Command Pattern’:实现具备‘完美撤销’(Undo)功能的游戏指令引擎

各位同仁,各位技术爱好者,大家好!

今天,我们将深入探讨一个在游戏开发中至关重要的设计模式——命令模式(Command Pattern),并以此为核心,构建一个具备“完美撤销”(Undo)功能的指令引擎。在复杂的游戏系统中,玩家的每一次操作,从移动角色、施放技能到开启菜单,本质上都是一个指令。如何优雅地处理这些指令,特别是如何实现一个健壮、灵活且能够轻松回溯的撤销机制,是衡量一个游戏引擎设计水平的关键指标之一。

我们将从命令模式的基础原理出发,逐步深入到如何为其赋予撤销能力,并最终解决在真实游戏场景中遇到的各种挑战,以实现我们所追求的“完美撤销”。


游戏指令的困境与命令模式的诞生

想象一个实时策略游戏,玩家可以选取单位、下达移动指令、攻击指令、建造指令等等。每个指令都会改变游戏世界的状态。如果玩家不小心下错了指令,或者希望尝试不同的策略,一个能够撤销操作的功能就变得极其宝贵。

传统的做法,往往是将这些操作逻辑直接写在输入处理函数中,或者分散在各个游戏对象的方法里。这种方式在简单场景下尚可,但很快就会暴露出问题:

  1. 耦合度高: 客户端(玩家输入、UI按钮)与具体操作逻辑紧密耦合。
  2. 难以扩展: 增加新的操作或修改现有操作,可能需要修改多处代码。
  3. 撤销/重做困难: 要实现撤销,需要手动记录每次状态变化,并在撤销时逆转,这会使代码变得异常复杂且容易出错。
  4. 序列化/网络同步复杂: 难以将操作序列化进行保存、加载或通过网络传输。

命令模式正是为解决这类问题而生。它将一个请求(或操作)封装成一个独立的、可传递的对象,从而将发出请求的对象(客户端或调用者)与执行请求的对象(接收者)解耦。

命令模式的核心理念

命令模式的本质是将“动作”本身对象化。不再是直接调用某个方法,而是创建一个代表这个方法的对象,然后由一个中间人来执行它。

让我们先看看命令模式的几个关键参与者:

参与者 职责
Command 声明执行操作的接口,通常只有一个 Execute() 方法。对于撤销功能,我们会增加 Undo() 方法。
ConcreteCommand 实现 Command 接口。它将一个接收者对象与一个或一组动作绑定,并封装了执行这些动作所需的所有信息。
Client 创建一个 ConcreteCommand 对象,并设置它的接收者。
Invoker 持有 Command 对象,并在适当的时候调用 CommandExecute() 方法。它不知道具体操作的细节。
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();
    }
}

运行上述代码,我们会看到单位的移动轨迹和最终位置。这里,MoveUnitCommandUnit 对象的 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();
    }
}

这段代码展示了一个基本的撤销/重做机制。MoveUnitCommandExecute 时保存了旧状态,在 Undo 时恢复旧状态。CommandProcessor 负责管理命令的历史。


实现“完美撤销”的挑战与策略

上述基础实现虽然有效,但在真实的游戏场景中,"完美撤销"远比这复杂。完美的撤销意味着无论操作多么复杂,涉及多少游戏对象,都能将其准确无误地还原到之前的状态,且不留下任何副作用。

挑战一:状态捕获的深度与广度

问题: 简单的值类型(如 int X, Y)易于捕获,但复杂对象、引用类型、嵌套结构或大量属性的变化,如何高效且准确地捕获旧状态?

策略:

  1. 直接在命令中存储必要信息: 对于简单命令,直接在 ConcreteCommand 内部存储 Execute() 之前和 Undo() 之后需要的所有状态变量。这是最直接也最常用的方法。
    • 优点: 简单直观,每个命令自给自足。
    • 缺点: 可能会导致命令对象过大,如果需要回溯的状态信息很多,或者涉及深层对象图,实现起来会很繁琐。
  2. Mementos (备忘录模式): 当接收者对象的状态非常复杂时,可以让接收者提供一个方法来创建其状态的“备忘录”对象。命令只存储这个备忘录,在 Undo() 时将备忘录还原给接收者。
    • 优点: 将状态捕获的责任从命令转移到接收者,接收者更了解自己的内部结构。
    • 缺点: 引入了额外的备忘录对象和管理逻辑。
  3. 差量状态记录: 不记录整个对象,只记录发生改变的属性及其旧值。
    • 优点: 节省内存。
    • 缺点: 复杂性增加,需要一个通用机制来识别和记录属性变化。

代码示例: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、触发事件等。仅仅撤销核心状态不足以实现“完美撤销”,这些副作用也需要被撤销或重做。

策略:

  1. 命令内管理副作用:Execute()Undo() 方法直接处理副作用。例如,Execute() 播放攻击动画,Undo() 停止动画或播放反向动画。
    • 优点: 简单,副作用与命令紧密耦合。
    • 缺点: 耦合度高,命令变得臃肿,难以复用副作用逻辑。
  2. 事件驱动: 命令只负责改变游戏状态,然后发出事件(如 UnitAttackedEvent)。事件监听器(如 AnimationPlayer, SoundManager, UIManager)响应事件并处理副作用。撤销时,命令发出 UnitAttackedUndoneEvent,监听器响应并逆转副作用。
    • 优点: 解耦,命令更纯粹,副作用处理逻辑集中。
    • 缺点: 引入事件系统,需要设计撤销事件,管理事件的逆转顺序可能复杂。
  3. 集中式副作用管理器: 专门的管理器监听所有命令的执行,并根据命令类型来处理副作用。
    • 优点: 副作用处理逻辑集中管理。
    • 缺点: 管理器可能变得庞大,需要知道所有命令的细节。

代码示例:事件驱动的副作用处理

假设我们有一个简单的事件管理器。

// 简单的事件系统
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开销。

策略:

  1. 限制历史深度: CommandProcessor 可以限制 _executedCommands 堆栈的最大容量。当达到上限时,最老的命令会被移除。
  2. 命令对象池: 对于频繁创建的命令,可以使用对象池(Object Pool Pattern)来复用命令对象,减少GC压力。
  3. 差量记录与压缩: 对于复杂状态,只记录状态的差异,而不是完整副本。或者对历史记录进行压缩。
  4. 按需加载: 如果历史非常长,可以考虑将部分历史序列化到磁盘,只在需要时加载。

挑战六:不可逆操作

问题: 有些操作本质上是不可逆的,例如发送网络消息给服务器、真实地扣除玩家的钱(在某些设计中)。

策略:

  • 明确标识:ICommand 接口中添加 IsUndoable 属性。CommandProcessorUndo() 时检查此属性。
  • 分离设计: 不可逆操作可能根本不应该被设计为可撤销的 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,包括显示命令历史、提供撤销/重做按钮,并根据 CommandProcessorCanUndo/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 中。


通过命令模式,我们不仅实现了游戏指令与执行者的解耦,更重要的是,为游戏带来了强大的撤销和重做功能。从捕获状态到处理副作用,再到组合复杂操作,命令模式提供了一套优雅且可扩展的框架。虽然实现“完美撤销”需要细致的思考和周全的设计,但其带来的灵活性和用户体验提升,无疑是值得投入的。这个指令引擎,如同游戏世界的心脏,每一次跳动都记录着历史,每一次回溯都重塑着可能。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注