1 理解Gameplay
Gameplay是最近几年才广为流传的一个名词(不是说以前没有),我没有做过具体考证,但应该是从Unreal 广泛流传开来的,伴生的一个概念还有3C。
最近的面试中(Unity和Unreal的都有),我也会适当的问一些候选人类似的问题,比如你所理解的Gameplay和3C是什么?大多数学习过Unreal会说 3C 就是指Character,Control和Camera,不过也就仅此而已;而对Gameplay的表述则会混乱一些,大致会把Unreal的流程讲一遍。而只有Untiy经验的有很大一部分是表述不出来这些概念的。这也会让我进行一些思考,为什么不同的引擎开发人员对游戏开发概念会出现这么大的偏差呢?
在过去很长一段时间里,Unity占据了手游甚至是游戏开发的“大半壁江山”。最开始只有一个统一的称谓叫“客户端”,之后逐渐从客户端开发上分化出了TA(技术美术)和引擎,甚至于现在比较热门的TD(技术策划)。我们会说Unity开发,Unity客户端,Unity前端,却很少听到Unity的Gameplay。归其原因还是因为Unity的闭源,以及源码付费让绝大多数的游戏公司在做项目的时候,不会把引擎开发和“客户端”开发进行概念上的并列。默认招聘和谈论的就是使用Unity引擎进行项目的内容开发,所以Unity的开发者之间除非特指引擎组和引擎源码,不然大家都是“客户端”开发。
随着Unreal 引擎的逐渐普及,Gameplay的概念也得到了广泛的传播。因为开源的关系,Unreal在招聘的时候会刻意区分是引擎岗位还是“客户端开发”岗位,而这个“客户端开发”在广义上就是指Unreal的Gameplay。
那么为什么我要区分“广义”和“狭义”呢?因为划分的标准不一样。
- 广义的Gameplay。以引擎源码为界限,需要改动引擎源码才能实现的会被划归为引擎开发。而基于引擎开发游戏或者玩法的称之为Gameplay。所以若以广义的划分标准来看,绝大多数的Unity开发都是“Gameplay”开发。而绝大大多数的Unreal开发,多多少少都会改动到引擎,是不是就是“引擎开发”了呢?
- 狭义上的Gameplay。以Unreal 4.27 提供的Gameplay框架作为参考,它其实包含的就是游戏的规则和状态,3C和用户界面,也就是表达一个游戏玩法的最基础元素(但实际上一个复杂的游戏考虑的远远不止这么些)。那么问题来了,比如我们所说的战斗,剧情,AI,载具这些就不属于游戏玩法了吗?就不是Gameplay了吗?
注:UE5的Gameplay扩展了Actors,移动组件,游戏功能和模块化,定时器等内容,但对于理解Unreal的Gameplay上没有什么大的概念上的变化。Unreal 5.1 的 Gameplay框架 。
综上所述,无论是从广义上还是狭义上的Gameplay划分都是不太科学合理的。
- 对于Unity项目而言,它的AssetBundle机制很不好用,某同学研发了一套自己的资源组织规则;又或者某位TA同学基于URP,重写了一套延迟渲染的流程,这些在我看来都属于引擎开发的范畴。
- 对于Unreal项目而言,某位同学没有动到引擎代码,从业务层设计了一套更高效的动画存储和加载框架,那么它是属于引擎开发的,而某位同学为了更方便的进行业务开发,从引擎层开放了一个面板参数进行数据配置,虽然改动了引擎源码但也不算是引擎开发。
所以我认为,如果某位同学的职能更多的是基于框架和系统来做玩法内容和乐趣体验的会被归为Gameplay;如果他的职能更多的是为游戏开发提供底层的扩展能力、优化框架和系统,增加游戏开发的技术边界等部分的内容可以算作引擎开发(如果只分引擎和Gameplay类别的话),当然如果愿意的话,也可以分更多的细类比如性能优化,工具开发,系统管线等。简单来说,提供能力的是引擎,提供内容的是Gameplay。
2 Unreal Gameplay 框架介绍
关于框架的理解,必然每个人还是有自己的看法。这里我们先就只讨论一下Unreal在文档中标记的GamePlay框架的内容,即:
- 游戏规则
- 角色
- 控制
- 相机
- 用户界面和HUD
拿官方的一个示例举例来说明Gameplay的工作方式:
兔子与蜗牛赛跑。
游戏框架的基础是GameMode。 GameMode 设置的是游戏规则,如首个跨过终点线的玩家即是冠军。其同时可生成玩家。
在 PlayerController 中设置一名玩家,其同时会产生一个Pawn。 Pawn 是玩家在游戏中的物理代表,控制器则拥有Pawn并设置其行为规则。本范例中共有2个Pawn,一个用于蜗牛而另一个用于兔子。兔子实际为 角色(Character),是pawn的一个特殊子类,拥有跑跳等内置移动功能。另一方面,蜗牛拥有不同的移动风格,可从Pawn类处直接延展。
Pawn可包含自身的移动规则和其他游戏逻辑,但控制器也可拥有该功能。控制器可以是获取真人玩家输入的PlayerController或是电脑自动控制的AIController。在本范例中,玩家控制的是蜗牛,因此PlayerController拥有的是蜗牛Pawn。而AI则控制兔子,AIController则拥有兔子角色,其中已设有停止、冲刺或打盹等行为。
相机(Camera)提供的视角仅对真人玩家有效,因此PlayerCamera仅会使用蜗牛Pawn的其中一个CameraComponent。
进行游戏时,玩家的输出将使蜗牛在地图中四处移动,同时 HUD将覆盖在相机提供的视角上,显示目前游戏中的第一名和已进行的游戏时间。
2.1 GameMode
在上面这个例子中,GameMode 决定的是游戏规则,即拥有两个角色,先跨过终点线的玩家为冠军。衍生的部分还有比如是否允许观战以及观战的人数最多为多少?玩家如何进入游戏,以及使用哪张比赛地图?游戏是否可以暂停,以及暂停之后如何恢复?游戏是否允许使用道具,又或者是否可以在游戏中作弊等,这些规则都是跑在服务器上的,确保规则的权威性和安全性。
GameMode在Unreal里的实现是AGameModeBase类(用A开头是因为它继承于Unreal的AActor,这是Unreal的类命名规则,可以查看代码规范),它是AGameMode的基类。一个项目可以拥有任意多的GameMode来设置各种各样的玩法,但同一时刻只能使用一个GameMode。
AGameModeBase提供若干基础的、可被override的接口:
- InitGame。 在这里做所有游戏规则的初始化工作。
- PreLogin 。登录前的预处理。由于GameMode只会跑在服务器上,可以在这里检查玩家的合法性,判定是否允许玩家登录服务器。
- PostLogin。登录后的后处理。玩家成功登录服务器之后的调用。
- HandleStartingNewPlayer。一般登录成功之后就会创建玩家在服务器上的对象,对象创建成功之后会调用该函数,可以在这里对玩家进行初始化,比如获取玩家的PlayerState。
- RestartPlayer。创建玩家的实体对象(可操控的,场景上可见的Pawn对象)。
- Logout。玩家退出或者服务器被销毁时调用。
其他的还有很多,这里只列举了一部分。
再次强调,这些逻辑都是存在服务器上的,客户端是没有办法访问的。如果确实需要访问一些GameMode相关的信息,那可以通过创建一个Actor,把相关属性和数据赋值给Actor,之后由replication机制覆盖到远程客户端上。
上面说到的是AGameModeBase类。其实在4.14 版本之前,通用的是AGameMode,该类现在仍然保留,它提供一些扩展类的接口。新建工程默认都是从AGameModeBase类继承,当然开发者可以手动从AGameMode继承以获取以下接口:
GameMode 作为Unreal项目的开始入口,是需要在最开始进行初始化的。那么它的设置方式也有很多种:
- 在工程的Project Setting下进行设置。
- 在DefaultEngine.ini的文件里进行设置
其实第一种的设置方式也是修改了这个配置文件而已。更多的操作方法可以查看 设置游戏模式 。
2.2 Game State
字面意思,Game State 就是指游戏状态。它管理了所有已连接的客户端,并且实时追踪游戏层面的属性并把它们分发给远程客户端。有别于Play State,GS(GameState)主要是负责游戏全局属性,比如5V5Moba游戏中的红蓝双方防御塔的剩余数量,游戏当前进行的时间,大小龙击杀的情况,红蓝阵营野怪刷新情况等等。而PS(Player State)则是记录单个玩家的属性和状态,比如补了多少刀,出了什么状态,身上有多少钱,技能冷却时间等等。
归纳一下就是,GS应该追踪游戏进程中变化的属性,这些属性与所有人皆相关,且所有人可见。它存在于服务器上,但会被复制到所有的客户端上。
和GameMode一样,Game State也是在AGameStateBase中实现基础接口,并且在Project Setting中进行配置。
几个比较重要的函数:
- GetServerWorldTimeSeconds 服务器版本的游戏时间,权威可靠的,会被同步在客户端。
- PlayerArray。所有APlayerState的列表,对游戏中玩家执行操作和逻辑时候非常有用。
- BeginPlay。
还有一些其他的接口,如下。
需要注意的是,这仅仅是Unreal 从引擎侧实现的最小版本,在项目开发的时候,你可以使用它来扩展任意的Game State数据,并进行远程客户端的数据推送。
2.3 Camera
接下来是大名鼎鼎的“3C”之一的Camera(相机)。在面试的时候,对于中初级的开发同学我一般都会跟他探讨一个话题:“你怎么理解3C?”
而得到的回答很多都是字面意思,相机,控制,和角色。如果健谈一点的同学可能还会补充一下,代表一个游戏的基础体验。但我其实更希望能听到他们举一些例子(无论是自己做过的还是别的游戏的),来说明如何通过这些模块来提高玩家的基础体验甚至变成游戏玩法的一部分。
相机在游戏中其实是代表了玩家的视角,以及玩家如何去观察这个“世界”。它不但会关联渲染,给管线提供必要的渲染内容可视性和遮挡剔除,同时也承载这渲染完成之后的后处理效果后期处理效果。但更多的是,如何使用相机的组件模块来完成更好的游戏体验和沉浸感。比如以下列举一些相机组件完成的游戏体验:
- 《英雄联盟》中,盖伦使用R斩杀了敌人之后,画面会表现出气浪冲击波的效果。
- 《尘埃》赛车游戏中,通过切换不同视角来完成第一人称和第三人称的驾驶体验。同时可以通过额外的摄像机渲染来完成后视镜的效果。
- 《黎明杀机》中,屠夫(第一视角)和逃生者(第三视角)的游玩视角不一样。屠夫可以通过佩戴“鹰眼”的技能来让视野变成类似于水滴透镜的效果,从而得到更开阔的视野。
- 《鬼泣》中,通过切换固定摄像机视角来完成走廊到房间的视角切换。或者模拟一个虚拟演唱会上的导播相机调度。
- 飞行游戏中可以通过设置轻微的动画来模拟穿过气流的颠簸感。航海游戏可以通过设置轻微的动画来表达海浪对船造成的轻微摇摆。常规的3D游戏可以使用弹簧臂的形式,让玩家躲在墙角或者被建筑遮挡的时候,相机不会穿模。
- 射击游戏中,通过改变相机的FOV参数完成狙击枪的模拟。格斗或者动作游戏中可以通过调用相机震动来调优“打击感”。
关于相机提升基础体验,总结为两点:
2.4 Character
提到角色,就需要先提一下他的父类Pawn(棋子)《InsideUE4》GamePlay架构(四)Pawn。UE中,把所有可以在游戏中视觉看到的东西都称之为Pawn。比如一张桌子,一块石头,一个池塘等。Pawn继承自Actor,并且一个Pawn需要很多个组件和它一起作用,比如场景上有一个金矿石:
- 它的位置、旋转和缩放由 SceneComponent 中定义的Transform信息所决定。
- 它的可视化样子由 StaticMeshComponent 决定。
- 它如果发光就需要绑定一个粒子组件ParticleSystemComponent 。
- 它如果需要和周围环境进行交互,有实际的物理体积就需要绑定一个碰撞盒组件BoxComponent 。
回到角色上来,一个Character就是一个特殊的,可以行走的Pawn,一般代表垂直站立的玩家。也就是说它比Pawn多了 CharacterMovementComponent,同时,因为一个可行走的模型需要提供一些行走动画,所以还需要SkeletalMeshComponent 组件来提供骨骼框架,由于人的形状和盒子差别很大,所以在物理碰撞上用胶囊体CapsuleComponent来替换碰撞盒。
角色组件是一个Avatar,代表玩家在和游戏场景交互。并且可以在场景中行走、跑动、跳跃、飞行和游泳等,同样作为一个Actor,它也包含基础的网络功能,并接受玩家的输入控制。当然可以可以任意扩展和使用Character。
关于角色的拓展可以做的非常非常的深,包含动画,场景交互,物理等维度都是可以的。比如不使用刚体物理即可行走、跑动、跳跃、飞行、坠落、摔倒、游泳和攀爬等,比如在空气、水、沼泽,沙漠、雪地、太空等场景下中行进的速度、浮力、重力值,以及角色能对物理对象施加的物理作用力(魔法,科技等)等。再比如一些动画相关的表现:RootMotion,MotionMatching 新一代动画技术:Motion Matching,IK/FK等。
其他关于Character的基础介绍可以查阅:Setting Up a Character 。
2.5 Controller
过去我们在谈论UI框架的时候,一个被提及的最多的模式就是MVC。它把一个系统结构分为数据-视图-控制三个不同的关系层。目的是为了减少逻辑耦合,并让每个层的职能更加的专一化。相同的概念我们也可以引入到一些战斗的设计中,比如逻辑-表现分离,用事件或者协议来传递数据并驱动逻辑执行。
那么到Gameplay框架中,我们仍然能找到一个比较合适的部分来套用这套模式。比如我们现在的M就是Player State,我们的V就是Character,那么C自然就是马上要介绍的Controller了(如果要看系统性的介绍请看这篇 《InsideUE4》GamePlay架构(五)Controller)。
AController继承自AActor,也就是说它并没有场景实体,是一个场景不可见的对象。它拥有一个PlayerState,一个Pawn,如果这个Pawn同样是Character的话,那么它还有一个不为空的Character对象。
默认情况下,一个控制器只对应一个Pawn,二者之间也非强绑定关系而是组合关系。如果需要更改默认的控制器逻辑,可以自定义继承实现。
控制器会接收其控制的Pawn所发生诸多事件的通知。因此控制器可借机实现响应该事件的行为,拦截事件并接替Pawn的默认行为。 控制器又分为两种不同的类型《InsideUE4》GamePlay架构(六)PlayerController和AIController:
- Player Controller 。代表玩家的输入和控制。
- AI Controller 。代表AI或者远程玩家在本地的镜像。
其中Player Controller是玩家直接操控角色的逻辑类,因此非常复杂。大体可以分为Camera管理,Input响应,UPlayer关联和操控,HUD显示,关卡切换的逻辑处理,音效部分等等。而AI Controller因为不需要接受玩家操控,因此对Camera、Input、UPlayer关联,HUD显示,Voice、Level切换等部分都不是必须的,但对应的它增加了一些额外的模块,比如Navigation(导航),行为树,Task系统等实现。
2.6 HUD 和UI
HUD可以理解为对部分Player State的场景可视化。比如怪物或者人物头顶的血条,名字等等。而UI则是覆盖在场景渲染之上,提供更多玩家交互和查看的信息。二者的主要区别是在交互上,HUD一般来说是不能交互的,简略的信息;而UI则指的是菜单和其他互动元素。这部分不展开细说,可以参考 Slate UI编程
2.7 其他
以上是Unreal 4.x时代的Gameplay框架所包含的内容,到了5.1之后,又新增了一些内容,我们也顺带提一下。
- Actors。不得不再次搬出大钊的文章《InsideUE4》GamePlay架构(一)Actor和Component,强烈建议大家系统性的学习他的“GamePlay架构”系列。因为文章视角不一样,我这里基本不会展开讨论细节。Actor除了继承自UObject的序列化、反射、内存管理等能力之外,额外实现的是组件的组合能力,Tick能力,网络复制能力和对生命周期的管控Actor 生命周期。
简单介绍一下上面这张图,它展示了Actor的三种实例化方式,但无论它是怎么“来”的,它“走”的流程是一样的。
三种模式是:
- 从磁盘加载
- Play in Editor
- Spawn
其中1和2十分相似,1是从磁盘里加载,2是从编辑器中复制。当实例化之后都会执行Post(Load || Duplicate)逻辑,InitializeActorsForPlay(UWorld 调用),再到RouteActorInitialize(Actor自己的组件初始化),再到关卡开始的逻辑调用BeginPlay。
3的逻辑不同,它是通过运行时生成的,所以执行的是PostCreate,然后需要执行对应的构造逻辑ExecuteConstruction来创建蓝图变量,然后用PostActorConstruction来执行Actor自身的组件初始化(其实和RouteActorInitialize 的主要一样),然后就是一样的BeginPlay。
虽然创建逻辑有差异,但销毁逻辑一致,执行了EndPlay之后,Actor就会被标记为RF_PendingKill,并在下个垃圾回收周期中被解除分配,然后有垃圾回收器将其回收。
- Timer 。不是很明白,为什么要把定时器单独归类到Gameplay框架中来。可能是因为AActor中提供了GetWorldTimerManager函数来获取FTimerManager的实例?定时器可以设置使用指定时间,或者指定帧来作为触发器。
- Movement Components 【图解UE4源码】其一 UCharacterMovementComponent的移动逻辑。除了人物移动之外,还有表示发射物/子弹移动的组件 ProjectileMovementComponent,以及一些特定的运动组件,比如RotatingMovementComponent 用来展示飞机螺旋桨,风车或者任何可以旋转的东西。
3 Unreal Gameplay 框架Runtime流程
在上一篇Unreal Engine 的启动流程 中,我们留了一个大坑。引擎的Init和Tick我们就只介绍了一点皮毛,也就是EngineLoop自身阶段的逻辑情况,那么真正跟开发者相关的部分还是EngineLoop调用了EditorEngine或者是GameEngine之后的Gameplay部分。
因为整个逻辑引擎的tick太多了,我们只聊一下跟Gameplay初始化相关的部分。先翻出这张包浆图:
这张图主要展示了编辑器环境下和Runtime环境下Gameplay的初始化顺序。而编辑器又比较特殊,它既要处理Editor编辑器本身的初始化,又要解决PIE(Play in Editor 也就是编辑器中点击Play按钮)和SIE(Simulate in Editor 编辑器中点击模拟)情况下的初始化情况。
求同存异,我们从共同的部分开始整(图上蓝色部分)。看一下UWorld::BeginPlay这个函数的介绍:Gameplay(梦)开始的地方,开始GameMode逻辑并且调用所有Actors的BeginPlay函数。
逻辑实现如下:
- 初始化所有World类型的Subsystem并调用它们的OnWorldBeginPlay函数。
- 根据服务器类型生成服务器的Actors
- 调用GameMode的StartPlay
- 如果有AISystem,那么StartPlay
- 进行WorldBeginPlay事件广播
- 初始化物理系统
到这里,我们的第一个Gameplay的元素GameMode已经开始工作了。那么接下来往下就是GameMode的StartPlay逻辑了。
这里第二个元素GameState也上场了。
GameState对所有Actors派发了BeginPlay事件,并广播了OnWorldMatchStarting事件。
那么其余的部分是在哪里初始化的呢?答案是在BeginPlay之前。不做全流程的代码细节分析了,贴两个前人已经做好的,想了解细节的可以看这两篇或者直接看源码。
UE4 Gameplay之GameMode流程分析(一)
UE4 Gameplay之GameMode流程分析(二)
上面的提到了GameMode的StartPlay流程,但它必须先初始化才能够执行StartPlay。它的初始化逻辑就写在 StartPlayInEditorGameInstance函数中,也就是当我们在编辑器里按下Play按钮之后。
在进行了一系列的参数组装之后,它会开始调用GameMode的初始化。
再往后执行一系列其他初始化工作之后,开始为LocalPlayer本地玩家创建Actor。
本地玩家先要完成登录验证,然后会返回一个PlayerController,这个对象在Login逻辑中生成。
然后来到了PostLogin逻辑,当玩家成功登录之后,就会调用HandleStartingNewPlayer函数并开始一场比赛。
在Handle的字调用栈里就会去创建一个Pawn(Character)来跟Controller进行绑定。
然后创建HUD(事实上HUD的调用逻辑比 Pawn 早一点点,都是在 AGameModeBase::PostLogin里做的)。
到这里,Controller,Character,HUD都已经出现了,加上之前提到的GameMode和GameState,狭义上的Gameplay就只剩下Camera了。
Camera 因为关联着渲染,本身逻辑会复杂很多,并且初始化的时机也要提前很多,大部分时候和跟随场景一起加载了。但Camera并没有那么多花样,它就是视口和transform的信息,再加上渲染好的renderTexture用作后处理。Gameplay向的相机玩法更多的是做相机的动画和功能用途,比如跟随,切换视角,平滑轨迹或者用小型的摄像机动画模拟各种显示场景来达到沉浸感。
除了前面在框架介绍里提到的一些Camera的用法之外,还可以看一下这个了解一下Camera系统 UE4 里的 Camera 系统 ,代码里直接差 APlayerCameraManager类就好。
另外,Controller是持有Camera对象并且可以操作Camera对象的。
架构图我也不想画了,怎么画都不会有
画的好,大家直接看他的就好了《InsideUE4》GamePlay架构(十)总结。
4 Lyra工程中的Gameplay部分
Lyra是Epic提供的基于Unreal5的初学者示例项目,但如果你真信了它是初学者项目的话,只能说会很惨。。。
严格来说它一点都不初级,甚至非常高级,说是当前Unreal 5的最佳实践也不过分。它向开发者展示了如何去重写一个项目的Gameplay,展示了最新的Unreal 5的特性和使用方式,甚至写了一些完全可以独立复用的Plugin插件。
本篇的重点还是Gameplay部分,所以我们着重聊一下ModularGameplayActors这个自定义的Gameplay扩展插件和Lyra基于它的业务逻辑。
其实这个思想很好,它用一套自己的Modular来隔离引擎和项目层,我自己的开发理念也是相似的,能不动引擎的尽量不动,自己写一些继承和扩展,既有自由度,又不会在未来引擎升级或者业务修改的时候造成兼容性的麻烦。
这个Plugin其实没有任何实质的内容,就是对所有涉及到的引擎原有模块做出继承,也就是充当了项目和引擎之间的缓冲带。
比如 AModularGameModeBase,AModularGameStateBase 等分别如下:
他们都只做了最基本的继承而已。那么重要的其实是在Lyra下的实现,我们一一来看。
最开始的自然是GameMode了,由于所有的基础模块都重写了,所以在构造函数里,需要将它们一一重新指定初始化。