0%

游戏中的代码设计模式总结

参考Game Programming Patterns完成的设计模式总结笔记。

命令模式

命令模式本质上希望把一个硬编码的函数解耦成可配置的函数命令,尽可能将功能触发具体功能分离。

如下有一个玩家对象的输入控制功能,其中按下X键对应jump动作。如果我们想要将X键绑定到fireGun功能,那还得去代码里修改对应的硬编码if语句,然后重新编译,重新链接。

1
2
3
4
5
6
7
void InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) jump();
else if (isPressed(BUTTON_Y)) fireGun();
else if (isPressed(BUTTON_A)) swapWeapon();
else if (isPressed(BUTTON_B)) lurchIneffectively();
}

动态绑定功能

那么我们来将功能触发具体功能分离吧。既然想要让X键对应动态的功能,那么可以用一个 多态/函数指针/委托 来实现。命令模式即使用多态方式,如下将jump,fireGun之类的功能抽取成一个Command基类的四个派生类,这样就可以通过Command对象的多态来实现动态绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//基类
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
};
//功能类
class JumpCommand : public Command
{
public:
virtual void execute() { jump(); }
};
//功能类
class FireCommand : public Command
{
public:
virtual void execute() { fireGun(); }
};

接下来我们就可以为按键绑定功能,如buttonX_=FireCommand,然后在按键触发时动态执行虚函数就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//处理类
class InputHandler
{
public:
void handleInput();
private:
Command* buttonX_;
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
};

void InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) buttonX_->execute();
else if (isPressed(BUTTON_Y)) buttonY_->execute();
else if (isPressed(BUTTON_A)) buttonA_->execute();
else if (isPressed(BUTTON_B)) buttonB_->execute();
}

这样我们在游戏流程中随时可以根据需要来替换按键对应的功能。当然,动态绑定还可以带来其他的便利性,如我们可以把Command.execute() 改成 Command.execute(GameObject actor),这样甚至能改变操作对象!

撤销和重做

如上所述,功能的动态绑定可以通过 多态/函数指针/委托 三种方式来实现,但为什么命令模式选择了Command类与多态呢?实现成类,一方面可以便于添加扩展功能,另一方面类拥有状态信息,可以针对命令做一些维护操作,比如撤销和重做。

例如我们有一个Move Command,在execute()中我们会移动角色,那么我们自然可以在Move中保留下角色移动之前的位置,然后构造一个Undo()操作,来将原来的位置赋值为角色,完成撤销。

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
class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
xBefore_(0),
yBefore_(0),
x_(x),
y_(y)
{}

virtual void execute()
{
// 保存移动之前的位置
// 这样之后可以复原。

xBefore_ = unit_->x();
yBefore_ = unit_->y();

unit_->moveTo(x_, y_);
}

virtual void undo()
{
unit_->moveTo(xBefore_, yBefore_);
}

private:
Unit* unit_;
int xBefore_, yBefore_;
int x_, y_;
};

享元模式

享元模式思路比较简单,就是把大量实例共享的那一部分数据单独抽取出来,成为一个共享数据ShareData类,并让其他实例类引用它即可。这样可以节省大量的内存,并且在GPU渲染大量同类实例时也能节省性能。

GPU渲染大量的树木时,可以把共享数据如网格、纹理等抽取出来引用,然后需要实例单独渲染的只有不同的位置了。

观察者模式

观察者模式算是一种十分常用的模式了,即把数据的改动变化做成一个 事件,然后由 发布者发布该事件,订阅者则可以提前订阅好相关事件处理程序。每当数据改动时事件会被发布者Invoke,订阅者的事件处理程序即可响应执行。这在数据逻辑和UI分离的常规MVC开发模式下很常见。

事件的订阅执行并没有想象中的那么慢:实际上在C#中事件就是由委托类型(类似函数指针)完成的。订阅者用事件处理程序去订阅事件通知,即相当于在事件的数据结构里保存这个事件处理程序的函数指针,每次事件的Invoke无非是遍历事件保存的函数指针数组依次执行而已。不过需要小心的一点是事件发布者可能会被订阅者阻塞,例如某个前端事件处理程序去访问网络...因此小心UI线程相关!

对象销毁和垃圾处理:如上订阅者订阅事件时,发布者的内部其实是在引用订阅者对象。假如我们此时主动销毁了订阅者呢?那么事件Invoke时就会访问一个错误的野指针。所以订阅者需要注意在自己被释放时主动取消订阅。这个问题对垃圾回收语言有没有影响呢?C#中不需要主动销毁对象,因此倒不至于会得到一个野指针。但是由于发布者手握着一个引用,垃圾回收器只能认为订阅者对象是有用的,因此订阅者会一直存在于内存中,最终形成一种内存泄漏。更为严重的情况是,我们可能以为订阅者已经被回收了,之后又尝试加载一个新的订阅者示例,进行新的订阅,最终你的内存里可能有一万个订阅者!

例如玩家在离开战斗场景时,我们可能会想要卸载战斗UI,但实际上由于事件订阅的存在,UI被隐藏后并没有被回收。而下一次玩家进入战斗场景时会创建新的战斗UI,现在,同样的UI就有两个了!之后还会有3个,4个....

单例模式

单例模式:一方面限定一个类只有一个示例,另一方面为这个实例提供了全局访问方式,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton
{
public:
static Singleton& instance()
{
// 惰性初始化
if (instance_ == NULL)
instance_ = new Singleton();
return *instance_;
}

private:
Singleton() {}
static Singleton* instance_;
};

可以看出单例模式实现十分简单,并且带来的好处显而易见:

  • 全局直接访问:所有调用对象都无需存储相关引用和指针。99.9%的单例模式都是为了创建全局访问的Manager类。
  • 实例数量限定:避免不必要的重复。
  • 惰性初始化实例:如果没有发生访问,则Singleton类永远不会创建实例。就算程序后期发生访问,那也是在运行时才实例化,不会给前期带来不必要的垃圾。
  • 可动态实例化:这种全局访问的Manager类另一种类似的实现方法是C#的静态类,但是静态类很大的一个缺点就是初始化数据也只能是静态的。例如我不能写一个字段static int a=ReadFromFile();,而单例模式就没有这种动态依赖的困扰,想怎么初始化怎么初始化。
  • 拥有类的完整特性,继承和多态:这样我们可以把一个单例基类扩展成多种泛化单例,分别添加不同的功能,之后再按需创建对应的一种。

每个了解单例模式的人都能很快的上手使用,并且觉得这个模式真是又快又好。但是难道把所有类都往单例上怼就行了吗?就像从我们学编程开始就被教导不要乱用全局变量一样,比起学会使用单例,我们更需要关注有没有滥用全局单例

  • 全局共享 = 类型安全降低:任何一个对单例的调用都可能对全局的数据进行更改。如果知道是哪个调用发生了错误的修改还好办,假如不知道是哪个调用呢?你开开心心的在单例的数据段打上断点,却发现这个单例会有几百次的相关调用,就算是007也很难从中找出错误的那一个。
  • 全局访问 = 失去访问权限控制:增加耦合性。随便一个类,只要它想,就能在里面加上一笔单例的调用,导致代码的引用链简直乱七八糟,例如:逻辑功能 ref 音频单例 ref 物理单例 ref 渲染单例 ref 逻辑单例。有可能程序员只想做一个走路带声音的效果,但这段代码却把整个引擎系统都逛了一遍。之后维护的时候动了物理单例,可能音频单例就出错了,动了渲染单例,可能逻辑就跑出BUG了,失去了访问权限的控制,程序员不可能自觉的降低代码耦合。
  • 实例数量限定 = 等等,我们真的需要单例的"单"吗?:正如上面所述,99.9%的单例模式都是为了全局访问,但我们往往就顺手给这个全局访问点加了个"限定唯一"的特性。我们真的需要唯一的访问点吗?好好想想。另一方面,当我们需要限定实例数量的时候,我们需要为它提供全局访问吗?换句话说,"全局"和"单例"有必要同时出现吗?
  • 惰性初始化 = 惰性加载还是预加载:在游戏开发中,预加载十分常见,有时候我们并不希望需要的时候再即时加载一个东西,而是希望提前在某个宽裕的点加载好。

总而言之,比起把不方便访问的类一股脑的塞给单例模式,更优先想想是否需要全局变量,是否需要数量控制,如果是小范围的访问能不能用成员变量或者传递参数的方式解决?

状态模式

让我们先从状态机的应用灵感讲起。在游戏中我们肯定会有一个角色控制器,其要控制玩家的 跳跃、卧倒、站立操作。当然,我们希望不能在空中无限跳跃,并且不能在空中卧倒等等...因此我们要在控制器中加入许多的判断条件检查是否允许操作:

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
void Player::handleInput(Input input)
{
if (input == PRESS_B)
{
if (!isJumping_ && !isDucking_)//跳跃之前需要检查 跳跃状态、卧倒状态
{
// 跳跃……
}
}
else if (input == PRESS_DOWN)
{
if (!isJumping_)//卧倒之前需要检查 卧倒状态
{
isDucking_ = true;
setGraphics(IMAGE_DUCK);
}
}
else if (input == RELEASE_DOWN)
{
if (isDucking_)//站立之前需要检查卧倒
{
isDucking_ = false;
setGraphics(IMAGE_STAND);
}
}
}

有限状态机的使用

可以看到光是 跳跃、卧倒、站立 这三个操作之间的互相耦合就已经非常难看了,如果还要加入攻击操作、道具操作、闪避操作等...可想而知代码中的if会爆炸式的增长,并且也根本理不清其中的避让关系。这时候就需要我们好好把所有的限定状态理清楚,迎接有限状态机的救援 (这里不细谈有限状态机的基本定义):

有限状态机

这个时候我们需要顾虑的事情就变了:

  • 之前的担忧:进入下一个操作前,需要检查不能从什么操作转移过来 (禁止父操作),比如 跳跃 之前不能在 攻击、下蹲、喝药...。
  • 现在的考量:在当前操作状态中,需要检查能去往什么操作状态 (可行子操作)。比如 跳跃 能去往 跳斩。

通常情况下对于一个操作状态来说,它的 可行子操作 肯定比 禁止父操作 要多得多。如图中 跳斩 没有去往的操作状态,但是不能前往 跳斩 的操作有 蹲下、站立,而且随着状态的增加,禁止父操作会O(N)级别增长,而可行子操作往往变化不大。因此,在通过状态机转换思考方式之后,我们需要考虑的条件明显简化了很多。那么我们怎么来实现这个状态机呢?首先需要定义所有的状态

1
2
3
4
5
6
7
enum State
{
STATE_STANDING,
STATE_JUMPING,
STATE_DUCKING,
STATE_DIVING
};

然后根据我们的状态图,可以定义出每个状态的状态转移操作,朴素点的话可以用if来实现条件判断:

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
33
34
void Player::handleInput(Input input)
{
switch (state_)
{
case STATE_STANDING:
if (input == PRESS_B)
{
state_ = STATE_JUMPING;
setGraphics(jump_image);
}
else if (input == PRESS_DOWN)
{
state_ = STATE_DUCKING;
setGraphics(duck_image);
}
break;

case STATE_JUMPING:
if (input == PRESS_DOWN)
{
state_ = STATE_DIVING;
setGraphics(dive_image);
}
break;

case STATE_DUCKING:
if (input == RELEASE_DOWN)
{
state_ = STATE_STANDING;
setGraphics(stand_image);
}
break;
}
}

这已经完成了状态机的模型,但还差了点意思。这里每个状态只是用一个枚举以及switch的分支来决定,但其实所有的状态还是在共享着Player的所有数据段。因此假如我们要为每个运动设置一个持续时间,那么我们就要往Player里塞4个time数据。并且对于其中一个状态来说,有3个time数据是和它没任何关系的。为了优化这一点,我们可以很自然的想到用派生类来表示状态

首先定义一个状态基类,其定义了每个状态的基本操作,例如进入状态时操作、退出状态时操作、帧更新、接受输入:

1
2
3
4
5
6
7
8
9
class PlayerState
{
public:
virtual ~PlayerState() {}
virtual void enter(Player& player){}
virtual void exit(Player& player){}
virtual void handleInput(Player& player, Input input) {}
virtual void update(Player& player) {}
};

然后定义具体的状态类:

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
class DuckingState : public PlayerState
{
public:
DuckingState(): chargeTime_(0)
{}

virtual void enter(Player& player)
{
player.setGraphics(duck_image)
}

//返回值用于告诉父级是否需要切换状态
virtual PlayerState* handleInput(Player& player, Input input)
{
if (input == RELEASE_DOWN)
{
return new StandState();//返回新状态用于切换
}
return NULL;//返回空则说明没有新状态,不用切换
}

virtual void update(Player& player)
{
chargeTime_++;
if (chargeTime_ > MAX_CHARGE)
{
player.superBomb();
}
}
private:
int chargeTime_;
};

这时候每个状态会管理好自己的转移操作,并且我们还可以为每个状态单独添加其专属的数据字段,如chargeTime_。并且在Player中对状态的处理也可以利用多态来简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Player
{
public:
virtual void handleInput(Input input)
{
PlayerState* new_state=state->handleInput(*this, input);
if(new_state!=NULL)
{
delete state;
state=new_state;
state->enter();
}
}

virtual void update()
{
state->update(*this);
}

// 其他方法……
private:
PlayerState* state;
};

有限状态机已经很棒了,它帮我们把复杂的判断条件全部梳理了出来,整个系统变得从所未有的干净整洁!但是如果你用过Unity的动画机就知道,仅仅是一个的有限状态机只能表示一张连通的状态图,但实际中我们还会有很多不相关的状态,并不是所有图都是连通图不是吗?

多个并行的状态机

例如我们现在已经掌握了Player的所有角色动作,我们还想要增加一个 持枪 的状态,持枪和之前的 跳跃、蹲下、站立都没关系,无论在什么姿势都能在Player的手上加上这把枪。那我们该怎么判断呢?如果只用一个状态机,那我们难道要直接增加一倍的状态:持枪跳跃、持枪蹲下、持枪站立?这样显然及其低效且繁琐。因此我们可以为持枪创建一个独立的状态机,即让Player拥有两个并行状态判断:

1
2
3
4
5
6
7
8
9
10
11
class Player
{
public:
void Heroine::handleInput(Input input)
{
action_state->handleInput(*this, input);
equip_state->handleInput(*this, input);
}
private:
PlayerState* action_state;
PlayerState* equip_state;

这时候在action_state里开个小的if分支判断equip_state的状态接口。例如equip_state处于不持枪时则保持jump_image, 持枪则改为jump_and_gun_image。当然这样又回到了使用状态机之前的if地狱,只不过规模小了一点,可以称为if地牢。

分层状态机

再来考虑复用/继承状态机的事情。例如在Player站立、跳跃时,我们要允许其进行开火操作:生成子弹、生成粒子效果、生成音效...我们显然不能在Player中实现Fire()操作,因为这样那些禁止开火的状态也能调用这个功能了,因此我们得在允许开火的状态内实现。在最粗暴的时候,我们会为站立、跳跃状态都粘贴Fire()操作,但这样显然不利于维护,我们希望Fire()的代码能够在多个状态机之间复用。因此按照我们平常面向对象的思路,我们可以抽取出一个 允许开火 的基类状态,把Fire()实现在里面,再让站立、跳跃的状态继承它。这就是分层状态机的结构。

下推状态机

下推状态机即为状态机加上历史记录功能。例如我们允许Player进行喝药补血,它可以蹲着喝也可以站着喝(喝药是一个持续性的动作,因此我们把它当做状态而不是操作),但是我们不希望它蹲着喝完就自动站起来了,或者站着喝完就自动蹲下了。我们需要记录其在进入喝药状态之前的状态,显然我们需要维护一个状态栈来表示状态的历史记录。这就是下推状态机。

工厂模式