本文就数种重要的Gameplay框架及插件,简述它们的原理,介绍这些Gameplay框架的适用场合,并进行对比。
本文假设读者有一定的游戏开发经验、Unity开发经验。
本文会写得比较随性和啰嗦。
Wikipedia:
Gameplay is the pattern defined through the game rules.
Gameplay,游戏性、玩法、游戏规则。
第一次听到Gameplay这英文单词,是大学毕业后到老东家上海育碧上班第一天。“之后你的职位是Gameplay programmer”,HR大叔对我说。这对一个刚刚毕业的、目光狭窄的、笔试靠写Shader进公司的、认为游戏等同于Rendering的、当时的我,是一种打击。我甚至内心开始形成鄙视链开始鄙视Gameplay,还幼稚地在公司电脑屏幕贴了一张小纸条安慰鼓励自己:
“Gameplay programmer in office, Rendering programmer at home.”
即在公司写写Gameplay、回家后研究Shader。好傻好可爱。
现在回头看,有点后悔当时没多花时间去参透一下前公司的Gameplay框架、应用代码。因为离开前东家后也断续地进行Gameplay开发,但都有种蛮荒时代没有火种摸石头过河地开发的感觉,缺少经验和积累。
做游戏还是玩游戏,Gameplay都是最最最重要的因素之一。
玩家开始玩一款游戏的原因是多样的,表现、心流、炫耀、交友,但其中最有可能的是:好玩。
玩家停止玩一款游戏的原因也是多样的,难度、重复、劳累、孤独,但其中最有可能的是:乏味。
为了让我们的游戏不乏味,我们必须持续添加内容、更新规则,让玩家持续地感受到新意和有趣。
但项目组的人员是有限的、工作时间哪怕加班也是有限的、玩家的耐心也是有限的,如何能让项目组在有限资源的情况下,更好更快地进行游戏Gameplay迭代更新,是Gameplay框架的一大责任。
(另,可能一般不会太关注到的点是,我们也不能过度更改我们的游戏。一个游戏当前玩家是已经认可之前版本玩法设定的、受之前版本重重过滤后留下的玩家,如果玩家手上的版本本来是个RAC,我们下一个版本把它改成RTS,那玩家肯定都流失了。比如笔者之前负责过的一款游戏,个人觉得其2.0版本因为对战斗外体验更改过大,是造成2.0版本上线后数据滑落的重要原因之一。)
开始实现各种各样Gameplay时,我们常会编写符合需求,却相对更hardcode的Gameplay代码。
这做法有一定好处,其在时间紧急的情况下,能在初期就立刻见成效。
随着时间推演,Gameplay需求越来越多、越来越复杂、越来越和自己之前所想不一样的时候,这些之前hardcode的代码就越来越难以维护。
此时我们需要重构,需要针对这些各种各样的Gameplay需求,进行归纳总结。
(换句话说,上述这种更hardcode的Gameplay代码还有一个好处:其的确能让我们更早地了解细节,更早地知道自己为何重构、如何重构,甚至给重构提供集成测试用例。)
世界万物都可被归纳、被总结。
我们不能拒绝归纳总结,否则解决一个问题后、再出现类似问题我们又得从零开始苦思冥想。归纳总结可以帮助人去理解并记住结论,让人有可能举一反三。
但过分的归纳总结是抽象、甚至可能是无用的、不严谨的。不存在万金油。(“ToE”也尚未被证实。:P)
框架也是。
框架是必须的,为了更好地提供服务解决某一类问题,我们搭建底层框架。
但从我们写框架的第一行代码开始,给它带来功能的同时,也给它带来了限制。
即,没有万能的框架、只有适用的框架。
在游戏行业中,根据前人的实践、思考,已归纳总结出不错的几种重要Gameplay框架。
本文将讨论几种Gameplay框架,讨论它们是什么、它们之间的联系和区别、它们各自的适用场合。它们是:
并非说以上框架能满足一切Gameplay,但它们组合在一起,相信已能满足颇多需求。
这些框架是实用的。本文之所以会提到这几个框架,并非生硬地把它们堆砌在一起。恰恰相反,而是因为作者本人在游戏开发中遇到了实际问题,思考后发现,“这不是恰好可以用这种Gameplay框架来解决这个问题吗?”,通过实验和实践,才体会到这些框架的实用价值。
之所以把实体组件系统(Entity-Component-System,以下简称ECS)放在最前面,是因为它是最最最重要的、同时也是我们最熟悉的、可能也是我们最容易忽略的。
ECS不复杂,本人亦曾2度写过ECS,分别是Flash游戏《弹道轨迹(TNT)》和一个开发中的Unity帧同步游戏。如果我必须做出N选一,我会放弃其他所有Gameplay框架而选择保留ECS。
另,从《Game Engine Architecture》将ECS这个话题收编于其Runtime Gameplay Foundation Systems一章,重点着墨介绍,也能证明其与Gameplay的密切关系。
ECS最核心的功能很简单:将传统继承的is-a换成了has-a,将Component保存于Entity的一个容器中,Entity提供API进行Component的查找访问。
因为针对任何一个事物进行有限的功能拆分必然是不完整的,选取任意一个维度将其作为基类,都是不那么严谨的。所以,将这些功能有限拆分后,与其不精确地必须选取一个作为基类,倒不如把它们公平地作为组件,公平地处于Entity里。
ECS能让我们更好地分解复杂的问题、整理复杂的关系。
狭义的ECS只包括上述这个功能,但一般,广义的ECS也会被修改成拥有以下几项重要功能。
ECS还可以提供API,进行Entity、Component的生存期管理,以及生存期相关事件的回调。
生存期以Unity的术语为例,一般指的是:
实现生存期的重难点在于:
ms_gameObjectsWillStart
列表实现。ms_gameObjectsWillStart
列表和ms_gameObjectsWillDestroy
队列实现。Update()
等函数)让用户有权限规定某些自定义的Component是否接受Update。Entity之间可以通信、Component之间也可以通信。通信的方式可以是多样的,包括:
GameObject.SendMessage()
)GameObject.Find()
、GameObject.GetComponent()
)GetProperty()
、SetProperty()
),但Unity并无此设计Entity之间可以有父子从属关系,从而进一步拆分功能。
比如人是一个Entity,它有Human这个Component;如果游戏需要重点关心心脏及其跳动次数,让Human提供GetHeartPumpCount()已不太合适,则可把心脏也作为一个Entity,作为人Entity的子Entity,同时心脏Entity有Heart这个Component,提供Heart.GetPumpCount()接口。
但Unity的实现中,并不将此功能归于GameObject,而是归于Transform。这样子有其好处,即进行Transform世界空间坐标运算时,仅仅关心Transform这个组件本身就好了。但坏处是,为了表达父子层级关系,必须引入Transform、居然就被迫引入Position、Rotaiton、Scale这些可能没用的信息了。
有一些重要的、通用的属性,也直接定义在Entity中,比如唯一ID。
Unity的GameObject,还有供(物理、渲染)引擎内部使用的Layer属性,供Gameplay使用的Tag属性。
从上面的例子可以看出,ECS的功能是如此基础和重要,所以才说是Gameplay的必备要素。
上面提到的Gameplay框架及插件都有共同的一点:它们都可以以Node-based Visual Scripting的形式存在。
可能有人对Visual Scripting反感,直觉觉得它们的性能是低效的。Visual Scripting的Editor的UI复杂程度,是造成这种偏见的主要原因,但Editor的复杂度和它的Runtime运行性能完全不相关。理论上,一个语言的Front-end也可实现成Visual Scripting。比如,在《Game Programming Patterns》的Bytecode一章,如果为游戏开发一门语言,作者的确建议使用Visual Scripting作为Bytecode的一环,而并非使用文本文件,因为Visual Scripting中用户的每一个操作都是分开的,其机制忽略用户的每一个非法操作,但文本编程不同,用户是可以输入所有代码了之后才交给编译器编译,这将大幅提升实现编译器错误检测、错误提示的难度。
至于Node-based,其思想就是封装和组合。
我们可以合理地考虑重用性,将功能拆分为非常通用、非常细小的Node,作为一个又一个Node。但这样有可能会造成Node过多,造成浏览、编写时的麻烦。
我们可以针对比较重要的一段逻辑进行归纳,将本由多个Node才能实现的重要逻辑,重新以1个Node的形式呈现。
这事实上是个何时进行重构的问题,也是个提取共性、保留异性的思想。
各个Node是相对独立解耦的,但各个Node有是有可能需要数据交互的。往往通过在主体中添加一个Blackboard(黑板)和SharedValue,来让这些Node进行数据交互。
以上图行为树作为Blackboard的例子。它实现的需求是
FindLocalUserActor
节点)ActorMoveToTargetUntilDistance
节点)FunActorVKey
节点)留意到,Blackboard定义了TargetTransform
的一个ShanredValue。
我们再观察FindLocalUserActor
节点和ActorMoveToTargetUntilDistance
节点:
从而,FindLocalUserActor
节点找到的目标Transform,成功地通过Blackboard的TargetTransform
,传递给了ActorMoveToTargetUntilDistance
的TargetTransform
,成功地通过Blackboard让两个相对解耦的节点又能合作起来。
Blackboard和SharedValue往往通过Dictionary来实现。各个节点仅仅保存了SharedValue的Key的字符串,取值的时候,都是携带这个Key去Blackboard中查Dictionary对应Key的Value。
总而言之,通过Node-based Visual Scripting,可以让程序、策划更加好地分工。
虽然都是Node-based Visual Scripting,不同的Gameplay框架,有不同的具体机制和限制。下面将逐一介绍。
状态机也是我们非常熟悉的概念。在Unity中,我们常通过Mecanim或PlayMaker接触到状态机。
《Game Programming Patterns》的《State》一章,非常直观地概况了状态机的用处。
其将以下响应玩家输入事件的混乱代码:
void Heroine::handleInput(Input input)
{
if (input == PRESS_B)
{
if (!isJumping_ && !isDucking_)
{
// Jump...
}
}
else if (input == PRESS_DOWN)
{
if (!isJumping_)
{
isDucking_ = true;
setGraphics(IMAGE_DUCK);
}
else
{
isJumping_ = false;
setGraphics(IMAGE_DIVE);
}
}
else if (input == RELEASE_DOWN)
{
if (isDucking_)
{
// Stand...
}
}
}
重构为:
这么简单直观的“一幅图”。
状态机之所以能将其问题简化,是因为它框架符合需求地提供了(但也限定死了)以下基础功能:
isJumping_
、isDucking_
这些独立的变量变为一个枚举变量State
来表达互斥,这的确能大幅优化上面代码的繁乱程度)从状态机的特点触发,它适用于简单的、需要全局事件跳转的、有状态的逻辑。
但状态机不适用于复杂的逻辑,否则状态机即变成盘丝洞。
使用状态机的具体举例有:技能的逻辑或表现、Buff的逻辑或表现、有明显步骤的动画表现(炉石传说主要用PlayMaker做表现动画)。
通过多个状态机并行执行,可以把多种互不相关的状态结合起来实现一个复杂的角色动作逻辑。
比如一个角色按身体姿态分有moveLayer={stand|run|crouch}
,按动作分有actionLayer={idle|shoot|melee}
,按状态分有statusLayer={normal|weak|speedup}
。
我们可以使用1个状态机去表达上述所有情况,这个状态机将包括:
s0={stand&idle&normal}
, s1={run&idle&normal}
, s2={crouch&idle&normal}
, s3={stand&shoot&normal}
s4={run&shoot&normal}
4*3*3=36
种状态及其切换。我们也可将这3个相关性本就较小的状态用3个并行执行的状态机去表达,此时,我们只需要考虑4+3+3=10
种状态切换就好。
注意到,要成功这样做,需要依赖于底层服务提供者(如控制move的组件、控制action的组件、控制status的组件)本就能互不相关地被设置。
行为树是诞生于游戏行业的一种重要的执行模型。
行为树的使用示例恰好在前面的Blackboard一节有提到,故不赘述。
行为树因为是树状,所以比状态机能够更好地应付复杂的执行流程。通过不断地拆分子问题、重用行为树,来帮助设计者更轻松地、更少出现错误地解决复杂问题。
虽然行为树也能和状态机一样响应外界事件、也能被外界事件中断某棵子树而跳到另一棵子树。但行为树常不这样做,常用于受外界事件突发事件影响较少的场合,而是通过行为树内部不断拉去游戏世界的信息,进行自发的流程控制。
所以,行为树常用于AI设计、流程相对比较固定的关卡逻辑。
其内部实现机制可概括为:
在前一个项目《独立防线》中,我们采用行为树作为关卡逻辑编辑。
在打算实现新项目关卡逻辑的时候,却发现有太多全局事件跳转,导致行为树出现各种interrupt节点,从这颗子树跳到另一棵毫不相干的子树,很是突兀和麻烦。才意识到之所以行为树能用于独立防线的关卡逻辑,是因为它的关卡逻辑需求是相对比较线性的,都是按照现行剧本去挨个发生的。
这时我们也正常但不合理地联想到状态机也能响应全局事件,但由于状态机一次全局事件只能被一个状态捕获,所以是和我们的需求不一致的。
于是参考兄弟项目组的经验,我们将目光转移到了Starcraft2的Galaxy Editor的关卡编辑器上:
从视频和上图可以看出,一个“Trigger”包括了
这个Trigger机制非常棒!某某Event在世界里发生了,策划配置好这个Event对应的Trigger们都会进行一系列Condition的判断,如果判断通过,则执行对应的一系列Action,过程中Trigger自己的局部状态通过Local Variables去记录,供Condition和Action读写。
重点是在Local Variables和Conditions。从视频中你会发现,策划不已经是在编写逻辑了吗?只不过编写逻辑是通过UI来进行而已。
但问题是,类似于Galaxy Editor中的Conditions的操作、UI,都显得比较繁琐不直观(比如上图中的一长串配置英文:“Number of Living units in (Any units in (Entire map) owned by player1 matching Excluded: Structure, Missile, Dead, Hidden, with at mostAny Amount) == 0”)。
这时,我立刻联想到了Unreal4唯一押宝的Gameplay框架:Blueprints(前Unreal3 Kismet)。
了解Blueprints后,发现Blueprints和Galaxy Editor的Trigger事实上都是属于Event-Driven。而且因为Blueprints是基于Visual Scripting的概念出发的,所以对于Variable、Condition的实现会显得更加灵活和强大。
然后,恰好,在Unity Assets Store里,有不错的一些EDVS插件,包括uScript、FlowCanvas等。考虑到我们的关卡逻辑需要进行AssetBundle更新,所以将EDVS翻译成C#脚本的uScript并不适合,最后再通过各种使用和性能评估,我们选定了FlowCanvas。
EDVS的特点是:
我们当前正将EDVS应用于关卡逻辑配置上。
什么是“ 非线性编辑(Non-linear editing,以下简称NLE)”?我们先通过图片搜索来找个直观感受。
NLE事实上就是老百姓口中的视频编辑,或者也可称为时间线(Timeline)编辑。
注意到“非线性”这个字眼和时间线本身比较“线性”这个感觉,比较矛盾。这是因为历史原因导致的。在上个世纪90年代,线性编辑(Linear video editing)是主要的视频编辑方式,其弊端是,进行视频编辑的时候,源视频必须线性地进行访问(想象一下录像带),给编辑带来了极大不便。所以,非线性编辑的最大特点是视频编辑时,可以对源视频进行非破坏性的随机访问。
所以,非线性编辑器和线性编辑器的差别并非我们当前游戏开发的重点——因为我们现在对外存、内存的访问都是非破坏性、可随机访问的。非线性编辑和线性编辑,都属于时间线编辑。
在游戏中,NLE主要用在实时过场动画(Cut-scene)的制作。
其核心概况是:
NLE还可以用在角色动作编辑上。
一般游戏类型的角色动作,我们完全可以使用上面提到的状态机或行为树来配置实现动作。
但在类似于FTG、ACT这些游戏类型,角色的动作精度要求极高,高到必须按帧进行单独配置(如上图Ryu的蓝色受击框是逐帧进行配置的)。所以我们也会把NLE的概念用于进行这种帧级别精度要求的角色配置上。
本章开篇图为本人参考多款NLE编辑器所制作出来的FunAction动作编辑器。
有Unity Flux插件经验的人会感觉其与Flux长得非常像,的确Editor方面FunAction是参考Flux的,但两者除了长得像之外,内在思路却完全不一样。
FunAction的概况如下:
PlayAnimation
为例,如下图PlayAnimation
的Inspector自定义实现了自动寻找动画属性的逻辑)ActorHurtBody
的受击Capsule(可从AABB/Capsule/OBB间选择),和ActorHitTest
的攻击OBB
上面这些Gameplay框架的Runtime实现都并非困难。但实现起来,往往大量开发时间消耗在:
一个好的游戏设计思路,是能让开发者可以重复造轮子、而不是让开发者必须重复造轮子。
让开发者必须重复造轮子是简单粗暴欠妥的,让开发者既能选择重复造轮子、也能选择采用已有第三方插件,反而需要更多对基础框架扩展性的思考。
在Unity Asset Store里有好一些比较不错的Gameplay框架具体实现插件。它们是:
开发者不能有因使用第三方插件而感到“技术性羞耻自卑”的心态。
相反,开发者应该发挥开发的能力去评估一款第三方插件是否优秀,评估的角度包括:
如果决定应用第三方插件,我们不应该轻易修改它,而是优先去扩展它。
在Unity里,第三方插件(及其他项目无关的通用基础功能),建议都摆放在“Standard Assets”目录里,因其与其他文件夹的脚本是处于不同的两个dll,从而防止普通开发者错误地把具体项目业务逻辑感染进通用逻辑里。
这样子,我们可以通过继承、或者partial、或者extend、或者static function等途径进行第三方插件的扩展。
对于一些重要不紧急的插件修改,可以通过社区和作者进行交流,让其进行修改。比如本人就多次对FlowCanvas/NodeCanvas/BehaviorDesigner的作者交流讨论、提出多项建议(如1、2等),最后被采纳。
如果有必要,我们决定修改第三方插件,我们需要承担从此不能再轻易更新这些插件的后果。
如果我们已大幅修改第三方插件,此时我们可以反问自己:“这第三方插件是否已经太不满足需求了?我们是否应该开始重新造更适合我们的轮子了?”
通过上述Gameplay框架的有机合理组合,能够实现丰富的Gameplay逻辑。
Gameplay框架工具也远不只这些,地形编辑器、Starcraft2的Unit编辑器、技能编辑器,是更进一步、更具体细分的Gameplay编辑器。
也能就上述Gameplay框架进行特例化修改,比如主要用于对话设计的Dialog tree是状态机的一种重要特例化应用。
Utility AI是一种不错的AI思路。相比更“Rule-based”的FSM/BehaviorTree,Utility AI和GOAP相似,更有“Plan-based”的感觉。
如上图,程序写好评分的Node后,策略填填不同Node的分数(Score),就一个不同性格的AI就出来了。你是喜欢近战的路霸,就把“Proximity To Nearest Enemy”的Score调高,你是喜欢直线攻击的76,就把“Line Of Sight To Cloeset”的Score调高。
应注意,没必要为了用工具而用工具,要看需求有否用到。但也要考虑,需求是易变的、市场是易变的、方向是易变的、玩家是不耐心的。要为Gameplay的通用性、扩展性做好准备。