设计模式
设计模式这个词最初是在1995由GoF提出的,现今泛指面向对象设计模式(或编程模式),在了解这到底是个什么鬼之前,建议小伙伴们还是先学会一门编程语言为好,并且最好是面向对象的编程语言,比如C++/C#/Java,而且对类、对象、继承与多态等也要有一定基本了解,不然你会看得很吃力。
其实在面向对象还没有需求之前,大家都是用面向过程的方法来编程的,什么意思呢,就是结构化的解决问题。这些问题是一系列的需要被完成的任务,而任务则由函数来解决,焦点在于根据指定条件来完成任务。
但这样有个问题,就是如果这个程序有很多个函数,那么就会产生很多的全局数据,这样很危险,因为这意味着所有函数都可以访问这些全局数据。这同时产生了很多问题,比如:两个函数同时访问一个全局数据,我要提交哪个改动?如果某个函数运行的时候产生了延迟,我怎么确保在这段延迟里面不会有其它函数访问我的全局数据?如果很多个函数有类似的实现方法,但需求稍微不同,我怎么确保我的工资和代码量挂钩?这些问题很有趣,但显然不是我们讨论的重点。重点是,面向对象的编程方法将会越来越凸显它的价值,因为它将数据和操作紧密地结合,并且保护了数据,避免了外界的干扰。
命令模式的定义
”命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。Encapsulate a request as an object, thereby letting you parameterize other objects with different requests, queue or log requests,and support undoable operations.“
这里涉及到两个概念
- 封装。假设我们有一个物件对象,然后这时有个人发送了一个”请求“到这个对象,这个”请求“就是我们要封装的东西。
- 参数化其他对象。通常来讲,我们会有一个发送者发送”请求“,还有一个接收者接收”请求“,因为所有的”请求“将会被封装成对象,所以我们就可以将他们像参数一样传递和存储。
第二点非常有意思,因为这实际上允许我们,使用链表或者数组等的连续存储类型,先存储一部分的命令,然后到一定的时机再一次过执行。在许多的回合制策略型游戏中,这非常有用。我们甚至还可以允许玩家取消自己不小心做错的行为。
游戏循环
在进入正题之前,让我们来聊一聊游戏循环。
如果你很不幸玩过一些Shell脚本写的命令行游戏,那你比起那些使用批处理程序的人要幸运得多了 —— 他们只是坐在那什么也不做的发呆,直到他们的猫提醒他们放置猫粮。
现在的游戏比起以前的命令行窗口酷炫多了,但游戏的核心其实没什么不同。以前的程序是文本,而现在的命令是鼠标点击和按键。你和以前的Shell程序员其实在处理同一个问题,它们都长这样。
while (true)
{
InputHandler();
Update();
render();
}
不过由于unity帮我们处理了render的部分,所以上面的代码在C#里面看起来像这样。
using System.Collections.Generic;
using UnityEngine;
public class Control : MonoBehaviour{
public void Update(){
InputHandler();
Update();
}
}
UnityEngine是用来处理GameObject、Input等涉及unity本身的类的,而Generic是用来处理C#的集合泛型的,大家都知道的对吧。而由于unity会不断循环调用update(),所以也不需要用while循环了。
现在让我们来处理用户的输入吧!假定有这几个命令:Fire,Jump和Attack
通常我们会在InputHandler()里面这样实现:
public void InputHandler(){
if (Input.GetKeyDown(KeyCode.Space)
{
Jump();
}
if (Input.GetKeyDown(KeyCode.F)
{
Fire();
}
if (Input.GetKeyDown(KeyCode.K)
{
Attack();
}
...
}
这个版本已经很好,足够完成整个游戏的开发。但是如果说我们的玩家想要自定按键,比如说,玩家因为某种原因,非常不喜欢按下空格来跳跃,而相反喜欢按C键来跳跃...没办法,谁叫他喜欢呢?
我们希望将按键和命令解耦,使得我们的命令变成可被存储的数据。
来试着将按键和命令解耦吧
在上面的代码中,用户输入和游戏动作是捆绑的。我们需要一个对象来让按钮映射到命令上。
我们首先建立一个Command基类。
public abstract class Command{
public abstract void execute();
}
你当然可以用Interface来实现这个类,你可以试着用Interface来实现它,然后看看有什么利弊。
然后,我们让每个游戏动作继承这个基类,并创建子类。这看起来很愚蠢,但有用不是吗?
public class JumpCommand : Command{
public override void execute(){
Jump():
}
}
public class FireCommand : Command{
public override void execute(){
Fire():
}
}
public class AttackCommand : Command{
public override void execute(){
Attack():
}
}
...
接着我们在调用update()的类里面创建他们的实例。
private JumpCommand Jump;
private FireCommand Fire
private AttackCommand Attack;
我们还需要一个数组来放置玩家的按键,这样玩家就可以随意的设定自己想要对应的按键和动作了。
KeyCode[] keycodes = { KeyCode.Space, KeyCode.F , KeyCode.K};
最后,InpuHandler()会变成这样:
public void InputHandler(){
if (Input.GetKeyDown(keycodes[0])
{
Jump.execute();
}
if (Input.GetKeyDown(keycodes[1])
{
Fire.execute();
}
if (Input.GetKeyDown(keycodes[2])
{
Attack.execute();
}
...
}
发出命令和执行命令
到目前为止,你已经成功将命令封装,让它们与游戏动作解耦,为他们增加了一个间接调用层,现在按钮输入和游戏输出看起来像这样:
在基类Command中,我们实现了一个等待被重写的抽象命令接口execute(),这是在迫使发送者 —— 在我们的例子里自然是InputHandler(),针对这个抽象命令接口编程。只有实现了抽象命令接口的具体命令,比如Jump、Fire,它才能与玩家对象(接收者)关联。
因此,我们引入这个间接调用层的意义在于我们需要将发出命令和执行命令的责任分开,而不是简单的调用命令的函数。这样,命令的调用者和执行者都不必知道对方如何具体操作,可喜可贺。
使用命令模式来发送命令给不同目标
即使我们已经把输入和输出解耦了,但所有的命令都集中被应用到一个玩家角色身上。如果这是个双人游戏呢?我们不得不分别写一个判断来控制他们。但这种代码的可维护性很低,因为说不准以后会有三人对战或者更多有趣的玩法。或者这个游戏有一些敌人、NPC,或者其他什么鬼。我们不应该只关注那些被玩家操作的角色,这对不是玩家的角色很不公平。
因此我们可以在执行execute的时候,将一个我们想要控制的对象传递进去。将Command基类稍微修改。
public abstract class.Command{
public abstract void execute(Control actor);
}
在子类中关联我们想控制的角色。
public class JumpCommand : Command{
public override void execute(Control actor){
actor.Jump();
}
}
现在,这个类可以使得游戏中的任何角色跳跃,只要我们在发出命令的同时传入想要控制的角色对象,那么我们的派生类JumpCommand就可以在对象角色上调用跳跃方法了。
命令模式是回调机制的面向对象版本
现在我们可以用InputHandler来控制任何角色,可我们却并不知道具体是哪个角色在操作。我们需要在这个控制器里延迟我们对命令的调用。所以现在,让我们稍微改一下我们的InputHandler,让它回传我们需要用到的命令吧。
public Command InputHandler(){
if (Input.GetKeyDown(keycodes[0])
{
return Jump;
}
if (Input.GetKeyDown(keycodes[1])
{
return Fire;
}
if (Input.GetKeyDown(keycodes[2])
{
return Attack;
}
...
return null;
}
简单来说,我们在这里是想要保存命令,让他们在接下來的调用中被执行。像这样:
void Update(){
...
Command command = InputHandler();
if (command != null)
{
command.execute(Actor);
}
...
}
现在我们可以把同一份Control类挂到任何一个游戏角色对象身上,只要在调用命令的时候传入他们的引用就好了。于是,我们把命令模式作为一个接口应用于AI系统和需要控制的角色之间,AI只要发出命令就可以了。更进一步地,我们甚至可以针对不同的角色组合不同的命令,来设计AI模块。在游戏超级玛丽里面,有不会飞的乌龟,也有会飞的乌龟,我们只需要将飞行命令和行走命令组合在一起,就能制作出针对会飞的乌龟的AI模块了。我们甚至可以将AI模块反过来套用在玩家身上,实现类似自动寻路一类的功能。像一些拥有demo模式等玩家不需要亲自操作的游戏,或者是新手教学一类的指导性操作,也会用到命令模式。
总结
命令模式的本质是对命令进行封装。由发送者发送命令请求,由接收者接收请求。
它应该拥有一个这样的结构:
- Command基类:是一个抽象类,类中对需要被执行的命令进行声明,一般来说要对外公布一个 execute 方法用来执行命令,而有撤销需求的还会公布一个unexecute方法。
- DerivedCommand派生类:Command类的实现类,需要重写Command类中声明的方法。
- Invoker:调用类,在本例中为Control类,负责调用InputHandler处理命令。
- Receiver:接受类/接收者,在本例中为游戏玩家对象和其他游戏对象。
缺点以及不建议你使用的情况
多到爆炸的命令。如果你在不喝咖啡的情况下加班也不会感到困的话,我并不反对你为每个命令都写一个实现类来进行封装。无论这个命令有多简单,你都不能简简单单地只写个函数就敷衍过去,因为后辈看你的代码时会感到很困惑的。
拓展
- 如果是单人玩家的情况,我们的Invoker,也就是Control类,会有且只有一个,可以用单例模式为Control类提供全局访问,同时限制它有且只有一个实例。如果你想如此做,你应该保证这个游戏现在和将来都是单人游玩。
- 很多的命令和实例,可用享元模式解决,避免内存的浪费
- 使用事件队列将代码生成的命令放入流中,然后通过网络传输这个命令流,在另一些机器上重现这些命令,这是实现网络多人游戏的其中一种思路。
Game Programming Patterns
Game Programming Patternsgameprogrammingpatterns.com如果你是初次接触设计模式,并且想找一些有趣的例子,建议你去读一读这本
Head First 设计模式(中文版)github.com而如果你想找到一些定义,或者专业的讨论,那非常建议你去读GoF的desgin pattern
Design Patternwww.uml.org.cn