MENU

《游戏设计模式》笔记

October 27, 2021 • 阅读: 1659 • 笔记&折腾

毋庸置疑,这是一本好书。记录一些在阅读本书时留下来的思考和笔记。

《游戏编程模式》电子版:游戏编程模式

设计模式

命令模式

将一个命令/操作/请求封装为一个单独的对象。将具体的操作封装到这些对象中,将对象的实例化视为单独的命令去执行。
这看起来类似于将对象的所有操作都封装到方法中,但是他们有区别。比如在游戏中不同的对象(玩家、敌人)都有move()方法,那我们可能会为每个具有move()方法的对象类添加move()方法的实现代码。

player.move(position);
enemy.move(position);

如果使用命令模式,则只需要使用一个move类的实例化对象,通过调用该实例化对象的具体方法来实现move()操作,此时只需要将被操作的对象当作参数传入。

mv.move(player,position);
mv.move(enemy,positon);

使用命令模式后,具体的操作/命令将不再是某个对象的子集,而是与该对象同级的“第一公民”。如果你不想你的游戏中对象过多,最好只封装那些常用的方法(比如移动,受伤等)。

命令撤销。

当我们将操作封装为对象时,是否可以在对象中添加undo()方法来实现撤销操作?当然可以,但是我认为应当谨慎加入撤销的操作,不仅仅是麻烦,这会要求你付出额外代码或内存来保存对象在每次操作之前的状态。

享元模式

当某个对象类将会被多次实例化且有相同的数据部分时,可以将类中的数据封装为父类用来共享。

你想为你的3D场景增加一片森林,大概有1000棵树。如果你想为每一个棵树都建立单独的对象以及模型,那么你一定是疯了!通常情况下一片森林中都是同一种树,比如樟树。他们的纹理,树皮、树枝、树叶等数据都是一样的,不同的只是他们的位置、高低、大小等。这时,我们可以将对象分割成两个部分,一个是能够共享的数据(纹理,树皮、树枝、树叶等),一个是决定不同对象特征的数据部分(位置、高低、大小等)。

class TreeData{
    private mesh_; //纹理
    private leaves_; //树叶
    ...
}
class Tree{
    private TreeData *treedata;
    
    public position; //位置
    public high; //高低
    ...
}

此时每个树的实例只需要有一个对共享数据类的引用,然后修改该树特有的特征数据,那么,他就是一颗独一无二的树。

虽然使用享元模式,最终你还是给1000棵树建立了1000个实例化对象(笑),但是!由于享用的存在,你节省了许多不必要的开支,这对客户机来说非常重要,因为也许你在使用最新的iMac进行游戏开发,这对你的电脑来说毫无压力,但是可能对2015年的 GTX750客户机来说,这只是一个破游戏!

观察者模式

对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

在设计时必须明确谁是观察者,谁是被观察者。观察者使用通知函数notify(),被观察者有接收函数receive()。这是一种一对多关系,被观察者并不是只有一个,它不在意被观察者的身份与数量,某件事件发生了,notify()发出通知,被观察者接收到了通知做出响应。即使此时没有被观察者,他也会发出通知。他只是一个喇叭,不管是在静音的太空还是嘈杂的闹市,他只负责发出通知。

当你设计了非常多的被观察者时,由于他们会一直等待消息通知,这似乎需要消耗比较多的资源,想想看,这里应该怎么做?

原型模式

非常常见的一种设计模式。将所有相似的种类视为一个原型,在原型的基础上进行拷贝衍生成不同的类。

在游戏设计中使用原型非常方便,比如“敌人”这个种类就可以当作一个原型,所有的敌人都有血量、移动、伤害、攻击等通用属性接口,在此原型的基础上进行复制并修改接口便可以设计出不同的敌人。比如给蝙蝠敌人加上飞行、重写它的移动接口,给魔法师重写攻击接口等等。

class Monster{
    move();
    attack();
}
class BatMan : Monster{
    move();
    attack();
    fly();
}

除了敌人以外,"收集物品"、“武器”、“技能”这些相似的类也可以使用原型模式进行设计。原型模式非常简单也很实用。

单例模式

保证一个类只有一个实例,并且提供了访问该实例的全局访问点。

我经常会使用单例模式,保证某些类我在全局范围内都能引用到,比如UI、文件系统、游戏状态。比如我在任何场景中想要暂停游戏,我只需要调用全局的游戏状态类的方法 gamestatus.pause()。

当我遇到某些困难时,我会优先使用单例模式,因为它看起来是万能的,但实际上他会让我变得懒惰,单例模式只是耍小聪明,太多的全局变量会让后期的代码变得异常耦合。

状态模式

简单来说就是对象跟随状态的改变而发生行为上的改变。

在游戏刚开始开发时,为求方便,通常根据输入而直接改变对象的行为:

if (input == jump_button){
    jump(); // 跳跃
}
if (input == crouch_button){
    crouch(); //下蹲
}
...

如果在角色跳跃时继续连按跳跃键,角色将会一直悬空,或者在下蹲时按下跳跃键,角色会蹲在空中。这显然不是我们想要的,简单的方法是给所有的行为都加上一个bool值用来控制他们的状态:

if (input == jump_button){
    if (!isJumping & !isCrouch){
        isJumping = true;
        jump(); // 跳跃
    }
}
if (input == crouch_button){
    if (!isCrouch & !isJumping){
        isCrouch = true;
        crouch(); //下蹲
    }
}
...

这便是最简单的状态模式。
但是,你的游戏角色不可能永远只有两个状态,在将来有了新需求,他会为他们增加站立、攻击、施法、跳斩等行为。这些行为与其他的行为看起来是互斥的,我们必须为每一个行为都设置独立的状态bool值。这时,你的对象每次只进行一种行为,行为间的转换主要靠状态的bool值。

state-flowchart

这是一个简单的有限状态机。通常情况下,状态模式都伴随着有限状态机,即使状态间的转换只是用的最简单的switch-case或者枚举类型,但是根据守序-中立-邪恶9宫图,它涉及状态之间的转换,好,那么它就是有限状态机!!!
在使用状态模式进行开发时,应当注意以下几点:

  • 输入部分。你的所有状态都是根据你的输入来进行转换的,确保每次的输入只涉及到一种状态,除非你使用队列。
  • 每个状态都都会有结束的时候,你的每次输入都将“杀死”现在的状态,唤醒另外一种状态。
  • 状态之间的关系。并不是所有的状态都能直接转换,每个状态应该由另一种状态转换而来。比如你无法在直接由跳跃状态转为下蹲状态,转换顺序应该为:跳跃-->站立-->下蹲。

在什么时候采用状态模式?

  • 你有个实体,它的行为基于一些内在状态。
  • 状态可以被严格地分割为相对较少的不相干项目。
  • 实体响应一系列输入或事件。

序列模式

双缓冲模式

用序列的操作模拟瞬间或者同时发生的事情。

定义缓冲类封装了缓冲:一段可改变的状态。 这个缓冲被增量地修改,但我们想要外部的代码将修改视为单一的原子操作。 为了实现这点,类保存了两个缓冲的实例:下一缓冲当前缓冲

当信息从缓冲区中读取,它总是读取当前的缓冲区。 当信息需要写到缓存,它总是在下一缓冲区上操作。 当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换, 这样新缓冲区就是公共可见的了。旧的缓冲区成为下一个重用的缓冲区。

这是那种你需要它时自然会想起的模式。 如果你有一个系统需要双缓冲,它可能有可见的错误(撕裂之类的)或者行为不正确。 但是,“当你需要时自然会想起”没提提供太多有效信息。 更加特殊地,以下情况都满足时,使用这个模式就很恰当:

  • 我们需要维护一些被增量修改的状态。
  • 在修改到一半的时候,状态可能会被外部请求。
  • 我们想要防止请求状态的外部代码知道内部的工作方式。
  • 我们想要读取状态,而且不想等着修改完成。

在某些情况下,游戏需要用到一个特定的场景,这个场景并不是不变的,它或许会一直更新。在它更新场景的某些部分时,这些部分会被清空。如果我们一直将该场景放置前台,那么,场景将会在更新时有一片区域是空白,如果更新很慢呢?那么它将长时间保持空白。对于这种情况我们便可以引入双缓冲。
在内存中进行一次场景的备份,展示在你面前的永远都是更新过的场景A,所有的更新操作都发生在场景B中,当更新操作完成,便将场景A与场景B进行swap()操作,或者将场景B中发生变化的局部场景加载到场景A中。

利用双缓冲,可以确保我们在使用的场景永远都保持最新状态,但这是有代价的,每次场景变化需要时间,swap()也需要时间,如果在某些时候,场景花在swap()上的时间超过了场景更新的时间,那么双缓冲就是失败的。并且,双缓冲会在内存中备份一个场景,这需要为它留有足够的内存,在某些内存受限的客户机上这可能会使游戏体验变得非常差劲。所以,请慎重引入双缓冲模式。

游戏循环模式

将游戏的进行和玩家的输入解耦,和处理器速度解耦。

游戏的循环非常重要,任何交互性的游戏或者游戏引擎都有自己的循环系统。游戏会一直处在循环的状态,即使你没有任何输入。它处理用户的输入,但是不会等待输入
一个游戏循环在游玩中不断运行。 每一次循环,它无阻塞地处理玩家输入更新游戏状态渲染游戏。 它追踪时间的消耗并控制游戏的速度。

unity有自带的循环系统,它使用update()进行更新,当没有输入时,它循环运行游戏场景、UI;有输入时,继续运行游戏场景、UI,顺便处理输入。

设计游戏的循环系统在某种程度上不亚于设计游戏引擎,除非现有的循环系统无法满足你,最好不要考虑去重写你的游戏循环系统。

更新方法

通过每次处理一帧的行为模拟一系列独立对象。

  • 每个游戏实体应该封装它自己的行为。
  • 游戏世界管理对象集合。 每个对象实现一个更新方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每一个对象。

更新方法适应以下情况:

  • 你的游戏有很多对象或系统需要同时运行。
  • 每个对象的行为都与其他的大部分独立。
  • 对象需要跟着时间进行模拟。

更新方法在Unity中非常直观,为需要用到更新的对象添加 update() 方法。unity 的 Update() 或者FixUpdate() 会为对象进行更新,将你需要更新的行为放置其中。


行为模式

字节码

将行为编码为虚拟机器上的指令,赋予其数据的灵活性。

指令集 定义了可执行的底层操作。 一系列的指令被编码为字节序列虚拟机 使用 中间值栈 依次执行这些指令。 通过组合指令,可以定义复杂的高层行为。

这个模式应当用在你有许多行为需要定义,而游戏实现语言因为如下原因不适用时:

  • 过于底层,繁琐易错。
  • 编译慢或者其他工具因素导致迭代缓慢。
  • 安全性依赖编程者。如果想保证行为不会破坏游戏,你需要将其与代码的其他部分隔开。

字节码比本地代码慢,所以不适合引擎的性能攸关的部分。

说实话,用字节码进行游戏设计有点难,我应该不会这么做。但它的收益很可观。

子类沙箱

用一系列由基类提供的操作定义子类中的行为。

基类定义抽象的沙箱方法和几个提供的操作。 将操作标为protected,表明它们只为子类所使用。 每个推导出的沙箱子类用提供的操作实现了沙箱函数。

子类沙箱模式是潜伏在代码库中简单常用的模式,哪怕是在游戏之外的地方亦有应用。 如果你有一个非虚的protected方法,你可能已经在用类似的东西了。 沙箱方法在以下情况适用:

  • 你有一个能推导很多子类的基类。
  • 基类可以提供子类需要的所有操作。
  • 在子类中有行为重复,你想要更容易地在它们间分享代码。
  • 你想要最小化子类和程序的其他部分的耦合。

使用子类沙箱会导致一些子类的耦合,但是比起它的收益,这是可以接受的。
但是需要考虑清楚,不要把所有的操作都加入基类中,比如某些操作只对两三个子类有收益,那么就不要把它们加到基类当中,要保证在基类中的操作能使大部分子类受益。
如果遇到了困难,考虑一下在基类的同级设计一个辅助类用来分担你的压力。

类型对象

创造一个类A来允许灵活地创造新“类型”,类A的每个实例都代表了不同的对象类型。

定义类型对象类和有类型的对象类。每个类型对象实例代表一种不同的逻辑类型。 每种有类型的对象保存对描述它类型的类型对象的引用

实例相关的数据被存储在有类型对象的实例中,被同种类分享的数据或者行为存储在类型对象中。 引用同一类型对象的对象将会像同一类型一样运作。 这让我们在一组相同的对象间分享行为和数据,就像子类让我们做的那样,但没有固定的硬编码子类集合。

类型对象的基本思想就是给基类一个品种类(breed类),而不是用一些子类继承自这个基类。所以我们在做种类区分的时候就可以只有两个类,怪物类monster和品种类breed,而不是monster,dragon,troll等一堆类。所以在此种情况下,游戏中的每个怪物都是怪物类的一个实例,而实例中的breed类包含了所有同种类型怪物共享的信息。

在任何你需要定义不同“种”事物,但是语言自身的类型系统过于僵硬的时候使用该模式。尤其是下面两者之一成立时:

  • 你不知道你后面还需要什么类型。(举个例子,如果你的游戏需要支持资料包,而资料包有新的怪物品种呢?)
  • 想不改变代码或者重新编译就能修改或添加新类型。

解耦模式

组件模式

允许单一的实体跨越多个领域而不会导致这些领域彼此耦合。

单一实体跨越了多个领域。为了保持领域之间相互分离,将每部分代码放入各自的组件类中。 实体被简化为组件的容器

组件通常在定义游戏实体的核心部分中使用,但它们在其他地方也有用。 这个模式应用在在如下情况中:

  • 有一个涉及了多个领域的类,而你想保持这些领域互相隔离。
  • 一个类正在变大而且越来越难以使用。
  • 想要能定义一系列分享不同能力的类,但是使用继承无法让你精确选取要重用的部分。

如果你接触过Unity,那么你一定会想到Unity 中的 GameObject 类。Unity 中的 GameObject 类作为容器,将各种不同领域的的类耦合在了一起,比如位置、动画、碰撞器、刚体、图像等等。事实上,Unity核心架构中GameObject类完全根据此模式来进行设计。

事件队列

解耦发出消息或事件的时间和处理它的时间。

事件队列在队列中按先入先出的顺序存储一系列通知或请求。 发送通知时,将请求放入队列并返回。 处理请求的系统之后稍晚从队列中获取请求并处理。 这解耦了发送者和接收者,既静态及时

使用场景:

  • 如果你只是想解耦接收者和发送者,像观察者模式和命令模式都可以用较小的复杂度进行处理。 在解耦某些需要及时处理的东西时使用队列。
  • 用推和拉来考虑。 有一块代码A需要另一块代码B去做些事情。 对A自然的处理方式是将请求推给B。同时,对B自然的处理方式是在B方便时将请求拉入。 当一端有推模型另一端有拉模型,你需要在它们之间设置缓存。 这就是队列比简单的解耦模式多提供的部分。
  • 队列给了代码对拉取的控制权——接收者可以延迟处理,合并或者忽视请求。 但队列做这些事是通过将控制权从发送者那里拿走完成的。 发送者能做的就是向队列发送请求然后祈祷。 当发送者需要回复时,队列不是好的选择。

慎重考虑使用事件队列。最简单的一个理由就是如果你的发出消息或者处理消息哪一环节出现了停顿,那么后续所有的事件都将会被推慢。以及必须设计一个优秀的反馈系统。

服务定位器

提供服务的全局接入点,避免使用者和实现服务的具体类耦合。

服务 类定义了一堆操作的抽象接口。 具体的服务提供者实现这个接口。 分离的服务定位器提供了通过查询获取服务的方法,同时隐藏了服务提供者的具体细节和定位它的过程。

我想把它简单地理解为在使用者和服务者之间做一个中继,用来阻断使用者直接向服务者发出请求,可以简单地起一个保护作用。这不仅仅是保护服务者,也是保护使用者。很多时候服务者和使用者都不想被人知道并定位它。当使用者有需求时,将需求发送给服务类,由服务类来调用具体提供服务的接口。

使用服务定位器并不会比单例模式好很多,并且它会导致部分的代码耦合,增加工作量。


优化模式

未看。

Last Modified: October 30, 2021
Leave a Comment

已有 1 条评论
  1. 英子 英子

    好文