游戏编程模式_2_命令模式
- 概念:
- 将一个请求封装成一个对象,从而让用户使用不同的请求把客户端参数化;对请求排队或记录日志,以及支持可撤销的操作。
-
- 定义或者说概念初次看到时永远是这么的晦涩难懂。
- 本书解释:
-
比喻不够形象,加上英语的一词多义,产生了很多歧义。
- 首先就是"客户(client)"指代同你有着业务来往的一类人。据我查证,人类(human beings)是不可“参数化”的。
- 其次,句子的剩余部分只是列举了这个模式可能的使用场景。而万一你遇到的用例不在其中,那么上面的阐述就不太明朗了。
- 我对命令模式的精练(pithy)概括如下:
- 命令就是一个对象化(实例化)的方法调用(A command is a reified method call)。
- 当然,“精炼”通常意味着“简洁到令人费解”,所以这里我的定义可能显得不够好。
- 让我解释一下:你可能没听过“Reify”一词,意即“具象化”(make real)。
- 另一个术语reifying的意思是使一些事物成为“第一类”(first-class)。
- 这两个术语都意味着,将某个概念(concept)转化为一块数据(data)、一个对象,或者你可以认为是传入函数的变量等。
- 所以说命令模式是一个“对象化的方法调用”,我的意思就是封装在一个对象中的一个方法调用。
- 也就是说,无论那种解释,命令相当于对函数(或者方法/回调/行为/DoSomeThing)的一种延伸和封装,使之具有良好的可扩展性。
- GOF补充:
- 命令就是一个对象化(实例化)的方法调用(A command is a reified method call)。
- 可以使用的场景:
-
- 基础实现,撤销
实例——配置输入
- 按键输入
-
- W => 前进
- A => 向左走
- S => 后退
- D => 向右走
- Q => 向左旋转
- R => 向右旋转
- J => 开火
直接写逻辑
using System;
using UnityEngine;
public class ShooterBehaviour : MonoBehaviour
{
private void Update()
{
if (Input.GetKey(KeyCode.W))
{
MoveForward();
}else if (Input.GetKey(KeyCode.A))
{
MoveLeft();
}else if (Input.GetKey(KeyCode.S))
{
MoveBack();
}else if (Input.GetKey(KeyCode.D))
{
MoveRight();
}else if (Input.GetKey(KeyCode.Q))
{
TurnLeft();
}else if (Input.GetKey(KeyCode.E))
{
TurnRight();
}else if (Input.GetKey(KeyCode.J))
{
Fire();
}
}
private void Fire()
{
Debug.Log("开火");
}
private void TurnRight()
{
Debug.Log("向右转");
}
private void TurnLeft()
{
Debug.Log("向左转");
}
private void MoveRight()
{
Debug.Log("向右走");
}
private void MoveBack()
{
Debug.Log("后退");
}
private void MoveLeft()
{
Debug.Log("向左走");
}
private void MoveForward()
{
Debug.Log("前进");
}
}
如果不急着实现功能的话,可以先log出来,看看有没有问题。结果如下图1.2.1所示:
接下来我们要将方法转换成命令,
- 首先:
- 将需要创建一个接口,对命令进行抽象。
-
namespace LearnScripts { public interface ICommand { void Execute(); } }
- 接下来我们要创建多个命令来实现这个接口,如下图1.2.2所示:
-
全部都继承ICommand接口,并且实现Execute方法。
- 接下来,我们只需要在管理类当中保存对应的应用。
-
... public class ShooterBehaviour : MonoBehaviour { private ICommand FireCommand; private ICommand MoveBackCommand; private ICommand MoveForwardCommand; private ICommand MoveLeftCommand; private ICommand MoveRightCommand; private ICommand TurnLeftCommand; private ICommand TurnRightCommand; ... }
我们这里已经添加了所有的命令对象。我们这里只需要知道ICommand对应的excute的方法,而不需要知道实际实例的内部实现。
- 这里需要进行一个初始化,我们可以使用工厂模式来动态创建。
- 然后我们可以对Command进行创建和获取。同时也完成了我们第一步的逻辑。
-
using System; using LearnScripts; using UnityEngine; public class ShooterBehaviour : MonoBehaviour { private ICommand FireCommand; private ICommand MoveBackCommand; private ICommand MoveForwardCommand; private ICommand MoveLeftCommand; private ICommand MoveRightCommand; private ICommand TurnLeftCommand; private ICommand TurnRightCommand; private ICommand EmptyCommand; private void Start() { FireCommand = CommandFactory.Create(KeyCode.J); MoveBackCommand = CommandFactory.Create(KeyCode.S); MoveForwardCommand = CommandFactory.Create(KeyCode.W); MoveLeftCommand = CommandFactory.Create(KeyCode.A); MoveRightCommand = CommandFactory.Create(KeyCode.D); TurnLeftCommand = CommandFactory.Create(KeyCode.Q); TurnRightCommand = CommandFactory.Create(KeyCode.E); EmptyCommand = CommandFactory.Create(default); } private void Update() { var command = HandleInput(); command.Execute(); } private ICommand HandleInput() { ICommand command = EmptyCommand; if (Input.GetKey(KeyCode.W)) { command = MoveForwardCommand; }else if (Input.GetKey(KeyCode.A)) { command = MoveLeftCommand; }else if (Input.GetKey(KeyCode.S)) { command = MoveBackCommand; }else if (Input.GetKey(KeyCode.D)) { command = MoveRightCommand; }else if (Input.GetKey(KeyCode.Q)) { command = TurnLeftCommand; }else if (Input.GetKey(KeyCode.E)) { command = TurnRightCommand; }else if (Input.GetKey(KeyCode.J)) { command = FireCommand; } return command; } }
接下来我们把之前创建的命令加上log,看看他们的功能是否完好。
但是单纯使用Icommand我们是没有办法把Behavior赋值给对应的命令的。如果要赋值则需要修改接口,很明显这样的设计很缺乏内聚性同时也违反了开闭原则。
- 作者在这里的解法是:
- 问题在于它们做了这样的假定:存在jump()、fireGun()等这样的顶级函数,这些函数能够隐式地获知玩家游戏实体并对其进行木偶般的操控。这种对耦合性的假设限制了这些命令的使用范围。JumpCommand类的跳跃命令只能作用于玩家对象。
- 让我们放宽限制,传进去一个我们想要控制的对象而不是让命令自身来确定所控制的对象:
- 将Execute方法添加一个actor参数,这个参数表明了执行命令的实际对象;当我们传入时,我们对于对象的依赖也就消除了。
- 这样一来,命令和执行者之间的耦合就被解除了。这时我们才真正意义上使用命令对于行为进行调用。
- 可以将工序重构为:获取命令对象和执行对应操作两步,重构之后的代码为:
- ShooterBehaviour脚本
-
using LearnScripts; using UnityEngine; public class ShooterBehaviour : MonoBehaviour,IGameActor { private ICommand FireCommand; private ICommand MoveBackCommand; private ICommand MoveForwardCommand; private ICommand MoveLeftCommand; private ICommand MoveRightCommand; private ICommand TurnLeftCommand; private ICommand TurnRightCommand; private ICommand EmptyCommand; private void Start() { FireCommand = CommandFactory.Create(KeyCode.J); MoveBackCommand = CommandFactory.Create(KeyCode.S); MoveForwardCommand = CommandFactory.Create(KeyCode.W); MoveLeftCommand = CommandFactory.Create(KeyCode.A); MoveRightCommand = CommandFactory.Create(KeyCode.D); TurnLeftCommand = CommandFactory.Create(KeyCode.Q); TurnRightCommand = CommandFactory.Create(KeyCode.E); EmptyCommand = CommandFactory.Create(default); } private void Update() { var command = HandleInput(); command.Execute(this); } private ICommand HandleInput() { ICommand command = EmptyCommand; if (Input.GetKey(KeyCode.W)) { command = MoveForwardCommand; }else if (Input.GetKey(KeyCode.A)) { command = MoveLeftCommand; }else if (Input.GetKey(KeyCode.S)) { command = MoveBackCommand; }else if (Input.GetKey(KeyCode.D)) { command = MoveRightCommand; }else if (Input.GetKey(KeyCode.Q)) { command = TurnLeftCommand; }else if (Input.GetKey(KeyCode.E)) { command = TurnRightCommand; }else if (Input.GetKey(KeyCode.J)) { command = FireCommand; } return command; } public void Fire() { Debug.Log("开火"); } public void MoveForward() { Debug.Log("前进"); } public void MoveBack() { Debug.Log("后退"); } public void MoveRight() { Debug.Log("向右移动"); } public void MoveLeft() { Debug.Log("向左移动"); } public void TurnRight() { Debug.Log("向右转"); } public void TurnLeft() { Debug.Log("向左转"); } }
- CommandFactory脚本
-
using LearnScripts.Commands; using UnityEngine; namespace LearnScripts { public static class CommandFactory { //这里使用静态类,因为它本身不需要有自己的成员和成员方法 public static ICommand Create(KeyCode keyCode) { switch (keyCode) { case KeyCode.W: return new MoveForwardCommand(); case KeyCode.S: return new MoveBackCommand(); case KeyCode.A: return new MoveLeftCommand(); case KeyCode.D: return new MoveRightCommand(); case KeyCode.Q: return new TurnLeftCommand(); case KeyCode.E: return new TurnRightCommand(); case KeyCode.J: return new FireCommand(); //空模式 default: return new EmptyCommand(); } } } }
- MoveForwardCommand等行为脚本
-
namespace LearnScripts.Commands { public class MoveForwardCommand : ICommand { public void Execute(IGameActor actor) { actor.MoveForward(); } } }
- ICommand接口
-
namespace LearnScripts { public interface ICommand { void Execute(IGameActor actor); } }
- IGameActor接口
-
namespace LearnScripts { public interface IGameActor { void Fire(); void MoveBack(); void MoveForward(); void MoveLeft(); void MoveRight(); void TurnLeft(); void TurnRight(); } }
- 解耦之后功能和之前的一致,如下图所示:
- 完全解耦后,此功能还有一个妙用;因为命令和角色之间加入的间接层使得我们可以让玩家控制游戏中的任何角色,只需要通过更改命令执行时传入的角色对象即可。AI也可以套用同样的操作。
- 将控制角色的命令作为头等对象,我们便解除了函数直接调用这样的紧耦合。如下图所示:
- 一些代码(输入处理或者AI)生成命令并将它们放置于命令流中,一些代码(发送者或者角色自身)执行命令并且调用它们。通过中间的队列,我们将生产者端和消费者端解耦。
- 如果我们把这些命令序列化,我们便可以通过网络发送数据流。我们可以把玩家的输入,通过网络发送到另外一台机器上,然后进行回放。这是多人网络游戏很重要的一部分。
using LearnScripts.Commands;
using UnityEngine;
namespace LearnScripts
{
public static class CommandFactory
{
//这里使用静态类,因为它本身不需要有自己的成员和成员方法
public static ICommand Create(KeyCode keyCode)
{
switch (keyCode)
{
case KeyCode.W: return new MoveForwardCommand();
case KeyCode.S: return new MoveBackCommand();
case KeyCode.A: return new MoveLeftCommand();
case KeyCode.D: return new MoveRightCommand();
case KeyCode.Q: return new TurnLeftCommand();
case KeyCode.E: return new TurnRightCommand();
case KeyCode.J: return new FireCommand();
//空模式
default: return new EmptyCommand();
}
}
}
}
撤销与重做
如果一个命令对象可以做(do)一些事情,那么就应该可以很轻松地撤销(undo)它们。
- 具体实现:
-
-
提取移动命令
- 注意这里保存的是具体的移动信息,而不是统一的移动行为。
- 此时命令不是可重复执行的对象,而是根据实际情况,在所需的时机不断创建的实例。
-
定义Undo方法
- Undo方法回滚了execute方法造成的游戏状态改变。
virtual void undo(){ unit_->moveTo(xBefore_,yBefore_); }
- 这里保存了xBefore,yBefore等状态,这是回退的核心。
-
多重撤销与重做:
- 用一个数据结构(如List)保存命令。
- Undo=>回退上一条,并往前移动一个标志位
- Redo=>执行当前标志位的下一位,并往后移动一个标志位。
-
实现记录的方式有两种:
- 备忘录模式,即记录状态快照(也是最常见的方式,数据在命令中)
- 持久化数据,即记录新的实体对象(每次创建新的命令,都创建一个新的命令执行对象的主体。数据在实行对象身上)
-
代码实现
- IBuildCommand接口
-
namespace CommandPatternExtra.Scripts.Command { public interface IBuildCommand { public PutBuildingInfo putBuildingInfo { get; set; } void Execute(); void Undo(); void Redo(); } }
- MoveBuildingCommand脚本
-
using UnityEngine; namespace CommandPatternExtra.Scripts.Command { public class MoveBuildingCommand: IBuildCommand { public PutBuildingInfo putBuildingInfo { get; set; } private Vector3 _resultPosition; public Vector3 StartPosition; public void Execute() { _resultPosition = putBuildingInfo.Building.transform.position; } public void Undo() { putBuildingInfo.Building.transform.position = StartPosition; } public void Redo() { putBuildingInfo.Position = _resultPosition; } } }
- NewBuildingCommand脚本
-
using UnityEngine; namespace CommandPatternExtra.Scripts.Command { public class NewBuildingCommand: IBuildCommand { public PutBuildingInfo putBuildingInfo { get; set; } public BuildingEditSystem EditSystem; public Vector3 StartPosition; public void Execute() { GameObject obj = GameObject.Instantiate(putBuildingInfo.Template); obj.transform.position = putBuildingInfo.Position; putBuildingInfo.Building = obj; StartPosition = putBuildingInfo.Position; var dragOperator = obj.AddComponent<BuildingDragOperator>(); dragOperator.BuildingInfo = putBuildingInfo; dragOperator.EditSystem = EditSystem; var clickOperator = obj.AddComponent<BuildingClickOperator>(); clickOperator.BuildingInfo = putBuildingInfo; clickOperator.EditSystem = EditSystem; } public void Undo() { EditSystem.RemoveBuilding(putBuildingInfo, true); } public void Redo() { putBuildingInfo.Position = StartPosition; Execute(); } } }
- RotateBuildingCommand脚本
-
using UnityEngine; namespace CommandPatternExtra.Scripts.Command { public class RotateBuildingCommand: IBuildCommand { public PutBuildingInfo putBuildingInfo { get; set; } public Quaternion StartRotation; public Quaternion ResultRotation; public bool Changed = false; public void Rotate() { Transform transform = putBuildingInfo.Building.transform; transform.rotation = Quaternion.Euler(transform.eulerAngles + new Vector3(0, 90, 0)); ResultRotation = transform.rotation; Changed = true; } public void Execute() { putBuildingInfo.Rotation = ResultRotation; } public void Undo() { putBuildingInfo.Rotation = StartRotation; } public void Redo() { putBuildingInfo.Rotation = ResultRotation; } } }
类风格化还是函数风格化
根据不同的语言不同针对;对于函数是顶级的语言来说(如JS),可以使用函数的方式;比如C++,C#这种函数不是第一对象的语言,需要使用类来实现。