引言

欢迎来到GamePlay架构章节的下半部分!
在上一篇的内容里,我们谈到了UE的3D游戏世界是由Object->Actor+Component->Level->World->WorldContext->GameInstance->Engine来逐渐层层构建而成的。那么从这下半章节开始,我们就将要开始逐一分析,UE是如何在每一个对象层次上表达游戏逻辑的。和分析对象节点树一样,我们也将采用自底向上的方法,从最原始简单的对象开始。

首先需要明确的是,本部分接下来要讲述的UE的GamePlay逻辑框架部分,只是讨论UE的设计思想和理念,并不是表示其在所有其他游戏引擎中是最优最完美的方案,同时当然也不是开发人员务必遵守的金科玉律,你依然可以也应该根据自己实际情况灵活变通。UE经过了很多权衡设计和历史进化,最后选择了该设计方案,一方面和对象层级相辅相成,另一方面也提供了足够的自由度可以供你腾挪。
实现一个游戏业务功能的方式有多种,你应该尽量妥善的权衡你当前的现实情况,考虑生产效率、维护性、功能实现、易理解、性能等等多种因素,然后选择你认为最恰当的方式。如果你当前在制作一个快速原型Demo,你大可以简单粗暴,我也不赞成时刻谨遵教条主义一定要分层拆分如何如何;而如果是面对一个正式的比较大型项目,随着规模的扩大,我们就得利用清晰的概念来帮助我们减轻心智负担。UE作为一个老牌的经历了十几年风风雨雨的游戏引擎,也当然有它的一套GamePlay哲学。我们选择了UE,接受了在UE的工作流之下工作,如果我们能比较好的理解它的概念和思想,就能更加的“顺”着它的思路,得心应手海阔任鱼跃。而如果我们“逆”着这个框架来搞自己的一套,一是不免有无法充分利用UE的嫌疑,二也是以UE的庞大和根深错节难免让你碰一头灰费力不讨好。

Note1:虽然本部分会涉及到游戏的业务逻辑编写部分,但并不打算详细讨论AI(BehaviorTree,Navigation等)。AI也是一个很大的话题,值得专门开个大章节讨论,我们现在不应该委屈她。
Note2:本部分也不会细讨论输入事件的处理,游戏逻辑确实一大部分是由输入事件驱动起来的,不过我们此时只是简单聊一下概念,后续会有章节再细讨论输入事件的路由流程。
Note3:联机游戏的游戏逻辑自然也是非常重要的,但为了简化本章节的概念,所以网络联机的逻辑同步等也都不会涉及。留待后续网络章节再好好的阐述。

Component

Actor可以说是由Component组成的,所以Component其实是我们对象树里最底层的员工了。在UE里,Component表达的是“功能”的概念。比如说你要实现一个可以响应的WASD移动的功能,或者是VR里抓取的功能,甚至是嵌套另一个Actor的功能,这些都是一个个组件。正确理解“功能”和“游戏业务逻辑”的区分是理解Component的关键要点。
所以我们在这一个层级上要编写的逻辑,是实现一个个“与特定游戏无关”的功能。理想情况下,等你的一个游戏完成,你那些已经实现完成的Components是可以无痛迁移到下一个游戏中用的。换言之,一旦你发现你在Component中含有游戏的业务逻辑代码,这就是所谓的“Bad Smell”了,要警惕游戏架构是否恰当,是否没有很清晰的概念划分。

Actor

如果说UE是一个大国家的话,那Actor无疑就是人口最大的民族了。StaticMeshActor,CameraActor……我们天天口里嚷嚷的也都是它。和Unity的Prefab对应的,在UE里我们用的最多的也是BlueprintActor了,我们也常常自定义我们的Actor子类来组装其他Component和Actor,然后再编写一些协作逻辑代码,就似乎完成了一个骁勇善战的特种兵,接下来就可以撒豆成兵般的往Level中扔了。
用的越广泛越多,往往错的也越多。似乎是受到了一种朴素的子承父业的精神感染,也或许是我们的面向对象编程都学得太好的缘故,我们都非常倾向于直接在Actor里堆砌逻辑。右键一个BlueprintActor,刚添加完Component,就立马撸起袖子来,Event、Function和Variable一个个罗列开来,噼里啪啦无不快活!但是且慢,这是最好的方式了吗?让我们一路带着这个问题,试着从UE角度去推演一下,重走一下Actor进化之路。在本章节旅程的终点,我保证,我们可以比较清楚的回答这个问题。

其实所有的游戏引擎在构建完节点树之后,都会面临这么一个问题,我的游戏逻辑写在哪里?
有的原始的如Cocos2dx懒得想那么多,干脆就直接码在Node里面得了,所以你翻看Cocos2dx的源码你就会经常发现它的逻辑和表现往往是交杂在一起的,简单直接暴力美学,面向对象继承玩得溜。而面向组合阵营的领军Unity则干脆就把Component思想再应用极致一点,我的逻辑为什么不能也是一个组件?所以Unity里的ScriptComponent也是这种组合思想的体现,模型统一架构优雅,MonoBehavior立大功了!但是在一个Component(ScriptComponent)里去操作管理其他的Components,本身却其实并不是那么优雅,因为有些Component之上的协调管理的事务,从层次上来说,应该放在更高的一个概念上实现。UE在思考这个问题时,却是感觉有些理想主义,颇有些C++的理念,力求不为你不需要的东西付代价,宁愿有时候折衷,也想保住最优性能。UE的架构中也大量应用了各种继承,有些继承链也能拉得很长,同时一方面也吸纳了组合的优点,我们也能见到UE的源码中类的成员变量也是组合了好多其他对象。所以接下来的该介绍的就是UE综合应用这两种思想的设计产物。面向对象派生下来的Pawn和Character,支持组合的Controller们。

Pawn

那么第二个至关重要的的问题是,哪些Actor需要附加逻辑?
在游戏中,我们之所以会觉得一个角色生动,是因为它会响应我们的交互,并给出恰当的反应。而我们所谓的游戏业务逻辑,实际上编写的就是该如何对玩家的输入提供反馈。同样,一个Actor想要变得“生动”,就得有响应外部输入的能力,否则就只是自动运转麻木的机器人。但是在一个比较大型的3D游戏中,Actor有千千万万,然后并不是所有的Actor都需要和玩家互动,得宠的能直接面圣和玩家互动的Actor也是比较少的。我们经常都只是操作我们的“角色”,让“角色”和场景里的其他物体互动。比如FPS游戏里我们操作的主角或者是FlappyBird里的那只小鸟。所以从这一点上来看,UE中Actor就立马又可以划分出一个类别了,这些Actor们可谓是玩家们的宠儿,它们是玩家们的亲卫兵,对,它的名字就是Pawn!

同其他AInfo一样,UE也是从Actor中再派生出了APawn,并定义了3块基本的模板方法接口:

  1. 可被Controller控制

  2. PhysicsCollision表示

  3. MovementInput的基本响应接口

为了更好理解这个概念,让我们看一下用搜索引擎搜一下Pawn得到的图:

没错,Pawn的英文翻译过来可以是兵卒,所以如果把UE游戏看作是一场棋盘上的游戏的话,那这些Pawn就可以看作是在UE的3D世界中玩家可以操纵的棋子,而其他的Actor则可以构成棋盘等。如果是人机对战的话,对方玩家是机器AI,同样需要控制Pawn棋子。所以Pawn就是那些可以被玩家(你或AI)控制的Actor!再考察到UE是做FPS游戏起家的,所以你可以想象这个Pawn就相当于战场里最基本的士兵的表示。一个士兵在战场中首先需要表达自身的存在(PhysicsCollision),可以移动(MovementInput),然后可以响应输入和处理逻辑(Controller),有了这三个基本要素,运用你的想象力,你就可以大概构想出一个被玩家控制的“兵卒”的模样和概念了。
要非常清楚一点的是,Actor是我们用来表示3D游戏中对象的,所以Pawn继承于Actor,概念的重点是在于更清楚的去表示,而不是重点在于Pawn被当作逻辑的载体,就像棋子本身只能简单的表达出出个棋子,但是该如何走还是得再靠外部的Controller机制。你也可以想象成提线木偶,那个木偶就是Pawn,而提线的是Controller。Pawn表达的最关键点是可被玩家操纵的能力。因为UE从FPS进化过来的关系,所以附带的物理表示和移动也一并加了进去,应该也是为了方便的缘故。就像我知道Damage这种业务逻辑部分按照纯粹性来说是不应该出现在引擎的代码里的,但是Actor里就是这么加上了,用的时候也确实能得到便利。游戏引擎是个工程,而不是科学研究,有时候确实模块划分也不是那么纯粹。

思考:为何Actor也能接受Input事件?
我上述的对Pawn的描述可能会让你觉得,似乎Pawn既然就是用来被玩家控制的,那么理所当然的我们应该在Pawn上同时实现对输入的接受。但我们会发现实际上EnableInput接口却是在Actor上的,同时InputComponent也是在Actor里面的,意味着实际上你也可以在Actor上绑定处理输入事件。官方的输入事件处理流程图也是表明了这一点:

(暂时不用细研究这个图,我们以后会再次见到的。)
我们在此暂不细讨论输入流程为何如此设计,只谈谈该如何理解这一事实。首先应该不难理解输入的处理功能可以实现化出InputComponent,而“输入”的种类也有很多(按键、摇杆、触摸和陀螺仪等等),我们也不能确定和分类哪些Actor的子类该接受哪些种类的输入事件;同时又因为Actor也是由Component组件化组装而成的,UE不可能为了输入的处理就改变Component的组织方式,所以还不如泛泛的在Actor的基类里提供InputComponent的集成,这样反而保证了灵活性。
理解这个问题的要点在于正确区分“输入响应”和“逻辑控制”。比如说WASD移动,Actor拥有最基本的输入响应,它可以响应WASD的按键事件。但是按键了之后呢?该如何移动?Pawn就定义了一个基本的MovementInput套路,相当于把WASD的输入响应再往前包装处理了一步。而“逻辑控制”指的是更高层上的比如寻路或自动巡逻等行为。
作为GamePlay中至关重要的一个逻辑概念,让我再罗嗦强调一遍应该不为过吧。Pawn实现的是“可被控制”的概念。因为“被控制了”之后经常要被移动(UE对FPS是真爱啊),所以Pawn就索性把移动的接口也定义了一下(当然,为了灵活性,内部转交给MovementComponent再处理),既然能移动了,但也不能随便在地图里乱走吧,所以碰撞(物理表示)看来也是需要的啊,好吧,那就加上,齐活了。

DefaultPawn,SpectatorPawn,Character

让我一口气介绍下面这三位:

DefaultPawn

因为我们每次想自己搞Pawn都得从Pawn派生过来,然后再一个个添加组件。UE知道我们大家都很懒,所以提供了一个默认的Pawn:DefaultPawn,默认带了一个DefaultPawnMovementComponent、spherical CollisionComponent和StaticMeshComponent。也是上述Pawn阐述过的三件套,只不过都是默认套餐。

SpectatorPawn

UE的FPS做的太好了,就会有一些观众想要观战。观战的玩家们虽然也在当前地图里,但是我们并不需要真正的去表示它们,只要给他们一些摄像机“漫游”的能力。所以派生于DefaultPawn的SpectatorPawn提供了一个基本的USpectatorPawnMovement(不带重力漫游),并关闭了StaticMesh的显示,碰撞也设置到了“Spectator”通道。

Character

因为我们是人,所以在游戏中,代入的角色大部分也都是人。大部分游戏中都会有用到人形的角色,既然如此,UE就为我们直接提供了一个人形的Pawn来让我们操纵。

像人一样行走的CharacterMovementComponent, 尽量贴合的CapsuleComponent,再加上骨骼上蒙皮的网格。同样的三件套,不一样的配方。
有些人一开始的时候会困惑应该选择Pawn还是Character,其实从继承体系中就可以了解到Character只不过是Pawn的加强特化版本。一般来说,如果你控制的角色是人形的带骨骼的,那就选择Character吧。而如果是VR中的一双手(假设只有一双手),因为移动模式和显示都算不太上人形,顶多只能算是个漂浮的“幽灵”,所以还是用Pawn方便些。后期如果你想加上人形模型和IK了,那么再把Mesh替换成SkeletalMesh也就行了。Pawn因为是基础款,所以提供了最大的灵活性。

总结

本篇主要探讨了从Actor到Pawn的分化过程,请读者们也好好自己体会一下这一过程中UE的设计和思量。一个游戏引擎对3D游戏世界的抽象是建立在很多概念之上的,UE的逻辑和实现也都是基于对这些概念的实现和封装。而如果读者你并不清晰理解这些概念,那么就很难正确的应用和组织游戏的逻辑各个部分。本系列教程一如开篇所说,并不会教你应用的各种技巧,而把重点放在讨论UE背后的各种概念,这些才是让我们的头脑保持清晰的关键之处。
因为在下笔力有限,很遗憾,我们心心念念的Controller只好留待下篇了。我在谈Pawn的时候,因为Pawn和Controller是那么紧密的关联着,所以也不得不事先一再的剧透提到Controller。但Controller作为GamePlay逻辑的最最重要的一个载体,可探讨的点也非常的多,所以留待下篇吧。