duyoli 发表于 2023-3-22 14:48

UE5 新项目Gameplay框架设计(以Lyra为例)

话接上回,Unreal Engine的Gameplay框架和重点 ,我们在最后聊到了Lyra的Gameplay设计,但鉴于篇幅的长度还是拆分了一下。伴随着Unreal 5一起发出的两个Demo项目,古代山谷(《古代山谷》示例)和Lyra(Lyra示例游戏)都是展示Unreal5引擎在游戏开发方向的特性和用法的极佳案例。




Lyra是一个第三人称的射击游戏,提供了几种不同的游戏玩法。积分获胜,人头获胜,测试场景,甚至还有经典的泡泡堂的玩法。







分析Gameplay框架,我们还是从ModularGameplayActors这个插件开始 UE5 ModularGamePlay相关理解。




如上一篇所说,ModularGameplay作用就是在游戏引擎的Gameplay和项目的Gameplay实现之间隔离一个缓冲带,目的是让任意一方的改动都不至于影响另外一方,既带来项目的自由度,又能便捷的做引擎维护和升级。它本身并没有任何逻辑,只是提供了一些供项目继承的基类。代码就不一一列了,都只是提供基础的可覆写函数。



介绍Lyra的Gameplay之前,先聊两个扩展的话题,Experience和GameFeature。前者是Lyra项目自己引入的全新Gameplay侧的项目实现,后者则是Unreal Engine5引入的全新的类似于DLC的工作机制。

1 Experience


关于Experience。官方的描述是这样的:
Experience是使用 LyraExperienceDefinition 类进行定义的。位置在 工具栏(Toolbar) > 窗口(Window) > 世界设置(World Settings) > 游戏模式(Game Mode) 下,可以在World Settings中访问 Default GameplayExperience 。

你可以将Experience视为Game Mode的高级版本。在插件中可以存在多种Experience,它们从同一个父类(B_LyraShooterGameVase ,这是LyraExperienceDefinition的子类)继承而来。
这些自己实现的Experience类其实包含非常多的额外信息,比如:地图,UI,玩家输入,角色技能以及一些游戏规则等自定义的配置。 LyraExperienceDefinition本身继承自 UPrimaryDataAsset(【UE4 C++】 UDataAsset、UPrimaryDataAsset 的简单使用 - 砥才人 - 博客园),也就是说它其实就是一种UE用到的数据配置的封装。其自身实现的逻辑并不多,主要定义了自身的PawnData,定义和管理了自身所使用的GameFeature和一些GameFeatureAction。


当然还可以通过继承的方式来修改不同Experience所引用的资源和逻辑。下面是Lyra定义的多种玩法的Experience。


总结来说,Experience算是GameMode的补充版本,它定义了更为高级的游戏规则和资源配置。但是它又不是完全放在服务器上的,它可以通过GameState的方式将数据同步至客户端。这里有对它的较为全面的总结:UE5 Lyra示例项目解读(二)。关于Experience的初始化方式,我们留到下面GameMode的章节聊。
2 GameFeature


关于GF的规则我还是推荐去看大钊的系列:《InsideUE5》GameFeatures架构(一)发展由来。我对这个架构的理解就是传统游戏中DLC的概念。
通过提前设计模块化的功能来完成功能的动态扩展。拿我比较喜欢玩的一个系列:全面战争来说,每一作都会有一个叫做血腥包的DLC,如果你购买了血包,那么在战斗中你会看到满屏的血液特效和残肢断臂。



拿大钊的一个图来举例,把一个完整的游戏比作成一台主机,那么主板就是UnrealEngine本身提供的能力,而CPU,内存和GPU等就是随着引擎一起发布的各种Plugin组件。他们共同做成了开发时态下的CoreGame,也就是说缺少了任何一个组件,这台主机的运行就有问题。
但GameFeature的概念就像是USB接口,是主机已经能成功开机并且正常运行之后,动态插拔的组件。可以理解为GameFeature是一种特殊的,可以在Runtime下进行插拔的Plugins(正常的Plugin是在开发时态下静态引入并参与编译的)。


GameFeature是一套全新的运行机制,并在Lyra和古代山谷中都有用法展示。基于可动态插拔这个理念,我们在游戏中可以有相当多的应用。比如运营活动的动态开启,比如特效或者资源分级,比如资源的分包加载等等都可以通过这个机制来实现,当然这是后话。之所以要重点介绍他,是因为Lyra在Experience的实现中,将GameFeature 的各种开关和状态进行了完美的融合。

3 LyraGameMode


终于开始说GameMode了,前面我们提过,GameMode决定了一局游戏的玩法规则,它只存在于服务器上。LyraGameMode核心初始化还是需要继承GameModeBase的,只不过因为所有相关的gameplay基类都重写了,所以它也需要做一些额外的逻辑来初始化Lyra相关的独特部分。
由于所有跟Gameplay的组件都进行了重写,所以LyraGameMode的构造函数需要对所有相关的模块进行重新指定。




由于前面提到Lyra引入了全新的Experience机制,它接管了一部分GameMode的职能,但是它又涉及到了资源和UI等内容的需要同步给客户端,所以它会以GameState的形式存在。


ULyraExperienceManagerComponent作为LyraGameState的成员,它会跟着GameState一起初始化,并在GameMode的InitGameState中注册加载完成的回调函数。


然后就到了 InitGame 逻辑。除了继承了GameModeBase的基本逻辑之外,用到了一个TimerManager的下一帧逻辑,加载核心的资产。


HandleMatchAssignmentIfNotExpectingOne函数主要是处理Experience的配置方式。函数会按照顺序依次查找Experience的配置。

[*]Matchmaking assignment (if present)
[*]URL Options override
[*]Developer Settings (PIE only)
[*]Command Line override
[*]World Settings
[*]Default experience
Lyra使用的是最后一个Default。


当找到Experience资源ID之后,会设置到当前服务器上并开始资源加载流程。



关于 LyraExperienceManagerComponent 本篇就不细说了,了解详情可以看这个:UE5 Lyra的数据驱动之数据加载与引用 。简单来说 LyraExperienceManagerComponent 定义几种不同优先级的回调函数,调用顺出依次为高,普通和低。

[*]CallOrRegister_OnExperienceLoaded_HighPriority
[*]CallOrRegister_OnExperienceLoaded
[*]CallOrRegister_OnExperienceLoaded_LowPriority
开发者可以将自己的回调函数根据实际情况注册为三种之中的某种,来决定它在加载完成之后的回调时机。


上面这个回调的逻辑是在业务完全加载之后完成的,对应的函数为OnExperienceFullLoadCompleted。
前面我们提到过,Experience是完整处理了GameFeature的,所以完整的流程如下:


按上图所示,StartExperienceLoad之后就会开始加载Experience资源,当资源加载完成之后会马上调用OnExperienceLoadComplete函数,并开始加载GameFeature相关的内容。


当所有的GameFeaturePlugin都加载完成之后,开始执行GameFeature的相关逻辑。


最后开始调用OnExperienceLoaded注册的回调函数。


在Lyra中注册是普通的OnExperienceLoaded函数。也就是代码逻辑绕着Experience相关的初始化执行了一圈之后,主动权又回到了GameMode中,开始处理玩家相关的逻辑。


现在球来到了PlayerCanRestart函数这,这是GameModeBase基类中的函数,定义玩家是否可以重生。第一次创建角色也是走的重生逻辑。同样的,基类里还会提供玩家的重生点之类的函数。LyraGameMode里重写了该函数,目的是将控制权转移到项目测。


这里启用了 ULyraPlayerSpawningManagerComponent 类来接管玩家出生相关部分的逻辑。同样接管的还有几个父类函数ChoosePlayerStart,FinishRestartPlayer。

如果可以重生玩家,就会执行 基类的RestartPlayer逻辑。重生的函数里又会调用一些跟Pawn相关的逻辑,由于Lyra对Pawn也进行了覆盖和继承,所以LyraGameMode里对这部分的接口也需要覆写,以便传递Lyra的Pawn和PawnData.






到这里为止,Lyra所重写的GameMode类逻辑就基本结束了。还有几个没有提到的都是不太重要的判定以及少量的逻辑覆写。那么总结一下LyraGameMode的改造内容:

[*]首先,Lyra所有的Gameplay相关的类都做了拓展,因此在获取或者设置相关类的时候,需要覆写原有函数来传递Lyra开头的类,比如LyraPawnData,LyraGameState等。
[*]然后,Lyra新拓展了一个ULyraPlayerSpawningManagerComponent组件,用来接管所有玩家出生相关的逻辑,所以跟玩家出生相关的函数也需要覆写。
[*]最后,Lyra新引入了一个Experience概念,用来配合原有的Gamemode做资源的管理,数据和状态的同步等,所以ULyraExperienceManagerComponent需要先初始化,并在原有的Gameplay流程中插入Experience的加载流程。
按照调用顺序大概可以表述如下:

[*]InitGameState。新增了ULyraExperienceManagerComponent的加载完成回调。
[*]InitGame。查找相关的Experience配置,并完成Experience的加载。
[*]OnExperienceLoaded 。Experience资源加载完成之后回调,并执行RestartPlayer相关的逻辑。
[*]RestartPlayer。这里引入了ULyraPlayerSpawningManagerComponent来管理玩家的重生状态,并影响到所有跟玩家出生相关的逻辑。
4 LyraGameState


相比于lyraGameMode而言,LyraGameState对父类的改造就少了很多。它主要做了以下几件事:

[*]设置FPS
[*]初始化 ULyraExperienceManagerComponent 组件。
[*]初始化ULyraAbilitySystemComponent 组件。
[*]使用UGameplayMessage实现了全新的MessageBroadcast系统


4.1 FPS


FPS主要就是将服务器上现在的FPS通告给所有客户端玩家。它在tick下执行每帧的赋值。



GAverageFPS是引擎本体的全局变量,它在每一帧的FEngineLoop::Tick()中执行相关的计算逻辑。关于引擎的启动流程可以参考Unreal Engine 的启动流程 。
在tick中调用 CalculateFPSTimings 完成FPS的计算。


这里的平均帧率有个小的算法,用之前平均值的0.75 + 当前帧时间的0.25。
那么当前帧的执行时间算法又有所不同,常规的是按照本帧的时间-上帧的时间,但如果开启了垂直同步,并且同步的类型是2(Game线程与GPU的垂直同步时间同步:UE4 关于主循环的资料)的时候去获取RHI的每帧时间。



4.2 初始化 ULyraExperienceManagerComponent


这个在Gamemode时候已经讲过了,不细讲。

4.3 初始化 ULyraAbilitySystemComponent


在构造函数里初始化了组件,并设置了同步模式。



这里主要是将ASC组件(关于ASC组件本篇不细说,可以粗略的裂解为整个技能框架系统中非常重要的调度和同步组件,简单理解参考:二、GAS中的技能系统组件AbilitySystemComponent)做了一个初始化,并绑定在了GameState上。未来需要使用的时候可以通过FindComponent来找到。



因为LyraGameState继承了IAbilitySystemInterface接口,它同样也提供了一个GetAbilitySystemComponent的接口可以找到当前的ASC组件。




作为一个ASC组件,GameState下的ULyraAbilitySystemComponent被用来向客户端同步当前的游戏阶段(GamePhase)。这个逻辑是合理的,因为当前游戏阶段数据确实应该归属于GameState。而与此同时,Lyra又扩展了一个ULyraGamePhaseSubsystem系统负责管理游戏阶段。以下是阶段变化的蓝图调用:




4.4 使用UGameplayMessage实现了全新的MessageBroadcast系统


Lyra自己实现的一套高效的消息广播机制,【UE5】GameplayMessageRouter 插件源码分析 。它可以让蓝图与c++针对不指定具体类型的结构体进行交互。插件比较复杂,但在这里其实就调用了一个方法,不细说。





5 UGameFrameworkComponent


本来不太想讲UGameFrameworkComponent的,但后面的几个内容都跟它有比较深的关系,所以还是拎出来聊一下。先看下大钊的这篇介绍:《InsideUE5》GameFeatures架构(五)AddComponents。




如上图所示,这个子的框架系统也是从GameFeature的GameinstanceSubsystem 继承而来。同时也拓展了多个Gameplay模块,比如GameState、Controller、Pawn、PlayerState等。




ModularGameplayModule插件是5.1引擎内置的,目前还是实验阶段。但在Lyra下已经大量使用了,包括我们前面聊到的ULyraExperienceManagerComponent的基类都是UGameFrameworkComponent。前面我们也提到了,虽然Experience是定义规则的,但它需要和客户端进行同步,所以它得归属GameState而不GameMode。






关于UGameFrameworkComponent本篇仍然不会展开细讲,详细了解可以看看这篇:【UE5】聊聊ModularGameplay 。

那么粗略的还是需要聊一下的。

之前我们有说过整个UE的Gameplay框架就像是一个MVC的架构,M就是PlayerState,V是Character,C就是Controller。但一个复杂的游戏,它的这几个部分的逻辑一定是又臭又长的,比如Unreal自带的PlayerController已经是一个6000多行的类了,所有的控制逻辑都写在一起了。
那么既然Unreal 本身已经是一个Actor - Component的组织形式了,那么我再给每个核心类加一些Component组件来分担它们的任务,就会让逻辑变得更加清晰和简单。比如下面这个就把 CharacterParts的逻辑部分拆离出来了。






当然,它还是需要一个UGameFrameworkComponentManager来管理。由于Gameplay框架存在了多年,突然要插入一个ComponentManager来管理Component逻辑,又不能影响整体设计,所以就只能写在自定义的Modular基类里了。比如LyraGameState从ModularGameState(基类是GameStateBase)继承,在PreInitializeComponents 的时候完成了ComponentReceiver 的逻辑注入。之所以用注入这个词,是因为它真的是侵入式的修改,它需要在类初始化的时候动态调用Static的接口。




它的主要职责就是在Actor实例化的时候他绑定对应的Component,并且在Actor中通过各种事件来通知Component做事,比如GameState就创建GameStateComponent组件。




6 LyraPlayerController


它受到了三个层面的影响。




6.1 CommonPlayerController


lyraPlayer的Controller继承关系很深,CommonPlayerController上面还有一个ModularPlayerController,然后才是UE自己的PlayerController。

前面说过,PlayerController因为拆分了很过个相关的Components,所以它需要在ModularPlayerController里注册GameFrameworkComponent的接收器。




由于ControllerComponent的逻辑是需要自定义的,所以它必须实现两个函数ReceivedPlayer 和 PlayerTick。前者做Component的初始化,后者做Component的逻辑执行。那么它就要在Controller里覆写这两个函数,调用所有的Component的执行。




然后逻辑来到CommonPlayerController中,这里主要是扩展了本地玩家(LocalPlayer)在接收到相关的数据之后的后处理,也就是初始化LocalPlayer。




CommonPlayerController的的逻辑也就是接收到相关数据之后,对LocalPlayer的回调函数进行广播。




然后就来到了LyraPlayerController自己的部分,除了对常规的Lyra特殊化继承的一些类进行类型转换之外,它自己则添加了服务器作弊的功能。




6.2 ILyraCameraAssistInterface


这个接口主要处理了场景相机和物体穿插的情况。包含了3个接口:


[*]GetIgnoredActorsForCameraPentration 收集可以让摄像机穿透的Actors
[*]GetCameraPreventPenetrationTarget 收集不能让摄像机穿透的Actors
[*]OnCameraPenetratingTarget 当穿透的行为发生了之后,事件响应。
这里的逻辑对前两个没有做实现,但对发生了穿透行为之后做了一些处理,收集了一些需要隐藏的对象。但没有写完,保留了一些可能性给开发者。




6.3 ILyraTeamAgentInterface


由于Lyra是一个红蓝阵营对行的TPS游戏,所以直接在PlayerController里实现了一些跟Team相关的逻辑实现,不细说。




7 LyraPlayerState


和Controller一样,ModularPlayerState也需要对GameplayComponent进行注册。但相比于Controller而言,它并不需要PlayerTick和ReciverPlayer,但由于它是管理数据的,它需要让所有的Component执行数据拷贝,所以它覆写了CopyProperties。




和Controller一样,它需要处理一些跟队伍有关的数据。



PlayerState依赖 LyraAbilitySystemComponent 进行数据同步,所以它需要对ASC进行初始化和相关的逻辑执行。


由于ASC一般和GameTag一起使用,所以还需要准备一些对tag的管理和包装。这里使用的是TagStack。


PlayerState一般都需要绑定一个Pawn,这个逻辑在上面提到的Experience加载完成之后做。



8 LyraCharacter


继承自ModularCharacter,老样子注册GamePlayComponent。由于Character已经涉及到各种表现了,所以它处理的东西比较多。
和上面两个一样,他需要处理队伍相关的内容,略过。
和上面两个一样,他需要处理AbilitySystem,初始化和调用,略过。
处理GamePlayTags,和PlayerState不一样的是,PS只是收集tag,而这里需要对tag做各种逻辑判断和处理。



除了上面的继承逻辑之外,就是正常的游戏玩法逻辑了,


Pawn相关的扩展类,血量管理,相机管理等。 玩家对角色的控制,移动逻辑啥的都在这里执行。比如蹲下:


由于Character类,把大部分逻辑都分出去了,所以本体的LyraCharacter并没有太多的内容,它可以被作为转发器和收集器来使用。





9 LyraHUD


这个很简单,从HUD继承,除了和上面一样需要注入GameframeworkComponent之外,覆写了GetDebugActorList函数。除了将父类原有类型的Actor加入到showdebug的相关逻辑以外,还增加了带有AbilitySystemComponent的Actor。

10 LyraGameSession


这个只是做了接口预留,什么逻辑都没有。




好吧,Gameplay框架本身肯定不止这点东西,这里是跟着上一篇的视角来着重分解了几个“狭义”上的Gameplay实现。

相比于Unity的灵活便利而言,Unreal在Gameplay上倾向于固化和保守。当然两个极端不能说谁比谁设计好,只能说都有优劣。

Unity的过度灵活,让每个开发者都需要思考一套自己的Gameplay启动流程,所以大家百花齐放,没有统一标准和认知,所以从一个项目到另一个项目会有多余的学习成本,但优势就在于它有一个更高的上限,让优秀的人根据不同的游戏类型来完成不同效能的Gameplay流程。

Unreal的保守让开发者完成一次学习之后,能应对几乎所有的Unreal项目,不管你是大世界,SLG,还是动作类型,总归是离不开以上我们介绍的这些概念。但对应的代价就是初学时非常痛苦,这也是新人入门Unreal难的重要原因之一。

尤其是UE5又做了这么些拓展和引申之后,学习的成本又加了一些。不过好在是缓解了3C模块过往一个.h大几千行的尴尬。不过从GameplayComponent的是实现上看,还可以更优雅一些,用反射来代替侵入式的修改会好很多。

好了,又是9000多字的长篇了,今天就先到这里了,下一篇填坑:

Unreal 5中的网络同步能力和同构服务器设计

Ilingis 发表于 2023-3-22 14:53

大牛这是属于技痒了,不写字不行

Arzie100 发表于 2023-3-22 15:00

[抱抱]

ChuanXin 发表于 2023-3-22 15:05

Mark Mark[调皮]
页: [1]
查看完整版本: UE5 新项目Gameplay框架设计(以Lyra为例)