《游戏编程模式》中的命令模式,翻阅了一天依旧是云里雾里,之后翻了几篇大牛的博客仔细拜读了一下才略有收货。

【游戏设计模式】之二 论撤消重做、回放系统的实现:命令模式 --by 浅墨_毛星云

原文是基于C++实现,尽管语言不是问题,但跨越语言和平台的隔阂还是有些困难。自己花了些时间简单实现了一下命令模式在Unity中的经典实现:撤销效果。这里写一些自己的心得体会。

命令模式

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

这是开篇第一段对于命令模式的定义,但是它太过于晦涩难懂,书中将他定义为

命令是一个对象化(实例化)的方法调用

但又太过笼统很难参透其中,我个人觉得毛星云在博客中的概括很合适:

将一组行为抽象为对象,这个对象和其他对象一样可以被存储和传递,从而实现行为请求者与行为实现者之间的松耦合,这就是命令模式。

解读一下这句话,当需要完成某个功能的时候,我们通常会调用相关类的方法来实现,这一调用的过程就是一个行为。这样的行为固然是正确的,但其实有一个考虑不全面的地方是事调用的结果会保留在反应对象之中,而并不会留下相应的工作记录。就好比我们调用了铅笔工具画了一条线,有调用钢笔画了另外一条线。但是之后我们在想当时是怎么画出的呢?要重现这一过程显然很困难。

如果我们不是调用方法,而是用一个命令来表示“请处理这份工作”,那么我们就可以调用命令在命令队列中,同时管理工作的历史记录,比如我想重复执行之前的操作,我想撤回甚至回访某个功能,就都可以实现了这样的设计模式,我们称之为Command模式,即命令模式。

也许我们没有察觉在大多数游戏中也有命令模式的影子,例如超级肉食男孩中的影子系统(同时回放多次游戏操作画面),赛车中的单人游戏的最高分影子系统,在线mmo中的死亡回放,精彩镜头等都和命令模式有着不解的渊源。

Unity学习之路(1):《游戏编程模式》-命令模式-LMLPHP


命令模式的成名应用--撤销

接下来这个例子(撤销)就是命令模式的成名应用了。一个行为我们可以做(do),那我们就可以撤销(Undo),这一效果在策略游戏中最为常见,我们可以轻松回滚上一局的对战情形或者耍赖悔棋,从而允许玩家分析或尝试不同解法。当然这一效果可以应用到其他游戏产生不同的效果,是时候激发你的想象力了!

我们知道游戏操作一般都是通过鼠标,键盘等控制,而将这些控制信号转化为游戏反馈的是程序中的某个代码块,这些代码块记录游戏的输入,来产生相应的动作。在某款2D游戏中有一些“移动角色到某点”“把角色放大或缩小的魔法”等操作,我们在游戏中可以这样定义:

    void Update()
    {
        //按下鼠标左键
        if (Input.GetMouseButtonDown(0))
        {
            Vector2 endPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            Move(endPos);
        }
        //按下空格键
        else if (Input.GetKeyDown(KeyCode.Space))
        {
            float randomSize = Random.value * 5.0f;
            Scale(new Vector3(randomSize, randomSize, 0));
        }
    }
    //移动位置
    public void Move(Vector2 end)
    {
        transform.position = end;
    }
    //随机缩放
    public void Scale(Vector3 size)
    {
        transform.localScale = size;
    }

这是我们的主要代码段,代码定义了两种游戏行为,即点击屏幕移动和点击空格随机缩放大小。并在update函数中监控事件,检测到信号输入即响应对应的操作。从中心点开始我们进行三次移动,三次缩放操作,效果如下:

Unity学习之路(1):《游戏编程模式》-命令模式-LMLPHP

这就是将用户的输入硬编码到游戏的行为,这样的代码当然是有效的,但是这同样意味着一些后期可能会遇到的问题,我们知道许多游戏都允许玩家自定义按键映射,或要重现玩家先前的操作过程。这样的编码自然无法胜任。

为了后期扩展,我们可以应用命令模式重新定义一下我们的代码,把行为(例如上例中的点击移动)转化称“可以更换”的东西,就如同一个变量,这就是上文提到的把行为抽象成对象。具体操作如下:

创建新的脚本Command.cs,首先建立一个命令基类,其中Player是玩家脚本,也就是玩家想要移动或者缩放的对象,在玩家单一操作的游戏中只需要挂载到对应的物体上就可以省略了,但为了扩展一般我们就放在这里(好吧!其实是上次实验多人后懒得删= =):

//命令基类
public class Command
{
    public virtual void Execute(Player avator)
    {

    }

    public virtual void Undo(Player avator)
    {

    }
}

然后为不同的游戏行为(移动,缩放,当然不限于这些操作,可以自己扩展 理论上所有对游戏的行为都可以)创建不同的命令:

//移动命令
public class MoveCommand : Command
{
    public Vector2 startPos;
    public Vector2 endPos;
    //初始化
    public MoveCommand(Vector2 pos)
    {
        endPos = pos;
    }
    //执行
    public override void Execute(Player avator)
    {
        startPos = avator.transform.position;
        avator.Move(endPos);
    }
    //撤销
    public override void Undo(Player avator)
    {
        avator.Move(startPos);
    }
}

//缩放命令
public class ScaleCommand : Command
{
    public  Vector3 startScale;
    public Vector3 endScale;
    //初始化
    public ScaleCommand(Vector3 scale)
    {
        endScale = scale;
    }
    //执行
    public override void Execute(Player avator)
    {
        startScale = avator.transform.localScale;
        avator.Scale(endScale);
    }
    //撤销
    public override void Undo(Player avator)
    {
        avator.Scale(startScale);
    }
}

每个类包含构造函数(初始化用),除了Execute执行操作之外,还对每个行为提供了Undo撤销操作。
这样我们就有了可以存储和传输的“行为”,即MoveCommand和ScaleCommand的对象,我们可以在鼠标点击后相应的事件更换为其他的对象,即可实现配置的输入,但是我主要为了实现撤销操作,所以按键映射部分请自行探索(换映射的对象)。

接下来需要一个管理映射的类。这个类有两个资本操作,执行命令和撤销命令,(P.s 其实命令队列推荐用栈来实现,压栈和出栈更为方便,因为我对List比较习惯,所以直接选用了List)

    public void ExecutiveCommand(Command command)
    {
        commandList.Add(command);
        command.Execute(avators[playerIndex]);

        Debug.Log("excute command!");
    }

    public void UndoCommand()
    {
        if (commandList.Count > 0)
        {
            Command uCommand = commandList[commandList.Count - 1];
            commandList.Remove(uCommand);
            uCommand.Undo(avators[playerIndex]);

            Debug.Log(commandList.Count + "undo command");
        }
    }

然后加入获取命令代码,InputHandler()返回我们的操作,update()中我们判断是否接受到命令,如果收到执行操作或撤销:

    void Update()
    {
        currentCommand = InputHandler();

        if (currentCommand != null)
        {
            ExecutiveCommand(currentCommand);

        }
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            UndoCommand();
        }
    }

    Command InputHandler()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Vector2 endPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            return new MoveCommand(endPos);
        }
        if (Input.GetKeyDown(KeyCode.Space))
        {
            float randomSize = Random.value * 5.0f;
            return new ScaleCommand(new Vector3(randomSize, randomSize, 0));
        }

        return null;
    }

效果如下:

Unity学习之路(1):《游戏编程模式》-命令模式-LMLPHP

其实命令模式作为一个容易被忽略的设计模式,他的应用远比我例子实现的要强大的多,简单列举一下命令模式在游戏中的应用场景:

  • 更换按键映射
  • 赛车中的影子系统
  • 策略游戏中的悔棋
  • 游戏中的回放功能
  • ...

其实命令模式无非都是记录先前操作之后存储或是传递,在我们需要的时候拿来查找复现等等,实现起来并不复杂效果却很常用。在看看先前的那段话,是不是印象深多了:

** 将一组行为抽象为对象,这个对象和其他对象一样可以被存储和传递,从而实现行为请求者与行为实现者之间的松耦合,这就是命令模式。 **

小结

命令模式虽然好用,但是就如同所有设计模式一样,都要用相应的正确场景才可以用,因为没有完美的设计模式,命令模式的一大缺点在于导致类数目的膨胀,就那上例来说,我们只需要一个脚本中的update()就可以实现的内容重新创建了三个文件四个类并且值的传递也更为复杂,更不用考虑更复杂的游戏了,但是他在扩展性方面留下了很大的余地,所以我们在需要时请仔细考虑他的合适性。

在这里借用毛星云的提炼总结,言简意赅:

  • GOF对命令模式的定义是,命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。命令模式是回调机制的面向对象版本。
  • 将一组行为抽象为对象,这个对象和其他对象一样可以被存储和传递,从而实现行为请求者与行为实现者之间的松耦合,这就是命令模式。
  • 命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。
  • 命令模式很适合实现诸如撤消,重做,回放,时间倒流之类的功能。而基于命令模式实现录像与回放等功能,也就是执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。
  • 命令模式的优点有:对类间解耦、可扩展性强、易于命令的组合维护、易于与其他模式结合,而缺点是会导致类的膨胀。
  • 命令模式有不少的细分种类,实际使用时应根据当前所需来找到合适的设计方式。

附上代码实现的完整工程:我的Github主页

12-05 21:56