凌驾于时间的逻辑

作者:indienova
2016-04-01
14 28 3

引言

Jean Simonet 来自Bethesda,是一名经验丰富的系统与 AI 程序员。在这篇文章中,他介绍了基于协程来优化有限状态机写法的宝贵经验。本文已经过授权,如需转载请注明原文和译文来源。

LogicOverTime-Title

我从事游戏行业已有许多年头,主要是在贝赛达斯工作,在那里我参与过上古卷轴4:湮灭,辐射,上古卷轴5:天际等游戏的开发。作为程序员,我负责编写过的代码可谓包罗万象:既包括简单的游戏玩法和 AI 逻辑,也包括一些复杂的导航系统,动画图像和行为树实现。

这些年里,总有一个简单的问题是我始终要重复面对的:对那些随着时间推移而变化的实体行为进行管理。

跨帧

这个任务的核心其实相当直观:我需要某个实体跨帧执行一到多个动作,并且还能智能处理过程中的各种行为。比方说,某个在玩家拾取前会一直闪烁的能力升级器,又比如某只当玩家进入指定半径内就会做出反应的怪物。简而言之,就是你经常要处理的那些游戏逻辑和简单的AI。

作为游戏程序员,我们老是会写这类代码,以至于我们压根不会真地再重新认真思考这类问题。我们甚至专门订立了一套完整成熟的方法来解决它们:即我们熟知的有限状态机

326px-CPT-FSM-abcd

问题并不在于有限状态机本身,它的确是应对这类问题的灵丹妙药-但我们需要了解具体应当如何来实现一个好的有限状态机。

我已经在自己编写的系统中引入过很多次有限状态机模型的变种,但是,在参照过其他一些设计方案后,我逐渐明确地意识到我们的方案还存在一些根本上的缺陷:它们总是会导致一些错误,要么违反直觉,要么过度设计,尤其是当系统变得越发复杂的时候。

这可不是那些和程序库,底层代码以及要求许多数学知识的算法相关的问题。我们都熟知最佳实践和设计模式,也将它们运用得相当纯熟。

然而,当我们着手处理跨帧的状态数据时,我们还是把一切都搞得一团糟。我们总是会突然发现某个局部变量需要成为某个类的成员变量。看似简单的条件判断语句就能搞定的事情不得不使用令人困惑的查表法。而在意识到这些问题之前我们就已经声明了状态对象,定义了状态切换代码,并且为一些理论上只需要寥寥数行代码就能完成的功能引入了有限状态机的整套框架。

“没说你,说我自己呢。”

之所以会卷入这堆麻烦事儿,困在我们提到的那些根本性缺陷里不可自拔,主要还是因为我们所使用的编程语言(C++,C# 等等)存在限制。游戏经常会需要中断某个逻辑流程,干点诸如渲染之类的别的事儿,再继续接着完成逻辑流程。这些语言并不是非常擅长这类事情。实际上,由于它们糟糕的表现,在处理比较繁重的计算需求时,我们已经在使用一些操作系统级别上的变通方法:即所谓的线程(或者纤程)。

但线程实在是一个相当笨拙的工具。以 Windows 系统为例,每一个独立的线程都会要求占用起码 1MB 的物理内存,并且会对游戏的启动退出时的性能表现造成显著影响。没人会考虑在 RTS 游戏中为每一个作战单元分配一个线程!

因此,作为替代方案,我们千方百计地将状态数据跨帧传递,并且伪造我们希望能出现的未被中断的程序逻辑。于是,我们会像下面这样结束这部分代码:

StateStuff

但是,希望的曙光早已出现,过去十年里,我们使用的游戏编程语言已经取得了长足的进步。比方说,已经多了像协程(Coroutines),延续(Continuations),闭包(Closures)这样的新武器。接下来,我将为大家展示如何利用这些新武器来编写更加简洁,直观,健壮的游戏代码。

生成器(Yield)返回值

我们先从一个例子开始。下面是同一实体代码的两个版本:这是一个会自动锁定目标的炮塔,它每过十秒会进行一轮射击,冷却5秒钟后再搜寻下一个目标。左侧是用惯常的技术-切换状态变量-来编写的版本,右侧则利用了一些新的语言特性。

注意这些代码是用 C# 编写的,如果换用 C++ 需要少许调整。

Side-by-side-13

点开这里可以看到高清大图。

如果用过 Unity,你可能会相对更熟悉这些 yield 声明,这些代码也确实是基于 Unity 协程的一些语言特性编写的。它能够让你编写一些返回一个值的方法,暂时挂起当前函数,在这些方法被再次调用并返回其他值时再恢复它。这就是所谓的协程。有时也被称为生成器(generators),在C#中,则使用术语迭代器(iterators)。

这是什么魔法呢?答案是,我们借助了编译器的威力!今后我可能会另行撰文来解释协程的内部机制,但这篇文章里,我们先来专注于如何应用它们。

无状态

Implicit-State

我们再来看一眼刚刚的示例代码,你会留意到使用协程编写的炮塔不包含任何成员变量。那是因为在使用协程的时候,所有的状态数据都作为局部变量存放在需要使用它们的方法之中,或者直接作为参数传递。

我前文已经提过,编译器暗地里会做许多工作,这里它最终会为我们生成持久的状态数据。它所采用的方式既透明又能保证语义正确。这简直太棒了,因为即便某些数据并非真地存储在内存的本地栈上,我们也完全可以把这些变量当成局部变量使用。

换言之,状态数据会隐式生成!实际上,整个有限状态机都会隐式地生成。

“外头很危险”

隐式生成的另一个显著优点是可以让我们的状态数据保持在局部作用域内,防止了意外误用的情况发生。

ShotTimer

在老版本的炮塔实体代码中,变量 _ShotTimer 是一个私有成员变量。因此它无法在类以外被修改,这对实际使用它的内部方法来说非常不清晰。更重要的是,很难弄清楚为了实现功能哪一个新方法应该可以修改它!

然而,在应用协程特性编写的版本中,同样用来存储射击间隔时间的数据本地存放于 FireAtTarget() 协程中。根本没可能会把它和其他代码混到一块儿去,因为其他地方根本就不会知道它的存在!

WaitForShot

通过协程,可以尽可能将变量限制在最小的作用域内,并且能够非常方便按需通过快速的声明或传参将其提升至更大的作用域中。这能够极大程度地避免意外发生。基本的规则是,你希望能暴露你的某个数据,而且不需要老得记着需要隐藏它。

尽可能使用局部作用域还能让代码变得更容易阅读。比方说我想在炮塔开始搜寻目标的时候加上一个粒子效果,在锁定目标后再去掉它。我会像下面这样写:

Scope

我声明和使用粒子效果变量的地方就能够为我提供我需要知道的大部分信息。这正是多数程序员都不喜欢一次声明所有变量(在 C89 中我们不得不那样做)的原因,而 C++ 的 RAII 也采取了相同的理念。

协程与双枪老太婆

你可能会注意到下面这行的奇怪写法:

Concurrence

这里会发生如下的事情:

  • TrackTarget() 和 FireAttarget() 本身都是协程, 并不是单纯的方法。
  • Concurrent() 是一种用于特殊对象的工具方法,协程框架会将代码理解为:
    • 并行地(通过 time slice 技术)执行作为参数传入的两个协程;
    • 当出现错误时尽快中止程序;
    • 出现错误时将其返回给当前的协程。

作为对比,示例代码中的 yield return null 则会被理解为:在下一帧继续执行当前的协程。

你可以想象,concurrent 可以成为相当强悍的工具。能够并行地生成和同步协程将改变你解决问题的思路,新的思路实际上更加地接近我们思考复杂行为的方式。

在以后的文章中我会更加全面地讲解 concurrent 配合整套代码框架的使用。我也会讲一些关于行为树(behavior trees)相关的知识。你们会见识到协程,延续和闭包组合起来能够发挥多么巨大的威力。

至于现下,各位读者如有兴趣,不妨可以先看看这两个对 C++14/17 中的协程做介绍的访谈节目(译者按,访谈皆为 youtube 视频):

原文链接

Logic over Time 查看

近期点赞的会员

 分享这篇文章

indienova 

indienova - 独立精神 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. eastecho 2016-04-01

    高深!

  2. ayame9joe 2016-04-02

    慢慢学习!……

  3. Khaoz_Fang 2016-04-03

    写服务器的时候用过c++协程,这个东西还是有点危险的。。而且独立的堆栈会占用很多内存

您需要登录或者注册后才能发表评论

登录/注册