游戏编程模式 - 命令模式

前进后退跳跃等诸多的动作让角色在游戏世界中生动起来,用户发出指令,角色响应用户的操作(命令)。在代码世界中,相信我们能快速实现这一些列动作指令:

1
2
3
4
5
6
7
8
9
if (Input.GetKeyDown(KeyCode.RightArrow))
{
actor.Forward();
}
else if (Input.GetKeyDown(KeyCode.LeftArrow))
{
actor.Back();
}
// ...

看上面的代码,并没有什么问题,在上面的代码中,玩家输入了事件(发送命令),名为 actor 的角色响应了用户的操作前进或者后退。这其中,玩家相当于是命令发送者,而角色是命令消费者。没错!玩家发送命令和角色消费命令的代码紧紧耦合在了一起,如果此时按下 RightArrow 的时候,消费者不再是名为 actor 的角色,或者执行的动作不是 Forward,则需要手动的修改代码。

看看设计需求

上面的设计需求经常出现,比如在一款联网游戏中,服务器模拟的 AI 数据需要实时同步到客户端处理,此时你可能想:那对每个动作加以判断,然后对应的角色去执行不就行了吗?这样确实可行,但是不优雅。想想随着动作的增多而你的判断也要增加,最后的代码可能会是下面的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void ExecuteCommand(Action action, Actor actor)
{
if(action == RIGHT)
{
actor.Forward();
}
else if(action == LEFT)
{
actor.Back();
}
else if(action == UP)
{
actor.Jump();
}
// 更多的 action 处理...
}

对于上面的这种情况,我们并不需要关注 AI 角色到底该如何去做什么(服务器已经都做好了),服务器发送了一条指令,让指令执行(其他都不需要关注,有点像 RPC)才是我们该做的事情。此时,使用命令模式就可以满足我们的设计需求。

定义

上面的两个例子阐述什么情况下需要使用命令模式,那么到底什么是命令模式了?以下引用摘自《游戏编程模式》

将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销操作。其本质是将命令的生产者和命令的消费者分开;命令模式属于一种行为模式。

实现

  • 普通的命令 Receiver 类,比如角色类,消费命令实现本身特定的操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Actor
{
public void Left()
{
Debug.Log("Left!");
}
public void Right()
{
Debug.Log("Right!");
}
// other action...
}
  • 引入抽象命令接口,生产者(调用者)使用抽象命令接口编程
1
2
3
4
public abstract class Command
{
public abstract void Execute(Actor actor);
}
  • 具体命令类需要实现抽象接口,在实现中执行接收者对应的操作
1
2
3
4
5
6
7
public class LeftCommand : Command
{
public override void Execute(Actor actor)
{
actor.Left();
}
}
  • 生产者(调用者)依据抽象命令接口编程,通过命令对象来执行请求
1
2
3
4
5
6
7
8
9
10
11
public class GameController
{
public void Main()
{
Actor _actor = new Actor();
Command _leftCommand = new LeftCommand();
// execute
_leftCommand.Execute(_actor);
}
}

通过实现方式可以看出,命令的生产者和命令消费者之间已经没了直接调用关系,他们之间多了一层 Command。这样当你需要消费者执行不同的操作时,添加一个新的命令类并实现消费者的操作即可;甚至你的命令生产者能产生许多命令,将其 push 到一个命令队列中,消费者只要从队列中读取命令消费,因为命令的消费已经与生产者无关了!

再回到我们的第二个例子中。通过使用命令模式对 ExecuteCommand 方法加以改造,从而让代码更加优雅易懂。

1
2
3
4
void ExecuteCommand(Command command, Actor actor)
{
command.Execute(actor);
}

现在,ExecuteCommand 接收的是一个 Command 和一个 Actor,这样看起来是不是更加符合我们的设计需求了?服务器模拟的 AI 的每一个操作通过一个个 Command 发送给客户端,客户端只需要执行命令命令就能够被消费。

ExecuteCommand 方法需要两个参数,在多数情况下这样是不错的选择。因为对于某个命令对象,你可以替换其中的操作执行者从而达到不同的执行者共享同一个命令对象的目的。但是如果碰到需要实现类似命令队列的问题,可能将每个操作执行者作为命令类的成员变量是更好的选择,但因此也就牺牲了共享命令类实例的优势。

如产生一系列命令的同时,将命令 push 到一个队列中,这样实现命令的撤销和恢复,尝试如下改动:

  • 首先,将操作执行类作为命令类的成员变量
1
2
3
4
5
6
public abstract class Command
{
protected Actor _actor;
public abstract void Execute();
}
  • 具体命令类修改
1
2
3
4
5
6
7
8
9
10
11
public class LeftCommand : Command
{
public LeftCommand(Actor actor)
{
_actor = actor;
}
public override void Execute()
{
_actor.Left();
}
}
  • 实现命令队列以及撤销、恢复操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class GameController
{
private List<Command> _commands = new List<Command>();
private int _curCommandIdx = -1;
public void Main()
{
ExecuteCommand(new LeftCommand(new Actor()));
ExecuteCommand(new RightCommand(new Actor()));
Undo();
Do();
}
void ExecuteCommand(Command command)
{
_curCommandIdx++;
_commands.Add(command);
_commands[_curCommandIdx].Execute();
}
void Undo()
{
_curCommandIdx--;
_commands[_curCommandIdx].Execute();
}
void Do()
{
_curCommandIdx++;
_commands[_curCommandIdx].Execute();
}
}

以上就是命令队列简单的实现,虽然牺牲了共享命令类实例的优势,但是换回了更多的设计需求。

所以在有些情况下,当操作非常多的时候,就需要写更多的具体命令类(虽然本来的目的就如此),当不能共享命令类(如同上面的命令队列实现)就可能会产生大量的命令实例。所以,最终的权衡还是要根据更多的需求实际情况来确定。