概述
本文就数种重要的Gameplay框架及插件,简述它们的原理,介绍这些Gameplay框架的适用场合,并进行对比。
本文假设读者有一定的游戏开发经验、Unity开发经验。
本文会写得比较随性和啰嗦。
从Gameplay这词说起
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迭代更新,是Gameplay框架的一大责任。
(另,可能一般不会太关注到的点是,我们也不能过度更改我们的游戏。一个游戏当前玩家是已经认可之前版本玩法设定的、受之前版本重重过滤后留下的玩家,如果玩家手上的版本本来是个RAC,我们下一个版本把它改成RTS,那玩家肯定都流失了。比如笔者之前负责过的一款游戏,个人觉得其2.0版本因为对战斗外体验更改过大,是造成2.0版本上线后数据滑落的重要原因之一。)
Gameplay框架
开始实现各种各样Gameplay时,我们常会编写符合需求,却相对更hardcode的Gameplay代码。
这做法有一定好处,其在时间紧急的情况下,能在初期就立刻见成效。
随着时间推演,Gameplay需求越来越多、越来越复杂、越来越和自己之前所想不一样的时候,这些之前hardcode的代码就越来越难以维护。
此时我们需要重构,需要针对这些各种各样的Gameplay需求,进行归纳总结。
(换句话说,上述这种更hardcode的Gameplay代码还有一个好处:其的确能让我们更早地了解细节,更早地知道自己为何重构、如何重构,甚至给重构提供集成测试用例。)
世界万物都可被归纳、被总结。
我们不能拒绝归纳总结,否则解决一个问题后、再出现类似问题我们又得从零开始苦思冥想。归纳总结可以帮助人去理解并记住结论,让人有可能举一反三。
但过分的归纳总结是抽象、甚至可能是无用的、不严谨的。不存在万金油。(“ToE”也尚未被证实。:P)
框架也是。
框架是必须的,为了更好地提供服务解决某一类问题,我们搭建底层框架。
但从我们写框架的第一行代码开始,给它带来功能的同时,也给它带来了限制。
即,没有万能的框架、只有适用的框架。
在游戏行业中,根据前人的实践、思考,已归纳总结出不错的几种重要Gameplay框架。
本文将讨论几种Gameplay框架,讨论它们是什么、它们之间的联系和区别、它们各自的适用场合。它们是:
并非说以上框架能满足一切Gameplay,但它们组合在一起,相信已能满足颇多需求。
这些框架是实用的。本文之所以会提到这几个框架,并非生硬地把它们堆砌在一起。恰恰相反,而是因为作者本人在游戏开发中遇到了实际问题,思考后发现,“这不是恰好可以用这种Gameplay框架来解决这个问题吗?”,通过实验和实践,才体会到这些框架的实用价值。
实体组件(Entity-Component)
之所以把实体组件系统(Entity-Component,以下简称EC)放在最前面,是因为它是最最最重要的、同时也是我们最熟悉的、可能也是我们最容易忽略的。
EC不复杂,本人亦曾2度写过EC,分别是Flash游戏《弹道轨迹(TNT)》和一个开发中的Unity帧同步游戏。如果我必须做出N选一,我会放弃其他所有Gameplay框架而选择保留EC。
另,从《Game Engine Architecture》将EC这个话题收编于其Runtime Gameplay Foundation Systems一章,重点着墨介绍,也能证明其与Gameplay的密切关系。
Is-A转为Has-A
EC最核心的功能很简单:将传统继承的is-a换成了has-a,将Component保存于Entity的一个容器中,Entity提供API进行Component的查找访问。
因为针对任何一个事物进行有限的功能拆分必然是不完整的,选取任意一个维度将其作为基类,都是不那么严谨的。所以,将这些功能有限拆分后,与其不精确地必须选取一个作为基类,倒不如把它们公平地作为组件,公平地处于Entity里。
EC能让我们更好地分解复杂的问题、整理复杂的关系。
狭义的EC只包括上述这个功能,但一般,广义的也会被修改成拥有以下几项重要功能。
生存期
EC还可以提供API,进行Entity、Component的生存期管理,以及生存期相关事件的回调。
生存期以Unity的术语为例,一般指的是:
- 创建(Awake)
- 有效(OnEnable)
- 启动(Start)
- 轮转(Update)
- 无效(OnDisable)
- 销毁(OnDestroy)
实现生存期的重难点在于:
- 如何确保“同时”创建的Entity的所有Start都发生在Awake之后。比如可以使用
ms_gameObjectsWillStart
列表实现。 - 如何确保创建销毁不会影响轮转阶段。每一次Tick()都会对组件列表进行遍历调用Update()。用户在Update()内调用创建或销毁后,如果EC立刻将其从列表中添加或移除,这将可能影响遍历逻辑。所以EC会在Tick的开始阶段或末尾阶段才真正将Entity、Component添加或移除到最终列表里。比如可以使用
ms_gameObjectsWillStart
列表和ms_gameObjectsWillDestroy
队列实现。 - 如何确保高效的轮转。比如通过接口(Unity通过反射检测
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属性。
从上面的例子可以看出,EC的功能是如此基础和重要,所以才说是Gameplay的必备要素。
Entity Component System
以上,是典型的Object-Oriented EC。
随着《守望先锋》的成功和他们在GDC分享《'Overwatch' Gameplay Architecture and Netcode》,Data-Oriented ECS成为了最近的话题焦点。
它的特点是Component只有数据没有方法、System只有方法而没有数据(Component has no method, and System has no field)。数据和行为分离,更加解耦。
同一种Component以Array的形式存储在一起。因为是struct-of-array,更加内存友好,性能效率会更快。
特定System只关心特定某几种Component(Group,守望先锋称为“Tuple”)。比如Render System只关心Transform和Renderer这两种Component,仅当一个Entity#12实例同时有这两种Component的实例Transform#98和Renderer#37时,Transform#98和Renderer#37就置于一个Tuple里,然后Render System就针对这包含Transform和Renderer的Tuple所组成的数组进行foreach执行逻辑。
System能以显式的配置按指定顺序执行,能更清晰地整理一帧内模块间的逻辑执行顺序,一般比较棘手的一帧Delay(one-frame-off)问题也就迎刃而解。
从而,延迟处理(Deferment)也是ECS的特点,上游众多System可以随意高频地塞入CommandQueue,或者对某个状态进行预改变,然后在少数下游System对CommandQueue或改变的状态进行逻辑响应,能有效对逻辑响应进行统筹、过滤筛选,减少多余的逻辑响应。比如播放特效等。
另外很重要地,基于以上,ECS更加容易做到粗粒度的JobSystem多线程编程。这一方面可另外参阅《Unite Europe 2017 - C# job system & compiler》
既能解耦、又能简化复杂流程逻辑、也可能带来性能提升,这是Data-Oriented ECS最诱人之处。
节点可视化编程(Node-based Visual Scripting)
- 状态机(Finite State Machine)
- 行为树(Behavior Tree)
- 事件驱动可视化编程(Event Driven Visual Scripting)
- 非线性编辑(Non-linear editing)
上面提到的Gameplay框架及插件都有共同的一点:它们都可以以Node-based Visual Scripting的形式存在。
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-based,其思想就是封装和组合。
我们可以合理地考虑重用性,将功能拆分为非常通用、非常细小的Node,作为一个又一个Node。但这样有可能会造成Node过多,造成浏览、编写时的麻烦。
我们可以针对比较重要的一段逻辑进行归纳,将本由多个Node才能实现的重要逻辑,重新以1个Node的形式呈现。
这事实上是个何时进行重构的问题,也是个提取共性、保留异性的思想。
Blackboard
各个Node是相对独立解耦的,但各个Node有是有可能需要数据交互的。往往通过在主体中添加一个Blackboard(黑板)和SharedValue,来让这些Node进行数据交互。
以上图行为树作为Blackboard的例子。它实现的需求是
- 找寻玩家控制的Actor(
FindLocalUserActor
节点) - 移动到该Actor到足够近(
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、封装各种常用的Node,
- 策划通过这些Node,通过Visual Scrpting,在将这些Node“有机”地组合起来,即能实现各种不同的逻辑。
虽然都是Node-based Visual Scripting,不同的Gameplay框架,有不同的具体机制和限制。下面将逐一介绍。
状态机(Finite State Machine)
状态机也是我们非常熟悉的概念。在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...
}
}
}
重构为:
这么简单直观的“一幅图”。
状态机之所以能将其问题简化,是因为它框架符合需求地提供了(但也限定死了)以下基础功能:
- 一个状态机内部的各个状态是互斥的,一个状态机一个时刻只处于一个特定状态
(比如上图的“STANDING”、“JUMPING”等方框)
(当然如果你坚持hardcode,你也可以把isJumping_
、isDucking_
这些独立的变量变为一个枚举变量State
来表达互斥,这的确能大幅优化上面代码的繁乱程度) - 可以将不同的事件发送给状态机
(比如上图的“PRESS↓”、“RELEASE ↓”等事件) - 如状态A能跳转到状态B,则它们俩间会有一个从A指向B的Transition,该Transition指定由什么事件触发,从而触发状态跳转
(比如上图“JUMPING”状态到“DIVING”状态之间有一个Transition,其指定由“PRESS↓”事件触发)- 当状态机接受到新事件时,如该事件是局部事件,则只有当前所在状态有该事件对应的Transition时,才进行跳转
(比如上图,假设状态机当前处于“JUMPING”状态,因其只包含一个响应“PRESS↓”事件的Transition,所以当状态机接受到“PRESS B”局部事件时,将不会进行跳转;当状态机接受到“PRESS↓”局部事件时,才会跳转到“DIVING”状态) - 全局事件不管当前处于什么状态,都可以立刻进行状态跳转
(即类似于Mecanim中AnyState相连的Transition、或PlayMaker的Global Transition)
- 当状态机接受到新事件时,如该事件是局部事件,则只有当前所在状态有该事件对应的Transition时,才进行跳转
- A状态可以设置成能跳转到A状态自己,也可以设置成不可以
- 状态有Enter()、Update()、Exit()三个阶段函数。
(比如上图“JUMPING”状态跳转到“DRIVING”状态的过程中,将会依次调用到“JUMPING”这个状态对象的Exit()、“ DRIVING”这个状态对象的Enter();如果会停留在“DRIVING”这个状态对象的话,将一直调用它的Update()) - 状态由用户自定义的脚本组成,分别都可以实现自己的Enter()、Update()、Exit()逻辑。脚本默认为串行执行,有些状态机也可以并行执行脚本。
- 状态机提供Tick()函数以驱动当前状态的当前脚本的Update()函数
- 状态机是张图
- 可以有多个状态机同时并行运行
从状态机的特点触发,它适用于简单的、需要全局事件跳转的、有状态的逻辑。
但状态机不适用于复杂的逻辑,否则状态机即变成盘丝洞。
使用状态机的具体举例有:技能的逻辑或表现、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的组件)本就能互不相关地被设置。
行为树(Behavior Tree)
行为树是诞生于游戏行业的一种重要的执行模型。
行为树的使用示例恰好在前面的Blackboard一节有提到,故不赘述。
行为树因为是树状,所以比状态机能够更好地应付复杂的执行流程。通过不断地拆分子问题、重用行为树,来帮助设计者更轻松地、更少出现错误地解决复杂问题。
虽然行为树也能和状态机一样响应外界事件、也能被外界事件中断某棵子树而跳到另一棵子树。但行为树常不这样做,常用于受外界事件突发事件影响较少的场合,而是通过行为树内部不断拉去游戏世界的信息,进行自发的流程控制。
所以,行为树常用于AI设计、流程相对比较固定的关卡逻辑。
其内部实现机制可概括为:
- 行为树类似分层状态机(Hierarchical Finite State Machine, HFSM),注意和上面提到的多个并行状态机并不同。
- 以树状的形式存在,状态被改叫为Task
- 其每个Task可返回Success、Running、Failure的执行结果给父节点
- 组合节点(Composite)是一种Task,其有1个或多个孩子Task。根据孩子Task返回的执行结果,不同的组合节点有不同的响应逻辑,从而不同地决定下一个节点是哪一个孩子并返回Running状态,或者不再执行孩子而返回Success或Failure节点
- 修饰节点(Decorator)是一种Task,组合节点差不多,但其只能有1个孩子Task
- 行为节点(Action)是一种Task,它对游戏世界信息进行读写操作,其必然是行为树的叶子节点,因为它并不能包含孩子节点。
- 判断节点(Conditional)是一种Task,它和行为节点差不多,但我们口头约定好,判断节点只对游戏世界信息进行读操作来判断其执行结果、而不要对游戏世界信息进行写操作
- 行为树提供Tick()函数,从而驱动当前待执行的或正在Running的节点的Update()函数。可以通过一个执行栈的列表来记录当前正在执行哪些节点。具体为:
- 从Root点开始递归深度逐一遍历,
- 将刚刚遍历到的新节点(包括Root自己)Push到执行栈栈顶;
- 调用该节点的Update();
- 先假设该节点的Update()只返回Success或Failue状态,即代表其已经执行完毕,即可将其返回状态保存在自身、Pop出栈、并交由父节点对其孩子们进行状态判断,决定需否执行下一个子节点,如没,则父节点本身返回状态并Pop出栈
- 如果期间没有并行节点、所有节点都返回Success或Failue状态,则这1个Tick()内都可以执行整棵行为树
- 如果一个执行栈执行过程中出现节点返回Running状态,则这次Tick()不再执行这个执行栈。而是下一次Tick()再执行这个执行栈的栈顶元素
- 如果遇到并行组合节点,则该并行组合节点为所有孩子节点都new一个新的执行栈来供孩子节点分别使用,从而实现并行执行。这个并行组合节点执行完毕时,可以销毁被它new出来的这些执行栈们
- 所有执行栈可以保存在一个执行栈列表中,在Tick()内就这个执行栈列表进行遍历执行
- 从Root点开始递归深度逐一遍历,
事件驱动可视化编程(Event Driven Visual Scripting)
在前一个项目《独立防线》中,我们采用行为树作为关卡逻辑编辑。
在打算实现新项目关卡逻辑的时候,却发现有太多全局事件跳转,导致行为树出现各种interrupt节点,从这颗子树跳到另一棵毫不相干的子树,很是突兀和麻烦。才意识到之所以行为树能用于独立防线的关卡逻辑,是因为它的关卡逻辑需求是相对比较线性的,都是按照现行剧本去挨个发生的。
这时我们也正常但不合理地联想到状态机也能响应全局事件,但由于状态机一次全局事件只能被一个状态捕获,所以是和我们的需求不一致的。
于是参考兄弟项目组的经验,我们将目光转移到了Starcraft2的Galaxy Editor的关卡编辑器上:
- 视频:Starcraft 2: Heart of the Swarm - Behind The Scenes - Galaxy Editor (HD)
- 文档:Triggering for Dummies (the basics)
从视频和上图可以看出,一个“Trigger”包括了
- Event
- Local Variables
- Conditions
- Action
这个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 player 1 matching Excluded: Structure, Missile, Dead, Hidden, with at most Any 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的特点是:
- 基于Event触发,事件产生了之后push才触发逻辑。这点和状态机一样,比行为树轮询pull检查的性能较好
- 默认一个Event发生后,对应的Flow都是同步执行完的。和状态机、行为树不同,默认未定义“状态”、“运行中”这些概念。你也可以实现自己的有“执行中”状态的节点,但需要自己定义同样的事件在这个状态下再发一次给你的这个节点,你的节点是什么行为
- 提供更加类似于编程语言的变量和流程控制,比状态机行为树的粒度能做到更细
我们当前正将EDVS应用于关卡逻辑配置上。
非线性编辑(Non-linear editing)
什么是“ 非线性编辑(Non-linear editing,以下简称NLE)”?我们先通过图片搜索来找个直观感受。
NLE事实上就是老百姓口中的视频编辑,或者也可称为时间线(Timeline)编辑。
注意到“非线性”这个字眼和时间线本身比较“线性”这个感觉,比较矛盾。这是因为历史原因导致的。在上个世纪90年代,线性编辑(Linear video editing)是主要的视频编辑方式,其弊端是,进行视频编辑的时候,源视频必须线性地进行访问(想象一下录像带),给编辑带来了极大不便。所以,非线性编辑的最大特点是视频编辑时,可以对源视频进行非破坏性的随机访问。
所以,非线性编辑器和线性编辑器的差别并非我们当前游戏开发的重点——因为我们现在对外存、内存的访问都是非破坏性、可随机访问的。非线性编辑和线性编辑,都属于时间线编辑。
在游戏中,NLE主要用在实时过场动画(Cut-scene)的制作。
其核心概况是:
- 多对象共存于时间线上,受NLE操作。NLE就好像导演,去控制摄影师、演员们、特效师们、音效师们什么时候该做什么事
- 和Unity的Animation有相似性,都是基于时间线进行“某些东西”的编辑,但Animation中每一帧所编辑的东西非常固定:对象的属性或一些简单参数的事件,这远远不能满足于Cut-scene制作
- NLE在时间线的基础上,允许开发自定义各种行为节点,及对行为节点进行参数配置
- 节点在时间线上有明确的开始点、结束点,即形象地以“条状”表达一段持续的“事件”。这样将[开始帧,结束帧)的帧范围(Frame Span)封装成一段范围事件的好处是:
- 明确区分1个Track内的多个帧范围事件对象拼接组成,以帧范围事件对象为单位,单独配置、操作、执行。举例为:
- 给帧范围单独设置角色动画,即可以不修改原有动画文件的情况下,单独配置角色所播动画的范围、播放速度
- 给帧范围传入一组路径点数据,作为对象(角色、Camera等)的运动轨迹
- 方便地单独调节一段事件的长短
- 方便地修改交换A事件和B事件的发生次序
- 明确区分1个Track内的多个帧范围事件对象拼接组成,以帧范围事件对象为单位,单独配置、操作、执行。举例为:
NLE还可以用在角色动作编辑上。
一般游戏类型的角色动作,我们完全可以使用上面提到的状态机或行为树来配置实现动作。
但在类似于FTG、ACT这些游戏类型,角色的动作精度要求极高,高到必须按帧进行单独配置(如上图Ryu的蓝色受击框是逐帧进行配置的)。所以我们也会把NLE的概念用于进行这种帧级别精度要求的角色配置上。
本章开篇图为本人参考多款NLE编辑器所制作出来的FunAction动作编辑器。
有Unity Flux插件经验的人会感觉其与Flux长得非常像,的确Editor方面FunAction是参考Flux的,但两者除了长得像之外,内在思路却完全不一样。
FunAction的概况如下:
- 最重要的,Action提供Tick()函数,从而一帧一帧地驱动执行
- 任意角色模型可和任意Action运行时动态绑定。但一旦绑定,规定了1个角色对象有且只有1个Action,1个Action认定只操作1个角色对象
- 事实上这对传统NLE多对象共存于时间线上来说,是一种退化。但这种退化是满足角色动作编辑的需求的,是合理的。未来如果有时间,在不能给编辑器带来额外操作复杂度的前提下,是可以实现成允许多对象同时编辑的,即一个既可编辑cutscene、也可编辑角色动作的NLE编辑器
- Action有多个Motion(动作,如idle、attack、hurt等),每个Motion有多个Track(轨道),每个Track和且只和一种BaseEvent的子类(事件类型,如PlayAniamation)绑定,Track可以出现其绑定的事件类型的任意个事件对象。BaseEvent可以让用户重载Enter()、Update(currentFrame)、Exit()等函数,从而实现各种千变万化的功能。
- BaseEvent的子类除了DurationEvent(样子为长条状)外,还有子类InstantEvent(箭头状)。DurationEvent类似于传统NLE的时间轴对象,有明确的StartFrame、EndFrame;InstantEvent类似,但规定StartFrame和EndFrame必须相同。这是因为在动作游戏中,有许多事件的持续帧数是只有1帧(比如攻击检测等)、或持续帧数是不用限定无法限定的(比如播放特效、播放音效等)
- Action提供SetMotion()函数,从而切换动作
- 可自定义序列化、反序列化方式。默认为Protobuf-net,效率比Unity的各种XML、各种JSON序列化方式好多个数量级。开发使用的方式非常简单,以
PlayAnimation
为例,如下图
- 每个自定义的Event都可方便地再自定义Inspector的逻辑和画法。示例如下图(留意到
PlayAnimation
的Inspector自定义实现了自动寻找动画属性的逻辑)
- 每个自定义的Event都可方便地再自定义在Editor场景绘制额外元素。示例如下图,为
ActorHurtBody
的受击Capsule(可从AABB/Capsule/OBB间选择),和ActorHitTest
的攻击OBB
第三方Gameplay插件
上面这些Gameplay框架的Runtime实现都并非困难。但实现起来,往往大量开发时间消耗在:
- 提供功能齐全、人性化的Editor和Inspector
- 实现性能高效、人性化的序列化反序列化
一个好的游戏设计思路,是能让开发者可以重复造轮子、而不是让开发者必须重复造轮子。
让开发者必须重复造轮子是简单粗暴欠妥的,让开发者既能选择重复造轮子、也能选择采用已有第三方插件,反而需要更多对基础框架扩展性的思考。
在Unity Asset Store里有好一些比较不错的Gameplay框架具体实现插件。它们是:
- 状态机:NodeCanvas、PlayMaker
- 行为树:NodeCanvas、BehaviorDesigner
- 事件驱动可视化编程:FlowCanvas
- 非线性编辑:Unity Director Sequencer(尚未发布)、Slate、Flux(出名但不好)
开发者不能有因使用第三方插件而感到“技术性羞耻自卑”的心态。
相反,开发者应该发挥开发的能力去评估一款第三方插件是否优秀,评估的角度包括:
- 是否满足基本需求
- 是否开源(这很重要,因为代码即文档、文档不透彻更新不及时、二次修改的可能)
- 运行性能、反序列化性能
- 版本迭代、作者、社区是否活跃
- UI、操作、体验
如果决定应用第三方插件,我们不应该轻易修改它,而是优先去扩展它。
在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的通用性、扩展性做好准备。