《游戏编程模式》二、命令模式(Unity 实现)

232 阅读4分钟

将一个请求(request)封装成一个对象,从而允许你使用不同的请求、队列或日志将客户端参数化,同 时支持请求操作的撤销与恢复。—— GoF

命令就是面向对象化的回调。——GoF

作者本人精炼后:

命令就是一个对象化(实例化)的方法调用(A command is a reifiedmethod call)

概要

优点

  • 解耦调用者接收者
  • 支持命令的撤销重做
  • 方便实现命令队列日志记录

缺点

  • 可能导致过多的具体命令类
  • 学习成本较高

适用场景

  • 操作控制
  • 存档与回放
  • 技能连招
  • AI 行为控制

2.1 配置输入

起始

硬编码用户输入对应执行的行为(暂时能看到输入就行)

  • 按空格执行 Jump()
  • 按 F 执行 Fire()
public class CommandExample : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            Jump();
        else if (Input.GetKeyDown(KeyCode.F))
            Fire();
    }

    private void Fire()
    {
        Debug.Log("Fire");
    }

    private void Jump()
    {
        Debug.Log("Jump");
    }

}

新需求

  • 需求:游戏允许用户配置他们的按钮与游戏行为之间的映射关系
    • 研发提出质疑:“可是,我们的经验是反过来的呀,是修改行为对应的按钮”
    • 策划:“这需求是老板提的,你们是质疑老板的高瞻远睹?”
    • 研发:“。。。”

为了支持自定义配置,我们需要将那些对函数的直接调用替换为一些可动态改变的东西,这就用到命令模式

public class CommandExample : MonoBehaviour
{
    private Command buttonSpace = new JumpCommand();
    private Command buttonF = new FireCommand();
    
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            buttonSpace.Execute();
        else if (Input.GetKeyDown(KeyCode.F))
            buttonF.Execute();
    }
}

// 命令基类
abstract class Command
{
    public abstract void Excute();
}

// “跳跃”命令
class JumpCommand : Command
{
    public override void Excute()
    {
        Debug.Log("Jump");
    }
}

// “开火”命令
class FireCommand : Command
{
    public override void Excute()
    {
        Debug.Log("Fire");
    }
}

这样就可以方便切换按键映射的行为

public class CommandExample : MonoBehaviour
{
    // ...
    void Update()
    {
        // ...
        else if (Input.GetKeyDown(KeyCode.Return))
            SwitchCommand();
    }
    
    private void SwitchCommand()
    {
        // 按空格开火,按 F 跳跃
        buttonSpace = new FireCommand();
        buttonF = new JumpCommand();
        Debug.Log("Command Switched");
    }
}

2.2 关于角色的说明

需求又来了

  • 需求:让命令控制任意角色
    • 研发心想:老板放过我们吧。。。
  • 解决:
    • 让命令的执行支持参数,即可操作任何指定类型的实例
    • 增加一个角色类,封装自身行为
public class CommandExample : MonoBehaviour
{
    // ...
    private GameActor gameActor = new GameActor();
    
    void Update()
    {
        // 把获取输入对应命令的功能封装起来,延后执行
        if (TryGetInputCommand(out Command command))
            command.Excute(gameActor);
    }
    
    private bool TryGetInputCommand(out Command command)
    {
        command = null;
        if (Input.GetKeyDown(KeyCode.Space))
            command = buttonSpace;
        else if (Input.GetKeyDown(KeyCode.F))
            command = buttonF;

        return command != null;
    }
}

abstract class Command
{
    public abstract void Excute(GameActor gameActor);
}

class JumpCommand : Command
{
    public override void Excute(GameActor gameActor)
    {
        gameActor.Jump();
    }
}

class FireCommand : Command
{
    public override void Excute(GameActor gameActor)
    {
        gameActor.Fire();
    }
}

class GameActor
{
    public void Jump()
    {
        Debug.Log("Jump");
    }

    public void Fire()
    {
        Debug.Log("Fire");
    }
}
  • 我们可以让玩家控制游戏中的任何角色,只需向命令传入不同的角色(但不常用)
  • 我们可以照搬上面的命令模式来作为 AI 引擎和受 AI 控制角色之间的接口:AI 简单地提供命令对象以供执行
  • 这种解耦为我们提供了很大的灵活性。我们可以对不同的角色使用不同的 AI 模块
  • 我们甚至可以将 AI 使用到玩家的角色身上 一个绘制拙劣的比喻图

2.3 撤销和重做

双来需求了

  • 需求:让命令支持撤销与重做
    • 研发心想:终于提了个像样的需求,前两个需求麻烦撤销一下
  • 解决:
    • 只输出日志不太方便观察效果,将移动操作可视化
    • 给命令增加“撤销”方法
    • 增加命令队列,实现撤销与重做

第一步:将移动操作可视化

public class CommandExample : MonoBehaviour
{
    // 绑定场景上的对象
    [SerializeField] private Transform actorObj;
    
    // private Command buttonSpace = new JumpCommand();
    // private Command buttonF = new FireCommand();
    
    // W、S 的按键命令,控制对象的前进与后退
    private Command btnW = new MoveCommand(Vector3.forward);
    private Command btnS = new MoveCommand(Vector3.back);
    private GameActor gameActor;

    void Start()
    {
        gameActor = new GameActor(actorObj);
    }

    //...

    private bool TryGetInputCommand(out Command command)
    {
        command = null;
        if (Input.GetKeyDown(KeyCode.W))
            // command = buttonSpace;
            command = btnW;
        else if (Input.GetKeyDown(KeyCode.S))
            // command = buttonF;
            command = btnS;

        return command != null;
    }
}

abstract class Command
{
    //...
}

// 删掉两个测试命令类
// class JumpCommand : Command { ... }
// class FireCommand : Command { ... }

class MoveCommand : Command
{
    private Vector3 moveVec;

    public MoveCommand(Vector3 moveVec)
    {
        this.moveVec = moveVec;
    }

    public override void Excute(GameActor gameActor)
    {
        gameActor.Move(moveVec);
    }
}

class GameActor
{
    private Transform target;

    public GameActor(Transform actorObj)
    {
        target = actorObj;
    }
    
    // public void Jump() { ... }
    // public void Fire() { ... }

    public void Move(Vector3 moveVec)
    {
        target.position += moveVec;
        Debug.Log($"Move: {moveVec}");
    }

}

效果(按 W/S 控制前进/后退):

gif-2025-02-15 at 11.29.07.gif转存失败,建议直接上传图片文件

第二步:给命令增加“撤销”方法

abstract class Command
{
    //...
    public abstract void Undo();
}

class MoveCommand : Command
{
    //...
    private Vector3 oriPos;

    //...

    public override void Excute(GameActor gameActor)
    {
        // 执行前先保存原来位置,方便撤销
        oriPos = gameActor.GetPos();
        gameActor.Move(moveVec);
    }
    
    public override void Undo()
    {
        // 使用原来位置来实现撤销
        gameActor.SetPos(oriPos);
    }
}

class GameActor
{
    //...
    
    public void SetPos(Vector3 pos)
    {
        target.position = pos;
    }
    
    public Vector3 GetPos()
    {
        return target.position;
    }

}

第三步:增加命令列表,实现撤销与重做

  • 想要实现撤销,就得将执行过的命令放进命令列表中,所以每个命令都得独立
public class CommandExample : MonoBehaviour
{
    [SerializeField] private Transform actorObj;

    // 这里不需要了,得更改构建命令对象的方式
    // private Command btnW = new MoveCommand(Vector3.forward);
    // private Command btnS = new MoveCommand(Vector3.back);

    private GameActor gameActor;
    // 已执行的命令列表
    private List<Command> commandList = new List<Command>();
    // 当前的命令下标
    private int commandIdx = -1;
    // 当前命令
    private Command CurrentCommand => commandIdx >= 0 ? commandList[commandIdx] : null;
    // 下个命令
    private Command NextCommand => commandIdx + 1 < commandList.Count ? commandList[commandIdx + 1] : null;

    //...

    void Update()
    {
        if (TryGetInputCommand(out Command command))
        {
            // command.Excute(gameActor);
            ExcuteCommand(command);
        }
        else if (Input.GetKeyDown(KeyCode.Z))
        {
            // 按 Z 撤销
            UndoCommand();
        }
        else if (Input.GetKeyDown(KeyCode.Y))
        {
            // 按 Y 重做
            RedoCommand();
        }
    }

    private void RedoCommand()
    {
        if (NextCommand == null)
            return;

        NextCommand.Excute();
        commandIdx++;
        Debug.Log($"Redo Command. idx: {commandIdx}");
    }

    private void UndoCommand()
    {
        if (CurrentCommand == null)
            return;
            
        CurrentCommand.Undo();
        commandIdx--;
        Debug.Log($"Undo Command. idx: {commandIdx}");
    }

    private void ExcuteCommand(Command command)
    {
        command.Excute();
        // 将后面的命令清空
        if (commandIdx + 1 < commandList.Count)
            commandList.RemoveRange(commandIdx + 1, commandList.Count - commandIdx - 1);

        commandList.Add(command);
        commandIdx++;
        Debug.Log($"Excute Command. idx: {commandIdx}");
    }

    private bool TryGetInputCommand(out Command command)
    {
        // 按 WSAD 得到对应的命令对象
        command = null;
        if (Input.GetKeyDown(KeyCode.W))
            command = new MoveCommand(gameActor, Vector3.forward);
        else if (Input.GetKeyDown(KeyCode.S))
            command = new MoveCommand(gameActor, Vector3.back);
        else if (Input.GetKeyDown(KeyCode.A))
            command = new MoveCommand(gameActor, Vector3.left);
        else if (Input.GetKeyDown(KeyCode.D))
            command = new MoveCommand(gameActor, Vector3.right);
            
        return command != null;
    }
    
    abstract class Command
    {
        // 执行方法去掉参数,参数的获取挪到构建函数中实现
        // public abstract void Excute(GameActor gameActor);
        public abstract void Excute();
        //...
    }
    
    class MoveCommand : Command
    {
        //...
        private GameActor gameActor;

        public MoveCommand(GameActor actor, Vector3 moveVec)
        {
            this.gameActor = actor;
            this.moveVec = moveVec;
            this.oriPos = actor.GetPos();
        }

        // public override void Excute(GameActor gameActor)
        public override void Excute()
        {
            // oriPos = gameActor.GetPos();
            gameActor.Move(moveVec);
        }

        //...
    }
    
    //...
}

效果(WSAD 移动,Z 撤销,Y 重做):

gif-2025-02-15 at 11.57.21.gif转存失败,建议直接上传图片文件

最后

最后附上 UML(跟上面的代码实现有点对不上就是了,主打一个装):

转存失败,建议直接上传图片文件

我看不懂 UML 那眼花缭乱的箭头