UE GamePaly架构
前言
本文为大钊老师的学习UE4GamePlay架构的笔记以及阅读UE5.6的笔记,以下为自己的阅读笔记,本人熟悉unity引擎,以unity引擎角度去阅读这篇文章。
写给Unity开发者的Unreal Engine开发指南 (扫盲) - 知乎
(注:本人现在是UE入门初学者,第一遍阅读粗略看基础架构)
开篇
这边有一些准备链接
- 官方文档
- 视频平台
- Github地址
注意:因为UnrealEngine只是公开源码,但不是开源项目,依然是个私有项目。访问该Github地址,需要先链接你的Github到EpicGames的会员权限里,这个文档Linking your Github account说明了步骤。
一段话解释GP架构
采自https://zhuanlan.zhihu.com/p/1092234798
UE几乎所有的对象都继承于UObject,其提供了基础的GC垃圾回收,反射,元数据,序列化等功能,
AActor继承自UObject,包含了诸如Replication,Spawn,Tick等基础功能。Actor可以添加UActorComponent(也继承自AActor)来实现功能的扩展。所有的Actor都存在于ULevel中,然后有一个UWorld负责管理所有的ULevel。
GameModeBase是UE4中定义游戏规则和逻辑的类。每个关卡会关联一个GameMode,它决定了游戏如何进行(如比赛规则、胜利条件、重生规则等)。游戏模式负责生成玩家控制器、角色等游戏元素。
APlayerController代表每个玩家在游戏中的逻辑接口,负责处理输入并控制玩家的角色或Pawn。它接收来自输入设备的信号,并将这些信号转换为对应的游戏行为。
APawn是可以被玩家或AI控制的任何实体。ACharacter是APawn的一个特化类,通常用于具有行走、跳跃、奔跑等功能的角色。Character继承自Pawn,增加了物理、动画、运动组件等。
AHUD负责绘制和管理屏幕上的游戏用户界面(UI)元素。通常会结合UMG(Unreal Motion Graphics)进行复杂的UI设计。
UWorld类管理整个游戏世界的状态,包括关卡、时间、事件等。它是UE4引擎中最高层次的管理单元,负责生成所有Actor(包括玩家、物体、特效等)。
AActor是UE4中所有可以在游戏世界中存在的对象的基类。所有场景中的实体(如道具、光源、角色等)都继承自Actor。
UActorComponent是Actor的功能单元,可以被添加到Actor中来扩展其功能。常见组件有移动组件(MovementComponent)、物理组件(PhysicsComponent)等,所有这些组件为Actor提供不同的功能和行为。
AI通常通过AIController控制Pawn。AI使用行为树(Behavior Tree)和EQ(环境查询系统)来进行决策,能够轻松处理复杂的AI行为。
Object->Actor+Component->Level->World->WorldContext->GameInstance->Engine
| GP架构 | 中文解释 | 一句话 |
|---|---|---|
| UObject | 反射父类 | 元数据、反射生成、GC垃圾回收、序列化、编辑器可见,Class Default Object |
| Actor | 运行最小单元 | Replication(网络复制),Spawn(生生死死),Tick(有了心跳) 。无Transform |
| Component | 组件 | EC模式 |
| Level | 关卡地图 | 在world层面上可以理解为mc区块 |
| World | Game地图 | 整张地图,由Level形成 |
| WorldContext | Game-World父类 | 编辑器本身就也是一个World,里面显示的游戏场景也是一个World |
| GameInstance | WorldContexts | 可持续化单例GameManager ,存储WorldContext |
| Engine | 引擎 | UE的编辑器其实也是个游戏 |
| GamePlayStatics | 蓝图操作 | Engine层次为我们提供了便利操作蓝图函数库UGameplayStatics |
| Pawn | 棋子 | 地图上的基本单位 |
| DefaultPawn | 默认的Pawn | 默认带了一个DefaultPawnMovementComponent、spherical CollisionComponent和StaticMeshComponent。 |
| SpectatorPawn | 观战Pawn | 派生于DefaultPawn的SpectatorPawn,提供了一个基本的USpectatorPawnMovement(不带重力漫游),并关闭了StaticMesh的显示,碰撞也设置到了“Spectator”通道 |
| Character | 人形的Pawn | 大部分游戏中都会有用到人形的角色 |
| AController | 控制器 | MVC |
| APlayState | 玩家状态 | 单个玩家的Model |
| APlayerController | Player的C层 | Camera Input UPlayer HUD Level切换 Voice |
| AAIController | AI行为树 | Navigation AI组件 Task系统 |
| AGameMode | 游戏规则 | 跑在服务器的内容 权威与安全 Login |
| AGameModeBase | 游戏规则 | UE4的基类 |
| Game State | 游戏状态 | 记录客户端数据的单局游戏全局Model |
| GameSession | 游戏联机 | 网络联机游戏中针对Session使用的一个方便的管理类,并不存储数据, |
| GameInstance | 单例 | 可持续化单例GameManager |
| SaveGame | 本地保存 | 本地保存样例 |
基础概念
文件结构
| 目录名称 | 描述 |
|---|---|
| Binaries | 存放编译生成的结果二进制文件。该目录可加入 .gitignore(每次都会重新生成)。 |
| Config | 配置文件。 |
| Content | 最常用目录,存放所有资源和蓝图等。 |
| DerivedDataCache | 存储引擎针对平台特化后的资源版本(DDC)。例如同一个图片可为不同平台生成适配格式,无需修改原始 .uasset 文件。可加入 .gitignore。 |
| Intermediate | 中间文件(可加入 .gitignore),存放临时生成文件: - Build 中间文件(如 .obj 和预编译头) - UHT 预处理生成的 .generated.h/.cpp - 由 .uproject 生成的 VS .vcxproj 项目文件 - 编译生成的 Shader 文件 |
| AssetRegistryCache | Asset Registry 系统的缓存文件,索引所有 .uasset 资源的头信息(如 CachedAssetRegistry.bin)。 |
| Saved | 存储自动保存文件、配置文件、日志、引擎崩溃日志、硬件信息及烘焙数据等。可加入 .gitignore。 |
| Source | 存放代码文件。 |
基础概念
和其他的3D引擎一样,UE4也有其特有的描述游戏世界的概念。在UE4中,几乎所有的对象都继承于UObject(跟Java,C#一样),UObject为它们提供了基础的垃圾回收,反射,元数据,序列化等,相应的,就有各种”UClass”的派生们定义了属性和行为的数据。 跟Unity(GameObject-Component)有些像的是,UE4也采用了组件式的架构,但细品起来却又有些不一样。在UE中,3D世界是由Actors构建起来的,而Actor又拥有各种Component,之后又有各种Controller可以控制Actor(Pawn)的行为。Unity中的Prefab,在UE4中变成了BlueprintClass,其实Class的概念确实更加贴近C++的底层一些。 Unity中,你可以为一个GameObject添加一个ScriptComponent,然后继承MonoBehaviour来编写游戏逻辑。在UE4中,你也可以为一个Actor添加一个蓝图或者C++ Component,然后实现它来直接组织逻辑。 UE4也支持各种插件。
- 一些常用的Unity概念和它在Unreal中的对应部分。
Actor和Component
https://zhuanlan.zhihu.com/p/22833151
UObject
藉着UObject提供的元数据、反射生成、GC垃圾回收、序列化、编辑器可见,Class Default Object等,UE可以构建一个Object运行的世界。(后续会有一个大长篇深挖UObject)
Actor
脱胎自Object的Actor也多了一些本事:Replication(网络复制),Spawn(生生死死),Tick(有了心跳)。 Actor无疑是UE中最重要的角色之一,组织庞大,最常见的有StaticMeshActor, CameraActor和 PlayerStartActor等。Actor之间还可以互相“嵌套”,拥有相对的“父子”关系。
思考:为何Actor不像GameObject一样自带Transform?
简述:
- Unity的强制绑定 每个GameObject必须携带Transform组件(不可移除),即使对象仅用于逻辑管理(如全局计时器)
- UE的灵活分离
- Transform能力由
SceneComponent提供,作为可选组件。 - 若Actor无需位置信息(如 APlayerState ),可不附加任何组件;若需要位置,则附加SceneComponent 作为根组件RootComponent
设计优点:
- 性能优先: 避免为无需Transform的对象支付内存/计算代价(尤其在大规模场景中)。
- 类型安全: 通过组件分离明确标识对象能力(如带SceneComponent=有位置,反之=纯逻辑)。
- C++文化影响: 遵循“最小代价”原则,类似bool用位域(uint bPending:1)节省内存
Component
UActorComponent
看见UActorComponent的U前缀,是不是想起了什么?没错,UActorComponent也是基础于UObject的一个子类,这意味着其实Component也是有UObject的那些通用功能的。(关于Actor和Component之间Tick的传递后续再细讨论)
SceneComponent
ActorComponent下面最重要的一个Component就非SceneComponent莫属了。SceneComponent提供了两大能力:一是Transform,二是SceneComponent的互相嵌套。
思考:为何ActorComponent不能互相嵌套?而在SceneComponent一级才提供嵌套?
UE更倾向于编写功能单一的Component(如UMovementComponent),而不是一个整合了其他Component的大管家Component
思考:Actor的SceneComponent哲学
很多其他游戏引擎,还有一种设计思路是“万物皆Node”。Node都带变换。比如说你要设计一辆汽车,一种方式是车身作为一个Node,4个轮子各为车身的子Node,然后移动父Node来前进。
在UE里,其实是把5个薄薄的SceneComponent表示再用Actor功能的盒子装了起来,而在这个盒子内部你可以编写操作这5个对象的逻辑。换做是Node模式,想编写操作逻辑的话,一般就来说就会内化到父Node的内部,不免会有逻辑与表现掺杂之嫌,而如果Node要把逻辑再用组合分离开的话,其实也就转化成了某种ScriptComponent。
思考:Actor之间的父子关系是怎么确定的?
你应该已经注意到了Actor里面的TArray<AActor*> Children字段,所以你可能会期望看到Actor:AddChild之类的方法,很遗憾。在UE里,Actor之间的父子关系却是通过Component确定的。同一般的Parent:AddChild操作原语不同,UE里是通过Child:AttachToActor或Child:AttachToComponent来创建父子连接的。
ChildActorComponent
同作为最常用到的Component之一,ChildActorComponent担负着Actor之间互相组合的胶水。这货在蓝图里静态存在的时候其实并不真正的创建Actor,而是在之后Component实例化的时候才真正创建。
Level和World
https://zhuanlan.zhihu.com/p/22924838
引言
不同的游戏引擎们,看待这个过程的角度和理念也不一样。
- Cocos2dx
会认为游戏世界是由Scene组成的,Scene再由一个个Layer层叠表现,然后再有一个Director来导演整个游戏。
- Unity
觉得世界也是由Scene组成的,然后一个Application来扮演上帝来LoadLevel,后来换成了SceneManager。
- 其他的
有的会称为关卡(Level)或地图(map)等等。
- UE中
把这种拆分叫做关卡(Level),由一个或多个Level组成一个World。
不要觉得这种划分好像很随意,只是个名字不同而已。实际上一个游戏引擎的“世界观”关系到了一整串后续的内容组织,玩家的管理,世界的生成,变换和毁灭。游戏引擎内部的资源的加载释放也往往都是和这种划分(Level)绑定在一起的。
Level
ALevelScriptActor,允许我们在关卡里编写脚本类似V层
AWorldSettings,其实只是跟Level相关,和World无关
思考:为何AWorldSettings要放进在Actors[0]的位置?而ALevelScriptActor却不用?
Actors们的排序依据是把那些“非网络”的Actor放在前面,而把“网络可复制”的Actor们放在后面,然后加一个起始索引标记iFirstNetRelevantActor,相当于为网络Actor划分了一个缓存,从而加速了网络复制时的检测速度。
思考:既然ALevelScriptActor也继承于AActor,为何关卡蓝图不设计能添加Component?
Level其实更应该表现为一个Actor的容器。UE其实也是不鼓励在Level里编写太复杂的逻辑的
World
终于,到了把大陆们(Level)拼装起来的时候了。可以用SubLevel的方式:
也支持WorldComposition的方式自动把项目里的所有Level都组合起来,并设置摆放位置:
简单本质来说,就是一个World里有多个Level,这些Level在什么位置,是在一开始就加载进来,还是Streaming运行时加载。
Persistent的意思是一开始就加载进World,Streaming是后续动态加载的意思。Levels里保存有所有的当前已经加载的Level,StreamingLevels保存整个World的Levels配置列表。PersistentLevel和CurrentLevel只是个快速引用。在编辑器里编辑的时候,CurrentLevel可以指向其他Level,但运行时CurrentLevel只能是指向PersistentLevel。
思考:为何要有主PersistentLevel?
先出生在一块大陆上才可以继续谈起去探索别的新大陆。所以这块玩家出生的大陆就是主Level了。
思考:Levels们的Actors和World有直接关系吗?
当别的Level被添加进当前World之后,我们能直接在WorldOutliner里看到其他Level的Actor们。
Levels里的Actors的物理实体其实都是在World里的,毕竟物理的碰撞之类的当然要是全局的了。
World在拼接Level的时候,也是会同时把两个Level的导航网格给“拼接”起来的。
思考:为什么要在Level里保存Actors,而不是把所有Map的Actors配置都生成在World一个总Actors里?
应该是尽量的把损耗平摊(这里是把Level加载释放的损耗尽量减小),才不会产生比较大的帧率波动,让玩家感觉到卡帧。
WorldContext,GameInstance,Engine
https://zhuanlan.zhihu.com/p/23167068
WorldContext
首先World就不是只有一种类型,比如编辑器本身就也是一个World,里面显示的游戏场景也是一个World,这两个World互相协作构成了我们的编辑体验。然后点播放的时候,引擎又可以生成新的类型World来让我们测试。简单来说,UE其实是一个平行宇宙世界观。 以下是一些世界类型:
1 | namespace EWorldType |
而UE用来管理和跟踪这些World的工具就是WorldContext:
FWorldContext保存着ThisCurrentWorld来指向当前的World。而当需要从一个World切换到另一个World的时候(比如说当点击播放时,就是从Preview切换到PIE),FWorldContext就用来保存切换过程信息和目标World上下文信息。所以一般在切换的时候,比如OpenLevel,也都会需要传FWorldContext的参数。一般就来说,对于独立运行的游戏,WorldContext只有唯一个。
这里的TravelURL和TravelType就是负责设定下一个Level的目标和转换过程。
UE在OpenLevel的时候, 先设置当前World的Context上的TravelURL,然后在UEngine::TickWorldTravel的时候判断TravelURL非空来真正执行Level的切换。具体的Level切换详细流程比较复杂,目前先从大局上理解整体结构。总而言之,WorldContext既负责World之间切换的上下文,也负责Level之间切换的操作信息。
思考:为何Level的切换信息不放在World里?
因为UE有一个逻辑,一个World只有一个PersistentLevel(见上篇),而当我们OpenLevel一个PersistentLevel的时候,实际上引擎做的是先释放掉当前的World,然后再创建个新的World。所以如果我们把下一个Level的信息放在当前的World中,就不得不在释放当前World前又拷贝回来一遍了。 而LoadStreamLevel的时候,就只是在当前的World中载入对象了,所以其实就没有这个限制了。
思考:为何World和Level的切换要放在下一帧再执行?
首先Level的加载显然是比较慢的,需要载入Map,相应的Mesh,Material……等等。所以这个操作就必须异步化,
GameInstance
那么这些WorldContexts又是保存在哪里的呢?追根溯源:
GameInstance里会保存着当前的WorldConext和其他整个游戏的信息。明白了GameInstance是比World更高的层次之后,我们也就能明白为何那些独立于Level的逻辑或数据要在GameInstance中存储了。 这一点其实也很好理解,大凡游戏引擎都会有一个Game的概念,不管是叫Application还是Director,它都是玩家能直接接触到的最根源的操作类。而UE的GameInstance因为继承于UObject,所以就拥有了动态创建的能力,所以我们可以通过指定GameInstanceClass来让UE创建使用我们自定义的GameInstance子类。所以不论是C++还是BP,我们通常会继承于GameInstance,然后在里面编写应用于整个游戏范围的逻辑。 因为经常有初学者会问到:我的Level切换了,变量数据就丟了,我应该把那些数据放在哪?再清晰直白一点,GameInstance就是你不管Level怎么切换,还是会一直存在的那个对象!
类似于可持续化单例GameManager ,可看下文有详细。
Engine
此处UEngine分化出了两个子类:UGameEngine和UEditorEngine。众所周知,UE的编辑器也是UE用自己的引擎渲染出来的,采用的也是Slate那套UI框架。好处有很多,比如跨平台比较统一,UI框架可以复用一套控件库,Dogfood等等,此处不再细讲。所以本质上来说,UE的编辑器其实也是个游戏!我们是在编辑器这个游戏里面创造我们自己的另一个游戏。话虽如此,但比较编辑器和游戏还是有一定差别的,所以UE会在不同模式下根据编译环境而采用不同的具体Engine类,而在基类UEngine里通过一个WorldList保存了所有的World。
- Standalone Game:会使用UGameEngine来创建出唯一的一个GameWorld,因为也只有一个,所以为了方便起见,就直接保存了GameInstance指针。
- 而对于编辑器来说,EditorWorld其实只是用来预览,所以并不拥有OwningGameInstance,而PlayWorld里的OwningGameInstance才是间接保存了GameInstance.
目前来说,因为UE还不支持同时运行多个World(当前只能一个,但可以切换),所以GameInstance其实也是唯一的。提前说些题外话,虽然目前网络部分还没涉及到,但是当我们在Editor里进行MultiplePlayer的测试时,每一个Player Window里都是一个World。如果是DedicateServer模式,那DedicateServer也会是一个World。
GamePlayStatics
既然我们在引擎内部C++层次已经有了访问World操作Level的能力,那么在暴露出的蓝图系统里,UE为了我们的使用方便,也在Engine层次为我们提供了便利操作蓝图函数库。
1 | UCLASS () |
我们在蓝图里见到的GetPlayerController、SpawActor和OpenLevel等都是来至于这个类的接口。这个类比较简单,相当于一个C++的静态类,只为蓝图暴露提供了一些静态方法。在想借鉴或者是查询某个功能的实现时,此处往往会是一个入口。
Pawn
https://zhuanlan.zhihu.com/p/23321666?refer=insideue4
在上一篇的内容里,我们谈到了UE的3D游戏世界是由Object->Actor+Component->Level->World->WorldContext->GameInstance->Engine来逐渐层层构建而成的。那么从这下半章节开始,我们就将要开始逐一分析,UE是如何在每一个对象层次上表达游戏逻辑的。和分析对象节点树一样,我们也将采用自底向上的方法,从最原始简单的对象开始。
代码逻辑
其实所有的游戏引擎在构建完节点树之后,都会面临这么一个问题,我的游戏逻辑写在哪里? 有的原始的如Cocos2dx懒得想那么多,干脆就直接码在Node里面得了,所以你翻看Cocos2dx的源码你就会经常发现它的逻辑和表现往往是交杂在一起的,简单直接暴力美学,面向对象继承玩得溜。而面向组合阵营的领军Unity则干脆就把Component思想再应用极致一点,我的逻辑为什么不能也是一个组件?所以Unity里的ScriptComponent也是这种组合思想的体现,模型统一架构优雅,MonoBehavior立大功了!但是在一个Component(ScriptComponent)里去操作管理其他的Components,本身却其实并不是那么优雅,因为有些Component之上的协调管理的事务,从层次上来说,应该放在更高的一个概念上实现。UE在思考这个问题时,却是感觉有些理想主义,颇有些C++的理念,力求不为你不需要的东西付代价,宁愿有时候折衷,也想保住最优性能。UE的架构中也大量应用了各种继承,有些继承链也能拉得很长,同时一方面也吸纳了组合的优点,我们也能见到UE的源码中类的成员变量也是组合了好多其他对象。所以接下来的该介绍的就是UE综合应用这两种思想的设计产物。面向对象派生下来的Pawn和Character,支持组合的Controller们。
Pawn
同其他AInfo一样,UE也是从Actor中再派生出了APawn,并定义了3块基本的模板方法接口: 1. 可被Controller控制 2. PhysicsCollision表示 3. MovementInput的基本响应接口
思考:为何Actor也能接受Input事件?
对于输入的处理功能是由InputComponent处理的,我们应该在里面处理不同输入种类应对不同结果。还不如泛泛的在Actor的基类里提供InputComponent的集成,这样反而保证了灵活性。
Pawn就定义了一个基本的MovementInput套路,相当于把WASD的输入响应再往前包装处理了一步。而“逻辑控制”指的是更高层上的比如寻路或自动巡逻等行为。Pawn实现的是“可被控制”的概念。
所以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,再加上骨骼上蒙皮的网格。
Controller
https://zhuanlan.zhihu.com/p/23480071
如上文所述,UE从Actor中分化了一些专门可供玩家“控制”的Pawn,那我们这篇就专门来谈谈该怎么个控制法!
AController
AController是继承自AActor的一个子类
问自己一个问题:如果我想实现一种机制去控制游戏里的Actor,该怎么设计? 巧妇难为无米之炊,咱们先来看看当前手上都有些什么:
- UObject,反射序列化等机制
- UActorComponent,功能的载体,一定程度的嵌套组装能力(SceneComponent)
- AActor,基础的游戏对象,Component的容器
- APawn,分化出来的AActor,物理表示和基本的移动能力,当前正翘首以待。
- 没了,在控制Actor这个层级,我们还暂时不需要去考虑Level等更高层次的对象
- Controller应该做的事情
- 能够和Pawn对应起来
- 多个控制实例
- 可挂载释放
- 能够脱离Pawn存在
- 操纵Pawn生死的能力
- 根据配置自动生成
- 事件响应
- 持续的运行
- 自身有状态
- 拥有一定的扩展继承组合能力
- 保存数据状态
- 可在世界里移动
- 可探查世界的对象
- 可同步
关联Pawn的能力,有Possess和UnPossess,源码里也有PawnPendingDestroy等这些函数(未一一列出);GameMode中也保存着AIControllerClass和PlayerControllerClass的配置,用于在适当的时候Spawn出Controller;继承于Actor也就有了EnableInput和Tick;Controller本身还可以继续派生下去(如AIController和PlayerController),也可以容纳Components;也带着一个SceneComponent所以可以摆放在世界中;自身也可以添加成员变量来记忆存储游戏状态;自身也有一个FName StateName(Playing、Spectating、Inactive),切换自身的状态(运行,观察,非激活);因为跟Pawn是平级的关系,只在运行的时候引用关联,所以对彼此独立存在不做强制约束,提高了灵活性。
思考:Controller和Pawn必须1:1吗?
思考:为何Controller不能像Actor层级嵌套?
你实际上想要表达的是一种“控制”互相嵌套的概念,感觉又给“控制”给分了层,有“大控制”,也有“小控制”,但是“控制”的“大小”又是个什么概念呢?没意义。
而针对游戏逻辑的复杂,怎么更好的管理组织逻辑代码,我们有状态机,分层状态机,行为树,GOAL(目标导向),甚至你还能搞些神经网络遗传算法机器学习啥的。
思考:Controller可以显示吗?
因为继承与Actor的话,是可以渲染的,但是没有意义就直接在构造函数自己隐藏了。
思考:Controller的位置有什么意义?
想象成一种是上帝视角在千里之外心电感应控制Pawn,另一种是骑在Pawn肩上来指挥。
思考:哪些逻辑应该写在Controller中?
APlayerState
至于为啥APlayerState是从AActor派生的AInfo继承下来的,我们聪明的读者相信也能猜得到了,所以也就不费口舌论证了。无非就是贪图AActor本身的那些特性以网络复制等。而AInfo们正是这种不爱表现的纯数据书呆子们的大本营。而这个PlayerState我们可以通过在GameMode中配置的PlayerStateClass来自动生成。
注意,这个APlayerState也理所当然是生成在Level中的,跟Pawn和Controller是平级的关系,Controller里只不过保存了一个指针引用罢了。注释里说的PlayerState只为players存在,不为NPC生成,指的是PlayerState是跟UPlayer对应的,换句话说当前游戏有多少个真正的玩家,才会有多少个PlayerState,而那些AI控制的NPC因为不是真正的玩家,所以也不需要创建生成PlayerState。但是UE把PlayerState的引用变量放在了Controller一级,而不是PlayerController之中,说明了其实AIController也是可以设置读取该变量的。一个AI智能能够读取玩家的比分等状态,有了更多的信息来作决策,想来也没有什么不对嘛。
Controller和网络的结合很紧密,很多机制和网络也非常强关联,但是在这里并不详细叙述,这里先可以单纯理解成Controller也可以当作玩家在服务器上的代理对象。把PlayerState独立构成一个Actor还有一个好处,当玩家偶尔因网络波动断线,因为这个连接不在了,所以该Controller也失效了被释放了,服务器可以把对应的该PlayerState先暂存起来,等玩家再紧接着重连上了,可以利用该PlayerState重新挂接上Controller,以此提供一个比较顺畅无缝的体验。至于AIController,因为都是运行在Server上的,Client上并没有,所以也就无所谓了。
思考:哪些数据应该放在PlayerState中?
- 总结
在游戏里,如果要评劳模,那Controller们无疑是最兢兢业业的,虽然有时候蛮横霸道了一些,但是经常工作在第一线,下面的Pawn们常常智商太低,上面的Level,GameMode们又有点高高在上,让他们直接管理数量繁多的Pawn们又有点太折腾,于是事无巨细的真正干那些脏活累活的还得靠Controller们。本文虽然没有在网络一块留太多笔墨,但是Controller也是同时作为联机环境中最重要的沟通渠道,身兼要职。 回顾总结一下本文要点,UE在Pawn这个层级演化构成了一个最基本和非常完善的Component-Actor-Pawn-Controller的结构:
PlayerController和AIController
https://zhuanlan.zhihu.com/p/23649987
APlayerController
- Camera,对于PlayerCameraManager来进行管理摄像机。
- Input,有UPlayerInput 来委托管理。
- UPlayer关联,在SetPlayer后才可以正常工作,下面Player文章有简述。
- HUD,现在UMG比较多HUD是UE3迁移的组件。
- Level切换,在Level Travelling的时候会通过PlayerController进行RPC调用,从而转发到自己的World中进行
- Voice,服务于网络中语音聊天
思考:哪些逻辑应该放在PlayerController中?
对实现游戏逻辑来说,如果是按照MVC的视角,那么View对应的是Pawn的表现,而PlayerController对应的是Controller的部分,那Model就是游戏业务逻辑的数据了(PlayerState)。
AAIController
同PlayerController对比,少了Camera、Input、UPlayer关联,HUD显示,Voice、Level切换接口,但也增加了一些AI需要的组件:
- Navigation,用于智能根据导航寻路,其中我们常用的MoveTo接口就是做这件事情的。而在移动的过程中,因为少了玩家控制的来转向,所以多了一个SetFocus来控制当前的Pawn视角朝向哪个位置。
- AI组件,运行启动行为树,使用黑板数据,探索周围环境,以后如果有别的AI算法方法实现成组件,也应该在本组件内组合启动。
- Task系统,让AI去完成一些任务,也是实现GameplayAbilities系统的一个接口。具体去查询GAS系统。
思考:哪些逻辑应该放在AIController中?
我们推荐尽量利用UE提供的行为树黑板等组件实现。
AIController都是在关卡内比较短暂存在的,一般不太有跨Level的数据保存,所以你可以用AIController的成员变量来保存状态。
- 总结
GameMode和GameState
https://zhuanlan.zhihu.com/p/23707588
GameMode
作用
既然勇敢的承担了游戏逻辑的职责,说他是AInfo家族里的扛把子也不为过,因此GameMode身为一场游戏的唯一逻辑操纵者身兼重任,在功能实现上有许多的接口,但主要可以分为以下几大块:
- Class登记
- 游戏内实体的Spawn
- 游戏的进度
- Level的切换,或者说World的切换更加合适
- 多人游戏的步调同步,UE提供了一个MatchState来指定一场游戏运行的状态,就是用了一个状态机来标记开始和结束的状态,并触发各种回调。
思考:多个Level配置不同的GameMode时采用的是哪一个GameMode?
一个World里只会有一个GameMode实例,在后续的LoadStreamingLevel时候,并不会再动态创建出别的GameMode,所以GameMode从始至终只有一个,PersistentLevel的那个。
思考:Level迁移时GameMode是否保持一致?
UE的流程travelling,GameMode在新的World里是会新生成一个的,即使Class类型一致,即使bUseSeamlessTravel,因此在travelling的时候要小心GameMode里保存的状态丢失。不过Pawn和Controller默认是一致的。
思考:哪些逻辑应该写在GameMode里?哪些应该写在Level Blueprint里?
同Controller应用到Pawn一样道理,因为GameMode是可以应用在不同的Level的,所以通用的玩法应该放在GameMode里
GameMode只在Server存在(单机游戏也是Server),所以GameMode里不要写Client特定相关的逻辑
AGameModeBase 生命周期
AGameModeBase 在UE5中作为之前UE4的AGameMode的基类
所有 Game Mode 均为 AGameModeBase 的子类。而 AGameModeBase 包含大量可覆盖的基础功能。部分常见函数包括:
| 函数/事件 | 目的 |
|---|---|
InitGame |
InitGame 事件在其他脚本之前调用(包括 PreInitializeComponents),由 AGameModeBase 使用,初始化参数并生成其助手类。 它在任意 Actor 运行 PreInitializeComponents 前调用(包括 Game Mode 实例自身)。 |
PreLogin |
接受或拒绝尝试加入服务器的玩家。如它将 ErrorMessage 设为一个非空字符串,会导致 Login 函数失败。PreLogin 在 Login 前调用,Login 调用前可能需要大量时间,加入的玩家需要下载游戏内容时尤其如此。 |
PostLogin |
成功登录后调用。这是首个在 PlayerController 上安全调用复制函数之处。OnPostLogin 可在蓝图中实现,以添加额外的逻辑。 |
HandleStartingNewPlayer |
在 PostLogin 后或无缝游历后调用,可在蓝图中覆盖,修改新玩家身上发生的事件。它将默认创建一个玩家 pawn。 |
RestartPlayer |
调用开始生成一个玩家 pawn。如需要指定 Pawn 生成的地点,还可使用 RestartPlayerAtPlayerStart 和 RestartPlayerAtTransform 函数。OnRestartPlayer 可在蓝图中实现,在此函数完成后添加逻辑。 |
SpawnDefaultPawnAtTransform |
这实际生成玩家 Pawn,可在蓝图中覆盖。 |
Logout |
玩家离开游戏或被摧毁时调用。可实现 OnLogout 执行蓝图逻辑。 |
GameState
UE5.6 文档是这么介绍的
Game State 负责启用客户端监控游戏状态。从概念上而言,Game State 应该管理所有已连接客户端已知的信息(特定于 Game Mode 但不特定于任何个体玩家)。它能够追踪游戏层面的属性,如已连接玩家的列表、夺旗游戏中的团队得分、开放世界游戏中已完成的任务,等等。
Game State 并非追踪玩家特有内容(如夺旗比赛中特定玩家为团队获得的分数)的最佳之处,因为它们由 Player State 更清晰地处理。整体而言,GameState 应该追踪游戏进程中变化的属性。这些属性与所有人皆相关,且所有人可见。Game mode 只存在于服务器上,而 Game State 存在于服务器上且会被复制到所有客户端,保持所有已连接机器的游戏进程更新。
AGameStateBase
AGameStateBase 是基础实现,其部分默认功能包括:
| 函数或变量 | 使用 |
|---|---|
GetServerWorldTimeSeconds |
这是 UWorld 函数 GetTimeSeconds 的服务器版本,将在客户端和服务器上同步,因此该时间可用于复制,十分可靠。 |
PlayerArray |
这是所有 APlayerState 对象的阵列,对游戏中所有玩家执行操作时十分实用。 |
HasBegunPlay |
如 BeginPlay 函数在游戏中的 actor 上调用,则返回 true。 |
GameSession
是在网络联机游戏中针对Session使用的一个方便的管理类,并不存储数据,本文重点也不在网络,故不做过多解释,可暂时忽略,留待网络章节再讨论。在单机游戏中,也存在该类对象用来LoginPlayer,不过因为只是作为辅助类,那也可看作GameMode本身的功能,所以不做过多讨论。
UE5.6文档
虚幻引擎中的 Game Mode 和 Game State | 虚幻引擎 5.6 文档 | Epic Developer Community
俩者区别
这俩可以都理解为unity中的GameManager,主要体现的是服务器权威
| 特性 | GameMode | GameState |
|---|---|---|
| 存在范围 | 仅服务器 | 服务器 + 所有客户端(同步) |
| 数据权限 | 可读写 | 服务器可写,客户端只读 |
| 核心职责 | 规则执行 | 状态同步(如分数、时间、玩家列表) |
| 网络复制 | 不复制 | 自动同步至客户端 |
- 总结
Player
https://zhuanlan.zhihu.com/p/23826859
UPlayer
如图可见,Player和一个PlayerController关联起来,因此UE引擎就可以把输入和PlayerController关联起来。因为不管是本地玩家还是远程玩家,都是需要控制一个玩家Pawn的,所以自然也就需要为每个玩家分配一个PlayerController,所以把PlayerController放在UPlayer基类里是合理的。
ULocalPlayer
然后是本地玩家,从Player中派生下来LocalPlayer类。对本地环境中,一个本地玩家关联着输入,也一般需要关联着输出(无输出的玩家毕竟还是非常少见)。玩家对象的上层就是引擎了,所以会在GameInstance里保存有LocalPlayer列表。
UE4里的ULocalPlayer也如图所见,ULocalPlayer比UPlayer多了Viewport相关的配置(Viewport相关的内容在渲染章节讲述),也终于用SpawnPlayerActor实现了创建出PlayerController的功能。GameInstance里有LocalPlayers的信息之后,就可以方便的遍历访问,来实现跟本地玩家相关操作。
- 网络下的ULocalPlayer
- 在Server模式,会直接创建出ULocalPlayer,然后创建出相应的PlayerController。
- Client(比如Play的时候选择NumberPlayer=2,则有一个为Client),则会先发送JoinSplit消息到服务器,在载入服务器上的Map之后,再为LocalPlayer创建出PlayerController。
- 而在每个PlayerController创建的过程中,在其内部会调用InitPlayerState
- 这样LocalPlayer最终就和PlayerState对应了起来。而网络联机时其他玩家的PlayerState是通过Replicated过来的。
我们谈了那么久的玩家就是输入,体现在在每个PlayerController接受Player的时候
可见,对于ULocalPlayer,APlayerController内部会开始InitInputSystem(),接着会创建相应的UPlayerInput,BuildInputStack等初始化出和Input相关的组件对象。现在先明白到LocalPlayer才是PlayerController产生的源头,也因此才有了Input就够了,特定的Input事件流程分析在后续章节再细述。
思考:为何不在LocalPlayer里编写逻辑?
在Pawn-PlayerController和PlayerState组合已经为表示逻辑数据已经处理好了,就没必要处理在Level处理其他事情了。PlayerController就是Player在Level中的话事人。
UNetConnection
非常耐人寻味的是,在UE里,一个网络连接也是个Player:
包含Socket的IpConnection也是玩家,甚至对于一些平台的特定实现如OculusNet的连接也可以当作玩家,因为对于玩家,只要能提供输入信号,就可以当作一个玩家。
追根溯源,UNetConnection的列表保存在UNetDriver,再到FWorldContext,最后也依然是UGameInstance,所以和LocalPlayer的列表一样,是在World上层的对象。
GameInstance
https://zhuanlan.zhihu.com/p/24005952
可持续化单例GameManager
一人之下,万人之上
UE提供的方案是一以贯之的,为我们提供了一个GameInstance类。为了受益于UObject的反射创建能力,直接继承于UObject,这样就可以依据一个Class直接动态创建出来具体的GameInstance子类。
我并不想罗列所有的接口,UGameInstance里的接口大概有4类:
- 引擎的初始化加载,Init和ShutDown等(在引擎流程章节会详细叙述)
- Player的创建,如CreateLocalPlayer,GetLocalPlayers之类的。
- GameMode的重载修改,这是从4.14新增加进来改进,本来你只能为特定的某个Map配置好GameModeClass,但是现在GameInstance允许你重载它的PreloadContentForURL、CreateGameModeForURL和OverrideGameModeClass方法来hook改变这一流程。
- OnlineSession的管理,这部分逻辑跟网络的机制有关(到时候再详细介绍),目前可以简单理解为有一个网络会话的管理辅助控制类。
GameInstance是在GameEngine里创建的
思考:GameInstance只有一个吗?
一般而言,是的。
因为UE的这套Editor自绘机制,还有PIE(PlayInEditor),进程里其实是可以同时有多个GameInstance的
思考:哪些逻辑应该放在GameInstance?
作为游戏中全局唯一的长者,我们就应该给他全局的控制权。
- Worlds,Level 切换
- 动态添加删除Players
- 独立于Level的全局UI 切换
- 全局配置
- 第三方逻辑接入与管理
自然的GameInstance就应该存储一些全局的状态数据。所以你可以在GameInstance的成员变量中添加一些全局的状态,或者是那些想要在Level之外持续存在的对象。
SaveGame
得益于UObject的序列化机制,现在你只需要继承于USaveGame,并添加你想要的那些属性字段,然后这个结构就可以序列化保存下来的。
架构总结
通过对前九篇的介绍,至此我们已经了解了UE里的游戏世界组织方式和游戏业务逻辑的控制。行百里者半九十,前述的篇章里我们的目光往往专注在于特定一个类或者对象,一方面固然可以让内容更有针对性,但另一方面也有了身在山中不见山的困惑。本文作为GamePlay章节的最终章,就是要回顾我们之前探讨过的内容,以一个更高层总览的眼光,把之前的所有内容有机组织起来,思考整体的结构和数据及逻辑的流向。
游戏世界
如我们在最初篇所问的,如果让你来制作一款3D游戏引擎,你会怎么设计其结构?已经知道,在UE的眼里,游戏世界的万物皆Actor,Actor再通过Component组装功能。Actor又通过UChildActorComponent实现Actor之间的父子嵌套。(GamePlay架构(一)Actor和Component)
众多的各种Actor子类又组装成了Level(GamePlay架构(二)Level和World):
如此每一个Level就拥有了一座Actor的森林,你可以根据自己的需要定制化Level,比如有些Level是临时Loading场景,有些只是保存光照,有些只是一块静态场景。UE用Level这种细一些粒度的对象为你的想象力提供了极大的自由度,同时也能方便团队内的平行协作。
一个个的Level,又进一步组装成了World:
就像地球上的大陆板块一样,World允许多个Level静态的通过位置摆放在游戏世界中,也允许运行时动态的加载关卡。
而World之间的切换,UE用了一个WorldContext来保存切换的过程信息。玩家在切换PersistentLevel的时候,实际上就相当于切换了一个World。而再往上,就是整个游戏唯一的GameInstance,由Engine对象管理着。(GamePlay架构(三)WorldContext,GameInstance,Engine)
到了World这一层,整个游戏的渲染对象就齐全了。但是游戏引擎并不只是渲染,因此为了让玩家也各种方式接入World中开始游戏。GameInstance下不光保存着World,同时也存储着Player,有着LocalPlayer用于表示本地的玩家,也有NetConnection当作远端的连接。(GamePlay架构(八)Player):
玩家利用Player对象接入World之后,就可以开始控制Pawn和PlayerController的生成,有了附身的对象和摄像的眼睛。最后在Engine的Tick心跳脉搏驱动下开始一帧帧的逻辑更新和渲染。
数据和逻辑
说完了游戏世界的表现组成,那么对于一个GamePlay框架而言自然需要与其配套的业务逻辑架构。GamePlay架构的后半部分就自底向上的逐一分析了各个层次的逻辑载体,按照MVC的思想,我们可以把整个游戏的GamePlay分为三大部分:表现(View)、逻辑(Controller)、数据(Model)。一图胜千言:
(请点击看大图) 最左侧的是我们已经讨论过的游戏世界表现部分,从最最根源的UObject和Actor,一直到UGameEngine,不断的组合起来,形成丰富的游戏世界的各种对象。
- 从UObject派生下来的AActor,拥有了UObject的反射序列化网络同步等功能,同时又通过各种Component来组装不同组件。UE在AActor身上同时利用了继承和组合的各自优点,同时也规避了彼此的一些缺点,我不得不说,UE在这一方面度把握得非常的平衡优雅,既不像cocos2dx那样继承爆炸,也不像Unity那样走极端全部组件组合。
- AActor中一些需要逻辑控制的成员分化出了APawn。Pawn就像是棋盘上的棋子,或者是战场中的兵卒。有3个基本的功能:可被Controller控制、PhysicsCollision表示和MovementInput的基本响应接口。代表了基本的逻辑控制物理表示和行走功能。根据这3个功能的定制化不同,可以派生出不同功能的的DefaultPawn、SpectatorPawn和Character。(GamePlay架构(四)Pawn)
- AController是用来控制APawn的一个特殊的AActor。同属于AActor的设计,可以让Controller享受到AActor的基本福利,而和APawn分离又可以通过组合来提供更大的灵活性,把表示和逻辑分开,独立变化。(GamePlay架构(五)Controller)。而AController又根据用法和适用对象的不同,分化出了APlayerController来充当本地玩家的控制器,而AAIController就充当了NPC们的AI智能。(GamePlay架构(六)PlayerController和AIController)。而数据配套的就是APlayerState,可以充当AController的可网络复制的状态。
- 到了Level这一层,UE为我们提供了ALevelScriptActor(关卡蓝图)当作关卡静态性的逻辑载体。而对于一场游戏或世界的规则,UE提供的AGameMode就只是一个虚拟的逻辑载体,可以通过PersistentLevel上的AWorldSettings上的配置创建出我们具体的AGameMode子类。AGameMode同时也是负责在具体的Level中创建出其他的Pawn和PlayerController的负责人,在Level的切换的时候AGameMode也负责协调Actor的迁移。配套的数据对象是AGameState。(GamePlay架构(七)GameMode和GameState)
- World构建好了,该派玩家进来了。但游戏的方式多样,玩家的接入方式也多样。UE为了支持各种不同的玩家模式,抽象出了UPlayer实体来实际上控制游戏中的玩家PlayerController的生成数量和方式。(GamePlay架构(八)Player)
- 所有的表示和逻辑汇集到一起,形成了全局唯一的UGameInstance对象,代表着整个游戏的开始和结束。同时为了方便开发者进行玩家存档,提供了USaveGame进行全局的数据配套。(GamePlay架构(九)GameInstance)
UE为我们提供了这些GamePlay的对象,说多其实也不多,而且其实也是这么优雅有机的结合在一起。但是仍然会把一些朋友给迷惑住了,常常就会问哪些逻辑该写在哪里,哪些数据该放在哪里,这么多个对象,好像哪个都可以。比如Pawn,有些人就会说我就是直接在Pawn里写逻辑和数据,游戏也运行的好好的,也没什么不对。
如果你是一个已经对设计架构了然于心,也预见到了游戏未来发展变化,那么这么直接干也确实比较快速方便。但是这么做其实隐含了两个前提,一是这个Pawn的逻辑足够简单,把MVC的三者混合在一起依然不超过你的心智负担;二是已经断绝了逻辑和数据的分离,如果以后本地想复用一些逻辑创建另一个Pawn就会很麻烦,而且未来联机多玩家的状态复制也不支持。但说回来,人类的一个最常见的问题就是自大,对自己能力的过度自信,对未来变化的虚假掌控感。程序员在自己的编程世界里,呼风唤雨操作内存设备惯了,这种强大的掌控感非常容易地就外延到其他方面去了。你现在写的代码,过几个月后再回头看,是不是经常觉得非常糟糕?那奇怪了,当初写的时候怎么就感觉信心满满呢?所以踩坑多了的人就会自然的保守一些。另一方面,作为团队里的技术高手或老人,我个人觉得也有支持同行和提携后辈的责任,对自己而言只是多花一点点力气,却为别人树立一个清晰的程序结构典范,也传播了设计思想。程序员何苦为难程序员。
但还有一些人喜欢那么硬怼着干的原因要嘛是对未来的可预见性不足(经验不足),要嘛是对程序设计的基本原则不够了解(程序能力不够),比如最简单的“单一职责”。在新手期,面对着UE的程序世界,虽然在已经懂的人眼里就那么几个对象,但是在新手眼里,往往就感觉复杂无比,面对未知,我们本能的反应是逃避,往往就倾向于哪些看起来这么用能工作,就像玩游戏一样,形成了你的“专属套路”。跟穷人忙于工作而没力气提高自己是一个道理。相信我,所有的高手都是从小白过来的,我敢保证,他出生的时候脑袋也肯定是一片空白!区别是有些人后来不怕麻烦的勤能补拙,他努力的去理解这种设计模式的优劣,不局限于自己已经掌握的一片舒适区内,努力去设想未来的各种变化和应对之法,最终形成自己的独立思考。高手只是比新手懂得更多想得更多一些而已。
闲话说完。在分析UE这么一个GamePlay系统的时候,就像UML有各种图一样,我们也应该从各个切面去分析它的构成。这里有两大基本原则:单一职责和变化隔离,但也可以说只有一个。所有的程序设计模式都只是在抽象变化,把变化都抽离开了,剩下的不就是单一职责了嘛。所以UE里对MVC的实践其实也只是在不断抽离出各个对象的变化部分,把Pawn的逻辑抽出来是Controller,把数据抽出来是PlayerState。把World的Level静态逻辑抽出来是关卡蓝图,把动态的游戏玩法抽离出来是GameMode,把游戏数据抽离出来是GameState。具体的每个层次的数据和逻辑的关系前文已经一一详细说过了,此处就不再赘述了。但也再次着重探讨一些分析方法:
- 从竖直的角度来看,左侧是表示,中间是逻辑,右侧是数据。
- 当我们谈到表示的时候,脑袋里想的应该是一个单纯的展示对象,就像一个基本的网络物体,它可以带一些基本的动画,再多一些功能,也顶多只能像一个木偶,有着一些非常机械原始的行为。我们让他前进,他可以知道左腿右腿交替着迈,但他是无知觉的。所以左侧的那一串对象,你应该尽量得让他们保持简单。
- 实现中间的逻辑的时候,你应该专注于逻辑本身,尽量的忘记两旁的表示和数据。去思考哪些逻辑是表示固有的还是比较智能判断的。哪些Controller或Mode我们应该尽量的让它们通用,哪些就让它们特定的负责某一块,有些也不能强求,自己把握好度。
- 右侧的数据,同样的保持简单。我们把它们分离出来的目的就是为了独立变化和在网络间同步,注意一下别走回头路了就好。我们应该只在此放置纯数据。
- 从水平的切面上看,依次自底向上,记住一个原则,哪个层次的应该尽量只负责哪个层次的东西,不要对上层或下层的细节知道得太多,也尽量不要逾矩越权去指手画脚别的对象里的内务事。大家通力协作,注重隐私,保持安全距离,不就社会和谐了嘛。
- 最底层的Component,应该只是实现一些与游戏逻辑无关的功能。理解这个“无关”是关键。换个游戏,你这些Component依然可以用,就是所谓的游戏无关。
- Actor层,通过Pawn、Controller和PlayerState的合作,根据需要旗下再派生出特定的Character,或PlayerController,AIController,但它们的合作模式,三大家族的长老们已经定下了,后辈们应该尽量遵守。这一层,关键的地方在于分清楚哪些是操作Actor的,别向下把Actor内部的功能给抽了出来,也别大包大揽把整个游戏的玩法也管了过来。脑袋保持清醒,这一层所做的事,就是为了让Actor们显得更加的智能。换句话说,这些智能的Actor组合,理论上是可以在随便哪个Level里用的。
- Level和World层,分清楚静态的关卡蓝图和动态可组合GameMode。静态的意思是这个场景本身的运作机制,动态的指的是可以像切换比赛方式一样切换一场游戏的目的。在这一层上,你得有总览游戏大局的自觉了,咱们都是干大事的人,眼光就不要局限在那些一兵一卒那些小事了。制定好游戏规则,赋予这一场游戏以意义,是GameMode最重要的职责。注意两点,一是脑袋里有跟弦,一旦开始联机环境了,GameMode就升职到Server里去了,Client就没有了,所以千万要小心别在GameMode做些客户端的小事;二是GameState是表示一场游戏的数据的,而PlayerState是表示Controller的数据,对象和范围都不同,不能混了。
- GameInstance层,一般来说Player不需要你做太多事情,UE已经帮你处理好了。虽说力量越大,责任就越大,但领导日理万机累坏了也不行是吧。所以GameInstance作为全局的唯一逻辑对象,我们如果能不打扰他就尽量少把事推给他,否则你很快就会看着GameInstance里堆着一山东西。GameInstance身在高层,应该只尽量做一些Level之间的协调工作。而SaveGame也应该尽量只保存游戏持久的数据。
自始至终,回顾一下每个类的本身的职责,该是他的就是他的,别人的不要抢。读者朋友们,如果到此觉得似乎懂了一些,但还是觉得不够深刻理解的话,也没关系,凡事不能一蹴而就,在开发过程中多想多琢磨自然而然就会慢慢领悟了。
整体类图
从类的继承层次上,咱们再加深一下理解。下图只列出了GamePlay架构里一些相关的重要的类:
(请点击看大图) 由此也可以看出来,UE基于UObject的机制出发,构建出了纷繁复杂的游戏世界,几乎所有的重要的类都直接或间接的继承于UObject,都能充分利用到UObject的反射等功能,大大加强了整体框架的灵活度和表达能力。比如GamePlay中最常用到根据某个Class配置在运行时创建出特定的对象的行为就是利用了反射功能;而网络里的属性同步也是利用了UObject的网络同步RPC调用;一个Level想保存成uasset文件,或者USaveGame想存档,也都是利用了UObject的序列化;而利用了UObject的CDO(Class Default Object),在保存时候也大大节省了内存;这么多Actor对象能在编辑器里方便的编辑,也得益于UObject的属性编辑器集成;对象互相引用的从属关系有了UObject的垃圾回收之后我们就不用担心会释放问题了。想象一下如果一开始没有设计出UObject,那么这个GamePlay框架肯定是另一番模样了。
总结
对于GamePlay我们从构建游戏世界开始,再到一层层的逻辑控制,本篇也从各个切面上总结归纳了整体架构。希望读者们好好领会UE的GamePlay架构思想,别贪快,整体上慢慢琢磨以上的架构图,细节上可以回顾过往的单篇来了解。
对于这一套UE提供的GamePlay框架,我们既然选择了用UE引擎,那么自然就应该想着怎么充分利用好它。框架就是你如果在它的规则下办事,那它就是事半功倍的助力器,你会常常发现UE怎么连这个也帮你做完了;而如果你在不了解的情况下想逆着它行事,就常常感受到怎么哪里都受到束缚。我们对于框架的理念应该就像是对待一辆汽车一般,我们关心的是怎么驾驶它到达想要的目的地,而不是折腾着怪它四个轮子不能按照你的心意朝不同方向乱转。对比隔壁的Cocos2dx、或Unity、或CryEngine,UE能够提供这么一个完善的GamePlay框架,对我们开发者而言,是一件幸福的事,不是吗?
结束语
完结撒花!GamePlay大章节也终于结束了,最开始是本着怎么尽早尽大的能帮助到读者朋友们,所以选择了GamePlay作为起始章节。相信GamePlay也是开发者们日常开发过程中接触最多,也是有可能混淆最多,概念不清,很容易用错的一块主题。在介绍GamePlay的时候,更多的重点是在于介绍各对象的职责和关联,所以更多是用类图来描述结构,反而对源码进行剖析的机会不多,但读者们可以自己去阅读验证。希望GamePlay架构的一系列十篇文章能切实地帮助到你们。
Subsystems
专业术语
- Subsystems:指的是这整套“子系统”框架,包含了定义的类以及运作机制。
- SubsystemType****/SubsystemClass:指向的是Subsystem的类型,比如TSubclassOf。
- Subsystem对象:指的是真正创建生成实例化出来的Subsystem对象。
- UMyXXXSubsystem: 用户定义的类,我会以My为前缀来区分。
- 5类Outer对象:Subsystem对象依存属于的5类Outer对象。
Subsystems是什么?
一句话:Subsystems是一套可以定义自动实例化和释放的类的框架。这个框架允许你从5类里选择一个来定义子类(只能在C++定义):
- 自动实例化
这些的UMyXXXSubsystem类,会在合适的时机被创建出对象,然后在合适的时机释放,这个过程是自动化的。不需要自己手写创建代码。也不需要自己显式的定义变量,Subsystems已经定义好方便友好的访问接口了。
- 托管生命周期
根据你选择的父类不同,引擎会为创建出来的Subsystem实现出不同的生命周期。因此官方文档里会称这5个父类为5个不同的生命周期。根据你选择的生命周期不同,Initialize()和Deinitialize()会自动的在合适的时机被调用。一个Subystem类型也有可能根据需要被自动的被创建出多个实例。这些里面的繁琐逻辑自己都不用操心。
简易版本
这边是放牛的星星大佬的版本
Unreal Engine的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将覆盖在相机提供的视角上,显示目前游戏中的第一名和已进行的游戏时间。
GameMode
在上面这个例子中,GameMode 决定的是游戏规则,即拥有两个角色,先跨过终点线的玩家为冠军。衍生的部分还有比如是否允许观战以及观战的人数最多为多少?玩家如何进入游戏,以及使用哪张比赛地图?游戏是否可以暂停,以及暂停之后如何恢复?游戏是否允许使用道具,又或者是否可以在游戏中作弊等,这些规则都是跑在服务器上的,确保规则的权威性和安全性。
GameMode在Unreal里的实现是AGameModeBase类(用A开头是因为它继承于Unreal的AActor,这是Unreal的类命名规则,可以查看代码规范)
主要函数:
AGameModeBase提供若干基础的、可被override的接口:
- InitGame。 在这里做所有游戏规则的初始化工作。
- PreLogin 。登录前的预处理。由于GameMode只会跑在服务器上,可以在这里检查玩家的合法性,判定是否允许玩家登录服务器。
- PostLogin。登录后的后处理。玩家成功登录服务器之后的调用。
- HandleStartingNewPlayer。一般登录成功之后就会创建玩家在服务器上的对象,对象创建成功之后会调用该函数,可以在这里对玩家进行初始化,比如获取玩家的PlayerState。
- RestartPlayer。创建玩家的实体对象(可操控的,场景上可见的Pawn对象)。
- Logout。玩家退出或者服务器被销毁时调用。
Game State
字面意思,Game State 就是指游戏状态。它管理了所有已连接的客户端,并且实时追踪游戏层面的属性并把它们分发给远程客户端。有别于Play State,GS(GameState)主要是负责游戏全局属性,比如5V5Moba游戏中的红蓝双方防御塔的剩余数量,游戏当前进行的时间,大小龙击杀的情况,红蓝阵营野怪刷新情况等等。而PS(Player State)则是记录单个玩家的属性和状态,比如补了多少刀,出了什么状态,身上有多少钱,技能冷却时间等等。
主要函数:
- GetServerWorldTimeSeconds 服务器版本的游戏时间,权威可靠的,会被同步在客户端。
- PlayerArray。所有APlayerState的列表,对游戏中玩家执行操作和逻辑时候非常有用。
- BeginPlay。
Camera
接下来是大名鼎鼎的“3C”之一的Camera(相机)。在面试的时候,对于中初级的开发同学我一般都会跟他探讨一个话题:“你怎么理解3C?”
而得到的回答很多都是字面意思,相机,控制,和角色。如果健谈一点的同学可能还会补充一下,代表一个游戏的基础体验。但我其实更希望能听到他们举一些例子(无论是自己做过的还是别的游戏的),来说明如何通过这些模块来提高玩家的基础体验甚至变成游戏玩法的一部分。
相机在游戏中其实是代表了玩家的视角,以及玩家如何去观察这个“世界”。它不但会关联渲染,给管线提供必要的渲染内容可视性和遮挡剔除,同时也承载这渲染完成之后的后处理效果后期处理效果。但更多的是,如何使用相机的组件模块来完成更好的游戏体验和沉浸感。比如以下列举一些相机组件完成的游戏体验:
- 《英雄联盟》中,盖伦使用R斩杀了敌人之后,画面会表现出气浪冲击波的效果。
- 《尘埃》赛车游戏中,通过切换不同视角来完成第一人称和第三人称的驾驶体验。同时可以通过额外的摄像机渲染来完成后视镜的效果。
- 《黎明杀机》中,屠夫(第一视角)和逃生者(第三视角)的游玩视角不一样。屠夫可以通过佩戴“鹰眼”的技能来让视野变成类似于水滴透镜的效果,从而得到更开阔的视野。
- 《鬼泣》中,通过切换固定摄像机视角来完成走廊到房间的视角切换。或者模拟一个虚拟演唱会上的导播相机调度。
- 飞行游戏中可以通过设置轻微的动画来模拟穿过气流的颠簸感。航海游戏可以通过设置轻微的动画来表达海浪对船造成的轻微摇摆。常规的3D游戏可以使用弹簧臂的形式,让玩家躲在墙角或者被建筑遮挡的时候,相机不会穿模。
- 射击游戏中,通过改变相机的FOV参数完成狙击枪的模拟。格斗或者动作游戏中可以通过调用相机震动来调优“打击感”。
关于相机提升基础体验,总结为两点:
Character
提到角色,就需要先提一下他的父类Pawn(棋子)。UE中,把所有可以在游戏中视觉看到的东西都称之为Pawn。比如一张桌子,一块石头,一个池塘等。Pawn继承自Actor,并且一个Pawn需要很多个组件和它一起作用。
比如场景上有一个金矿石:
- 它的位置、旋转和缩放由 SceneComponent 中定义的Transform信息所决定。
- 它的可视化样子由 StaticMeshComponent 决定。
- 它如果发光就需要绑定一个粒子组件ParticleSystemComponent 。
- 它如果需要和周围环境进行交互,有实际的物理体积就需要绑定一个碰撞盒组件BoxComponent 。
回到角色上来,一个Character就是一个特殊的,可以行走的Pawn,一般代表垂直站立的玩家。也就是说它比Pawn多了 CharacterMovementComponent,同时,因为一个可行走的模型需要提供一些行走动画,所以还需要SkeletalMeshComponent 组件来提供骨骼框架,由于人的形状和盒子差别很大,所以在物理碰撞上用胶囊体CapsuleComponent来替换碰撞盒。
角色组件是一个Avatar,代表玩家在和游戏场景交互。并且可以在场景中行走、跑动、跳跃、飞行和游泳等,同样作为一个Actor,它也包含基础的网络功能,并接受玩家的输入控制。当然可以可以任意扩展和使用Character。
Controller
那么到Gameplay框架中,我们仍然能找到一个比较合适的部分来套用这套MVC。比如我们现在的M就是Player State,我们的V就是Character,那么C自然就是马上要介绍的Controller了。 AController继承自AActor,也就是说它并没有场景实体,是一个场景不可见的对象。它拥有一个PlayerState,一个Pawn,如果这个Pawn同样是Character的话,那么它还有一个不为空的Character对象。
默认情况下,一个控制器只对应一个Pawn,二者之间也非强绑定关系而是组合关系。如果需要更改默认的控制器逻辑,可以自定义继承实现。
控制器会接收其控制的Pawn所发生诸多事件的通知。因此控制器可借机实现响应该事件的行为,拦截事件并接替Pawn的默认行为。 控制器又分为两种不同的类型
- Player Controller 。代表玩家的输入和控制。
- AI Controller 。代表AI或者远程玩家在本地的镜像。
其中Player Controller是玩家直接操控角色的逻辑类,因此非常复杂。大体可以分为Camera管理,Input响应,UPlayer关联和操控,HUD显示,关卡切换的逻辑处理,音效部分等等。而AI Controller因为不需要接受玩家操控,因此对Camera、Input、UPlayer关联,HUD显示,Voice、Level切换等部分都不是必须的,但对应的它增加了一些额外的模块,比如Navigation(导航),行为树,Task系统等实现。
HUD 和UI
HUD可以理解为对部分Player State的场景可视化。比如怪物或者人物头顶的血条,名字等等。而UI则是覆盖在场景渲染之上,提供更多玩家交互和查看的信息。二者的主要区别是在交互上,HUD一般来说是不能交互的,简略的信息;而UI则指的是菜单和其他互动元素。这部分不展开细说,可以参考 Slate UI编程
Actors
Actors。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 用来展示飞机螺旋桨,风车或者任何可以旋转的东西。

