本文作为《Game Programming Pattern》(游戏编程原理)的摘要总结,记录为期一个月的阅读感想与心得体会。
撰写本文时,希望可以尽量遵循star法则,此后的文章同样以这四个要点作为大纲。
situation(情境)
task(任务)
action(行为)
result(结果)
本文代码尽量以unity c#进行演示(最近看的都是cpp代码,时常有被带偏的情况,本人实在太菜菜子了)
命令模式
将一个请求(request)封装成一个对象,从而允许使用不同的请求,队列,或日志将客户端参数化,同时支持操作的撤销与恢复。
ps:因为所有的”请求“将会被封装成对象,所以我们就可以将他们像参数一样传递和存储。这实际上允许我们,使用链表或者数组等的连续存储类型,先存储一部分的命令,然后到一定的时机再一次过执行。在许多的回合制策略型游戏中,这非常有用。我们甚至还可以允许玩家取消自己不小心做错的行为。
situation
玩家可能需要自定义更改某个输入的按键,同时我们希望只写一套接口供玩家和AI系统都能调用。
task
我需要将一个命令/请求/概念转化为数据。同时我希望可以将游戏中的输入解耦成一个独立的接口,然后我们还可以进行命令的撤销和重做。
action
我们通常会将输入这样写
public void InputHandler(){
if (Input.GetKeyDown(KeyCode.Space)
{
Jump();
}
if (Input.GetKeyDown(KeyCode.F)
{
Fire();
}
if (Input.GetKeyDown(KeyCode.K)
{
Attack();
}
...
}
不难看出,上面的代码中,用户输入和游戏动作是捆绑的
将按键和命令解耦吧,让我们的命令变成可被存储的数据。
我们定义一个基类Command来代表可触发的游戏命令:
public abstract class Command{
public abstract void execute();
}
然后,我们让每个游戏动作继承这个基类,并创建子类。
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;
最后,InpuHandler()会变成这样:
public void InputHandler(){
if (Input.GetKeyDown(A)
{
Jump.execute();
}
if (Input.GetKeyDown(B)
{
Fire.execute();
}
if (Input.GetKeyDown(C)
{
Attack.execute();
}
...
}
在基类Command中,我们实现了一个等待被重写的抽象命令接口execute(),这样能迫使发送者(我们的例子中是InputHander)针对这个抽象命令编程。抽象的意义就在于此,只有实现了抽象命令接口的具体命令,比如Jump、Fire,它才能与玩家对象(接收者)关联。
尚有不足的是,我们刚才定义的命令在上个例子中有效,但却存在着耦合性的假设来限制这些命令的使用范围,jump()这些顶级函数能隐性的获知玩家游戏实体,所有的命令都集中被应用到一个玩家角色身上。
我们对Command基类进行一些修改,传入一个角色对象,以便Command的子类能对其进行调用
public abstract class Command{
public abstract void execute(Control actor);
}
...
public class JumpCommand : Command{
public override void execute(Control actor){
actor.Jump();
}
}
现在,只要我们在发出命令的同时传入想要控制的角色对象,那么我们的派生类就可以在对象角色身上调用跳跃方法。
如今就能用InputHandler来控制任何角色,可我们却并不知道具体是哪个角色在操作。
让我们再改进一下InputHandler,在这个控制器里延迟我们对命令的调用,让它回传我们需要用到的命令。
public Command InputHandler(){
if (Input.GetKeyDown(A)
{
return Jump;
}
if (Input.GetKeyDown(B)
{
return Fire;
}
if (Input.GetKeyDown(C)
{
return Attack;
}
...
return null;
}
....
void Update(){
...
Command command = InputHandler();
if (command != null)
{
command.execute(Actor);
}
...
}
我们此时就成功将命令参数化了,这意味着他们能够传入队列,或组成命令流。
在命令与角色加入间接层能使我们可以让玩家控制游戏中的任何角色,只要在调用命令的时候传入他们的引用就好了。
我们在每次玩家做出一个动作时,都创建一个实例,我们由此来实现命令模式最成名的应用——撤销与重做。
我们再修改一下command:
public abstract class Command{
public abstract void execute(Control actor);
public abstract void undo(Control actor);
}
...
public class MoveCommand : Command{
public:
MoveCommand(Control Actor,int x,int y)
{
...
}
public override void execute(Control actor)
{
xBefore=actor.GetX();
yBefore=actor.GetY();
actor.Moveto(x_,y_);
}
public override void undo(Control actor)
{
actor.Moveto(xBefore,yBefore);
}
private:
Control actor;
int x_,y_;
int xBefore,yBefore;
}
我们在类中添加了一些状态,当单位移动时记录他们上一次的位置,调用undo方法便能撤销刚才的移动,再按一次就变为重做。
若要支持多次撤销,我们就维护一个命令列表和一个对当前(current)命令的引用,当玩家执行一个命令就将这个命令添加到列表中,并将current指向它。如果在撤销后执行了一个新的命令,那么位于此命令之后所有命令都被丢弃掉。
result
我们成功使用命令模式将输入解耦,我们可以让玩家更改键位,可以控制任何一个角色(改变传入的对象),同时这套接口也能为AI模块所使用。
它应该拥有一个这样的结构:
- Command基类:是一个抽象类,类中对需要被执行的命令进行声明,一般来说要对外公布一个 execute 方法用来执行命令,而有撤销需求的还会公布一个undo方法。
- xxxxxCommand派生类:Command类的实现类,需要重写Command类中声明的方法。
- Invoker:调用类,负责调用InputHandler处理命令。
- Receiver:接受类/接收者,在本例中为游戏玩家对象和其他游戏对象。
不建议使用的情况:
当命令太多时,为每个命令都写一个实现类来进行封装会耗费大量的时间。
建议:
- 如果是单人玩家的情况,我们的Invoker,也就是Control类,会有且只有一个,可以用单例模式为Control类提供全局访问,同时限制它有且只有一个实例。
- 很多的命令和实例,可用享元模式解决,避免内存的浪费
- 使用事件队列将代码生成的命令放入流中,然后通过网络传输这个命令流,在另一些机器上重现这些命令,这是实现网络多人游戏的其中一种思路。
享元模式
建立起共享以高效的支持大量的细粒度对象
说到享元模式,第一个想到的应该就是池技术了,String常量池、数据库连接池、缓冲池等等都是享元模式的应用,所以说享元模式是池技术的重要实现方式。
situation
我们知道分配太多的对象到应用程序中将有损程序的性能,同时还容易造成内存溢出。我们的系统有大量的对象,而这些对象又将消耗大量内存,且这些对象的状态大部分都可以外部化。
task
我们需要只发送一次对象们共享的数据,然后再单独的将每个实例的特有数据推送到需要的地方。减少对象的创建,降低程序内存的占用,提高效率。
action
享元模式通过将对象数据切分成内部状态和外部状态来解决问题。
外部状态指对象得以依赖的一个标记,是随环境改变而改变的、不可共享的状态。
内部状态指对象共享出来的信息,存储在享元对象内部并且不会随环境的改变而改变。
例如围棋有大量的棋子对象,棋子只有黑白两色,所以棋子颜色就是棋子的内部状态;而各个棋子之间的差别就是位置的不同,我们落子嘛,落子颜色是定的,但位置是变化的,所以方位坐标就是棋子的外部状态。
首先我们定义Flyweight抽象类,它是所有具体享元类的超类或接口,通过这个接口,Flyweight可以接受并作用于外部状态。该类通过工厂之外,没有任何方式来创建设置这些属性。
public abstract class Flyweight {
//内部状态
public string mInside;
//外部状态
protected string mOutside;
//构造函数要求享元角色必须接受外部状态
public Flyweight(String Outside):
mOutside(Outside) {}
//定义操作
public abstract void Operate();;
....Get()
....Set()
}
接着我们创建ConcreteFlyweight类,它继承Flyweight超类或实现Flyweight接口,作用是为其内部状态增加存储空间。
public class ConcreteFlyWeight : FlyWeight
{
public ConcreteFlyWeight(string outside)
:base(outside){}
//调用基类构造函数,接受外部状态
public override void Operate()
{
Debug.Log("外部状态:" + mOutside);
}
}
对内存使用的情况一般就是大量生产的内存对象情况才能明显的降低内存,因此可以一般在工厂(大量生产某种属性对象)中进行生产共享属性的具体设置。
我们创建FlyweightFactory类作为享元工厂,用它来创建并管理Flyweight对象。当用户请求一个Flyweight时,FlyweightFactory对象提供一个已创建的实例或创建一个实例。
public class FlyWeightFactory
{
private Dictionary<string, FlyWeight> mFlyWeightDict;
public FlyWeightFactory()
{
mFlyWeightDict = new Dictionary<string, FlyWeight>();
}
// 获取FlyWeight
public FlyWeight GetFlyWeight(string name)
{
//先检查是否已经存在所需享元
if (mFlyWeightDict.ContainsKey(name))
{
Debug.Log(name + ":已经存在字典中,直接返回");
return mFlyWeightDict[name];
}
FlyWeight FlyWeight = new ConcreteFlyWeight(name);
mFlyWeightDict.Add(name, FlyWeight);
Debug.Log(name + ":不存在字典中,加入字典再返回");
return FlyWeight;
}
public void ShowAllFlyWeights()
{
foreach (FlyWeight item in mFlyWeightDict.Values)
{
Debug.Log("FlyWeight:" + item.Outside);
}
}
}
我们使用字典来保存这些享元,在需要使用时要先判断共享属性工厂类的容器里是否有这种共享属性对象,有就返回这个共享属性的对象,没有就报出警告并将其添加进字典里进行管理。
result
我们成功让可共享的对象分离出来,让多个对象可以同时使用这个对象,从而避免减少了内存的销毁,尤其是在大量产生这种对象的时候,能有效的提高效率,与此同时我们也提高了系统的复杂度。
对象池也运用了享元模式的思想,将子弹对象存在容器里。这样就可以只产生少量子弹对象,而不必要重复创建删除子弹对象,减少了GC操作,做到了性能的优化
2021.8.16–19:41完成