# GameFeatures学习笔记 主要参考了大钊的系列文章: - 《InsideUE5》GameFeatures架构(二)基础用法 https://zhuanlan.zhihu.com/p/470184973 - 《InsideUE5》GameFeatures架构(三)初始化 https://zhuanlan.zhihu.com/p/473535854 - 《InsideUE5》GameFeatures架构(四)状态机 https://zhuanlan.zhihu.com/p/484763722 - GameFeatures插件实现了Actions执行和GameFeature的装载 - ModularGameplay插件实现AddComponent等操作 通过插件创建向导新创建的GameFeature会放在**GameFeatures**文件夹中,也必须放在**GameFeature**文件夹中。并且会创建**GameFeatureData**并且想AssetManager注册。GameFeatures的运行逻辑为:调用ToggleGameFeaturePlugin=>触发GameFeatureAction=>做出对应的操作。 做出对应Action操作的Actor对象需要调用UGameFrameworkComponentManager的AddReceiver()与RemoveReceiver()进行注册与反注册。 - GameFeature,缩写为GF,就是代表一个GameFeature插件。 - CoreGame,特意加上Core,是指的把游戏的本体Module和GameFeature相区分开,即还没有GF发挥作用的游戏本体。 - UGameFeatureData,缩写为GFD,游戏功能的纯数据配置资产,用来描述了GF要做的动作。 - UGameFeatureAction,单个动作,缩写GFA。引擎已经内建了几个Action,我们也可以自己扩展。 - UGameFeaturesSubsystem,缩写为GFS,GF框架的管理类,全局的API都可以在这里找到。父类为UEngineSubsystem。 - UGameFeaturePluginStateMachine,缩写为GFSM,每个GF插件都关联着一个状态机来管理自身的加载卸载逻辑。 - UGameFrameworkComponentManager,缩写为GFCM,支撑AddComponent Action作用的管理类,记录了为哪些Actor身上添加了哪些Component,以便在GF卸载的时候移除掉。 ## 初始化 ### UGameFeaturesProjectPolicies GameFeature的加载规则类。UE实现一个了默认类为UDefaultGameFeaturesProjectPolicies。默认是会加载所有的GF插件。如果我们需要自定义自己的策略,比如某些GF插件只是用来测试的,后期要关闭掉,就可以继承重载一个自己的策略对象,在里面可以实现自己的过滤器和判断逻辑。 - GetPreloadAssetListForGameFeature,返回一个GF进入Loading要预先加载的资产列表,方便预载一些资产,比如数据配置表之类的。 - IsPluginAllowed,可重载这个函数来进一步判断某个插件是否允许加载,可以做更细的判断。 可以在项目设置中修改所使用的类。大钊介绍了Additional Plugin Metadata Keys的用法: >举个例子,假设2.0版本的游戏要禁用掉以前1.0版本搞活动时的一个GF插件,可以继承定义一个我们自己的Policy对象,然后在Init里的过滤器里实现自己的筛选逻辑,比如截图里就示例了根据uplugin里的MyGameVersion键来指定版本号,然后对比。这里要注意的是,**要先在项目设置里配置上Additional Plugin Metadata Keys**,才能把uplugin文件里的自定义键识别解析到PluginDetails.AdditionalMetadata里,才可以进行后续的判断。至于要添加什么键,就看各位自己的项目需要了。 ![](https://pic3.zhimg.com/v2-eb32702550e4d8517b1bcdbc08620c8a_r.jpg) ![](https://pic1.zhimg.com/v2-30bb4136f249d6a50ea0121bd53b4c04_r.jpg) ```c++ void UMyGameFeaturesProjectPolicies::InitGameFeatureManager() { auto AdditionalFilter = [&](const FString& PluginFilename, const FGameFeaturePluginDetails& PluginDetails, FBuiltInGameFeaturePluginBehaviorOptions& OutOptions) -> bool { //可以自己写判断逻辑 if (const FString* myGameVersion = PluginDetails.AdditionalMetadata.Find(TEXT("MyGameVersion"))) { float verison = FCString::Atof(**myGameVersion); if (verison > 2.0) { return true; } } return false; }; UGameFeaturesSubsystem::Get().LoadBuiltInGameFeaturePlugins(AdditionalFilter); //加载内建的所有可能为GF的插件 } ``` GF插件的识别是从解析.uplugin文件开始的。我们可以手动编辑这个json文件来详细的描述GF插件。这里有几个键值得一说: - BuiltInAutoState,这个GF被识别后默认初始的状态,共有4种。如果你设为Active,就代表这个GF就是默认激活的。关于GF的状态我们稍后再详细说说。 - AdditionalMetadata,这个刚才已经讲过了被Policy识别用的。 - PluginDependencies,我们依然可以在GF插件里设置引用别的插件,别的插件可以是普通的插件,也可以是另外的GF插件。这些被依赖的插件会被递归的进行加载。这个递归加载的机制跟普通的插件机制也是一致的。 - ExplicitlyLoaded=true,必须为true,因为GF插件是被显式加载的,不像其他插件可能会默认开启。 - CanContainContent=true,必须为true,因为GF插件毕竟至少得有GFD资产。 ![](https://pic1.zhimg.com/v2-96d6ed62d48871fe9f6d1cda3b297af4_r.jpg) ## 状态机 对每一个GF而言,我们在使用的过程中,能够使用到的GF状态就4个:Installed、Registered、Loaded、Active。这4个状态之间可以双向转换以进行加载卸载。 也要注意GF状态的切换流程是一条双向的流水线,可以往加载激活的方向前进,在下图上是用黑色的箭头来表示;也可以往失效卸载的方向走,图上是红色的线表示。而箭头上的文字其实就是一个个GFS类里已经提供的GF加载卸载API。 双向箭头中间的圆角矩形表示的是状态,绿色的状态是我们能看见的,但其实内部还有挺多过渡状态的。过渡状态的概念后面也会解释。值得注意的是,UE5预览版增加了一个Terminal状态,可以把整个插件的内存状态释放掉。 ![](https://pic1.zhimg.com/v2-d67731c19467330cff71f51e2698705c_r.jpg) 在LoadBuiltInGameFeaturePlugin的最后一步GFS会为每一个GF创建一个UGameFeaturePluginStateMachine对象,用来管理内部的GF状态切换。 ```c++ void UGameFeaturesSubsystem::LoadBuiltInGameFeaturePlugin(const TSharedRef& Plugin, FBuiltInPluginAdditionalFilters AdditionalFilter) { //...省略其他代码 UGameFeaturePluginStateMachine* StateMachine = GetGameFeaturePluginStateMachine(PluginURL, true);//创建状态机 const EBuiltInAutoState InitialAutoState = (BehaviorOptions.AutoStateOverride != EBuiltInAutoState::Invalid) ? BehaviorOptions.AutoStateOverride : PluginDetails.BuiltInAutoState; const EGameFeaturePluginState DestinationState = ConvertInitialFeatureStateToTargetState(InitialAutoState); if (StateMachine->GetCurrentState() >= DestinationState) { // If we're already at the destination or beyond, don't transition back LoadGameFeaturePluginComplete(StateMachine, MakeValue()); } else { StateMachine->SetDestinationState(DestinationState, FGameFeatureStateTransitionComplete::CreateUObject(this, &ThisClass::LoadGameFeaturePluginComplete));//更新到目标的初始状态 } //...省略其他代码 } ``` FGameFeaturePluginState为所有状态机的基础结构体,所有派生结构体都会实现所需的虚函数。 ```c++ struct FGameFeaturePluginState { FGameFeaturePluginState(FGameFeaturePluginStateMachineProperties& InStateProperties) : StateProperties(InStateProperties) {} virtual ~FGameFeaturePluginState(); /** Called when this state becomes the active state */ virtual void BeginState() {} /** Process the state's logic to decide if there should be a state transition. */ virtual void UpdateState(FGameFeaturePluginStateStatus& StateStatus) {} /** Called when this state is no longer the active state */ virtual void EndState() {} /** Returns the type of state this is */ virtual EGameFeaturePluginStateType GetStateType() const { return EGameFeaturePluginStateType::Transition; } /** The common properties that can be accessed by the states of the state machine */ FGameFeaturePluginStateMachineProperties& StateProperties; void UpdateStateMachineDeferred(float Delay = 0.0f) const; void UpdateStateMachineImmediate() const; void UpdateProgress(float Progress) const; private: mutable FTSTicker::FDelegateHandle TickHandle; }; ``` 初始化状态机时候会创建所有状态(UGameFeaturePluginStateMachine::InitStateMachine())。 ### 状态机更新流程 SetDestinationState()=>UpdateStateMachine() UpdateStateMachine()除了判断与Log之外,主要执行了: - 调用当前状态的UpdateState() - 调用当前状态的EndState() - 调用新状态的BeginState() #### 检查存在性 首先GF插件的最初始状态是Uninitialized,很快进入UnknownStatus,标明还不知道该插件的状态。然后进入CheckingStatus阶段,这个阶段主要目的就是检查uplugin文件是否存在,一般的GF插件都是已经在本地的,可以比较快通过检测。 ![](https://pic2.zhimg.com/v2-429db98dfa46f7f387de6841aae718d9_r.jpg) #### 加载CF C++模块 下一个阶段就是开始尝试加载GF的C++模块。这个阶段的初始状态是Installed,这个我用绿色表示,表明它是个目标状态,区分于过渡状态。目标状态意思是在这个状态可以停留住,直到你手动调用API触发迁移到下一个状态,比如你想要注册或激活这个插件,就会把Installed状态向下一个状态转换。卸载的时候往反方向红色的线前进。接着往下: - Mounting阶段内部会触发插件管理器显式的加载这个模块,因此会加载dll,触发StartupModule。在以前Unmounting阶段并不会卸载C++,因此不会调用ShutdownModule。意思就是C++模块一经加载就常驻在内存中了。但在UE5预览版中,加上了这一步,因此现在Unmounting已经可以卸载掉插件的dll了。 - WaitingForDependencies,会加载之前uplugin里依赖的其他插件模块,递归加载等待所有其他的依赖项完成之后才会进入下一个阶段。这点其实跟普通的插件加载策略是一致的,因此GF插件本质上其实就是以插件的机制在运作,只不过有些地方有些特殊罢了。 ![](https://pic1.zhimg.com/v2-823dae0fa911b90c334f860d436b53f8_r.jpg) #### 加载GameFeatureData 在C++模块加载完成之后,下一步就要开始把GF自身注册到GFS里面去。其中最重要的一步是在Registering的时候加载GFD资产,会触发UGameFeaturesSubsystem::LoadGameFeatureData(BackupGameFeatureDataPath);的调用从而完成GFD加载。 ![](https://pic3.zhimg.com/v2-9abd72961b0ae6b08f24f22276f370ca_r.jpg) #### 预加载资产和配置 下一个阶段就是进行加载了。Loading阶段会开始加载两种东西,一是插件的运行时的ini(如…/LearnGF/Saved/Config/WindowsEditor/MyFeature.ini)与C++ dll,另外一项是可以预先加载一些资产,资产列表可以由Policy对象根据每个GF插件的GFD文件来获得,因此我们也可以重载GFD来添加我们想要预先加载的资产列表。其他的资产是在激活阶段根据Action的执行按需加载的。 ![](https://pic2.zhimg.com/v2-95b5552a06842280cc361cf929b82351_r.jpg) #### 激活生效 在加载完成之后,我们就可以激活这个GF了。Activating会为每个GFD里定义的Action触发OnGameFeatureActivating,而Deactivating触发OnGameFeatureDeactivating。激活和反激活是Action真正做事的时机。 ![](https://pic4.zhimg.com/v2-645173c77bfeb968145f7eb53576e7bf_r.jpg) ### 详细状态切换图 位于GameFeaturePluginStateMachine.h ```c++ /* +--------------+ | | |Uninitialized | | | +------+-------+ +------------+ | | * | | | Terminal <-------------~------------------------------ | | | | +--^------^--+ | | | | | | | | +------v-------+ | | | | * | | | ----------+UnknownStatus | | | | | | | +------+-------+ | | | | | +-----------v---+ +--------------------+ | | | | | ! | | | |CheckingStatus <-----> ErrorCheckingStatus+-->| | | | | | | | +------+------^-+ +--------------------+ | | | | | | | | +--------------------+ | ---------- | | | ! | | | | --------> ErrorUnavailable +---- | | | | | | +--------------------+ | | +-+----v-------+ | * | ----------+ StatusKnown | | | | | +-^-----+---+--+ | | | | ------~---------~------------------------- | | | | | | +--v-----+---+ +-v----------+ +-----v--------------+ | | | | | | ! | | |Uninstalling| |Downloading <-------> ErrorInstalling | | | | | | | | | +--------^---+ +-+----------+ +--------------------+ | | | | +-+---------v-+ | | * | ----------> Installed | | | +-^---------+-+ | | ------~---------~-------------------------------- | | | | +--v-----+--+ +-v---------+ +-----v--------------+ | | | | | ! | |Unmounting | | Mounting <---------------> ErrorMounting | | | | | | | +--^-----^--+ +--+--------+ +--------------------+ | | | ------~----------~------------------------------- | | | | +--v------------------- + +-----v-----------------------+ | | | | ! | | |WaitingForDependencies <---> ErrorWaitingForDependencies | | | | | | | +--+------------------- + +-----------------------------+ | | ------~----------~------------------------------- | | | | +--v-----+----+ +--v-------- + +-----v--------------+ | | | | | ! | |Unregistering| |Registering <--------------> ErrorRegistering | | | | | | | +--------^----+ ++---------- + +--------------------+ | | +-+--------v-+ | * | | Registered | | | +-^--------+-+ | | +--------+--+ +--v--------+ | | | | | Unloading | | Loading | | | | | +--------^--+ +--+--------+ | | +-+--------v-+ | * | | Loaded | | | +-^--------+-+ | | +--------+---+ +-v---------+ | | | | |Deactivating| |Activating | | | | | +--------^---+ +-+---------+ | | +-+--------v-+ | * | | Active | | | +------------+ */ ``` ## AddComponents 主要逻辑位于ModularGameplay模块中的UGameFrameworkComponentManager。GFCM内部的实现还是蛮复杂和精巧的,可以做到在一个GF激活后,会把激活前已经存在场景中Actor,还有激活后新生成的Actor,都会被正确的添加上Component。这个顺序无关的逻辑是怎么做到的呢?关键的逻辑分为两大部分: ### AddReceiver的注册 核心逻辑位于AddReceiverInternal()。我觉得大钊这里搞错了。 1. 判断Actor是否存在与当前关卡中。 2. 递归这个Actor类的父类,直到AActor。 3. 从TMap> ReceiverClassToComponentClassMap中寻找这个类对应的Component UClass* 集。 4. 如果Component类有效则调用CreateComponentOnInstance(),创建并且挂载到Actor上,并将信息放入TMap> ComponentClassToComponentInstanceMap中。 5. 查询TMap ReceiverClassToEventMap,并执行委托。 ### AddComponents 每个GFA在Activating的时候都会调用OnGameFeatureActivating。而对于UGameFeatureAction_AddComponents这个来说,其最终会调用到AddToWorld。在判断一番是否是游戏世界,是否服务器客户端之类的配置之后。最后真正发生发生的作用是Handles.ComponentRequestHandles.Add(GFCM->AddComponentRequest(Entry.ActorClass, ComponentClass)); AddComponentRequest会增加ReceiverClassToComponentClassMap中的项。 1. 创建FComponentRequest,并添加RequestTrackingMap中的项 2. 获取LocalGameInstance以及World,并且在World中遍历指定的Actor(ReceiverClass) 3. 如果Actor有效则调用CreateComponentOnInstance(),创建并且挂载到Actor上,并将信息放入TMap> ComponentClassToComponentInstanceMap中。 值得注意的是: 1. RequestTrackingMap是TMap的类型,可以看到Key是ReceiverClassPath和ComponentClassPtr的组合,那为什么要用int32作为Value来计数呢?这是因为在不同的GF插件里有可能会出现重复的ActorClass-ComponentClass的组合,比如GF1和GF2里都注册了A1-C2的配置。那在卸载GF的时候,我们知道也只有把两个GF1和GF2统统都卸载之后,这个A1-C2的配置才失效,这个时候这个int32计数才=0。因此才需要有个计数来记录生效和失效的次数。 2. ReceiverClassToComponentClassMap会记录ActorClass-多个ComponentClass的组合,其会在上文的AddReceiver的时候被用来查询。 3. 同样会发现根据代码逻辑,ensureMsgf这个报错也只在WITH_EDITOR的时候才生效。在Runtime下,依然会不管不顾的根据GFA里的配置为相应ActorClass类型的所有Actor实例添加Component。因此这个时候我们明白,AddReceiver的调用准确的说不过是为了GF生效后为新Spawn的Actor添加一个依然能添加相应组件的机会。 4. 返回值为何是TSharedPtr?又为何要Add进Handles.ComponentRequestHandles?其实这个时候就涉及到一个逻辑,当GF失效卸载的时候,之前添加的那些Component应该怎么卸载掉?所以这个时候就采取了一个办法,UGameFeatureAction_AddComponents这个Action实例里(不止一个,不同的GF会生成不同的UGameFeatureAction_AddComponents实例)记录着由它创建出来的组件请求,当这个GF被卸载的时候,会触发UGameFeatureAction_AddComponents的析构,继而释放掉TArray> ComponentRequestHandles;,而这是个智能指针,只会在最后一个被释放的时候(其实就是最后一个相关的GF被卸载时候),才会触发FComponentRequestHandle的析构,继而在GFCM里真正的移除掉这个ActorClass-ComponentClass的组合,然后在相应的Actor上删除Component实例。 ### 一套UGameFrameworkComponent UE5增加了UPawnComponent,父类为UGameFrameworkComponent(ModularGameplay模块中的文件)。主要是增加了一些Get胶水函数。 #### Component Component里面一般会写的逻辑有一种是会把Owner的事件给注册进来,比如为Pawn添加输入绑定的组件,会在UActorComponent的OnRegister的时候,把OwnerPawn的Restarted和ControllerChanged事件注册进来监听,以便在合适时机重新应用输入绑定或移除。这里我是想向大家说明,这也是编写Component的一种常用的范式,提供给大家参考。 ```c++ void UPlayerControlsComponent::OnRegister() { Super::OnRegister(); UWorld* World = GetWorld(); APawn* MyOwner = GetPawn(); if (ensure(MyOwner) && World->IsGameWorld()) { MyOwner->ReceiveRestartedDelegate.AddDynamic(this, &UPlayerControlsComponent::OnPawnRestarted); MyOwner->ReceiveControllerChangedDelegate.AddDynamic(this, &UPlayerControlsComponent::OnControllerChanged); // If our pawn has an input component we were added after restart if (MyOwner->InputComponent) { OnPawnRestarted(MyOwner); } } } ``` #### Actor - 蓝图中在BeginPlay与EndPlay事件中调用GameFrameworkComponentManager的AddReceiver与RemoveReceived。 - c++ 可以参考《古代山谷》的ModularGameplayActors。里面定义的是一些更Modular的Gameplay基础Actor,重载的逻辑主要有两部分,一是把自己注册给GFCM,二是把自己的一些有用的事件转发给GameFeature的Component,比如截图上的ReceivedPlayer和PlayerTick。 - ![](https://pic3.zhimg.com/v2-d380d507c096b8b2ce14ab63c330f442_r.jpg) ## 扩展 ### Action扩展( 古代山谷额外实现的GFA有:AddAbility、AddInputContextMapping、AddLevelInstance、AddSpawnedActor、AddWorldSystem、WorldActionBase 在实现Action的时候有一个值得注意的地方是,因为GFS是继承于EngineSubsystem的,因此Action的作用机制也是Engine的,因此在编辑器里播放游戏和停止游戏,这些Action其实都是在激活状态的。因此特意有定义了一个WordActionBase的基类,专门注册了OnStartGameInstance的事件,用来在游戏启动的时候执行逻辑。当然在真正用的时候,还需要通过IsGameWorld来判断哪个世界是要发挥作用的世界。 ### GameFeatrues依赖机制 普通的插件一般是被CoreGame所引用,用来实现CoreGame的支撑功能。而GameFeature是给已经运行的游戏注入新的功能的,因此CoreGame理论上来说应该对GameFeature的存在和实现一无所知,所以CoreGame就肯定不能引用GF,而是反过来被GF所引用。资产的引用也是同理,GF的资产可以引用CoreGame的,但不能被反过来引用。 ### 可继承扩展的类 - UGameFeaturesProjectPolicies,决定是否加载GF的策略,CoreGame - UGameFeatureStateChangeObserver,注册到GFS里监听GF状态变化,CoreGame - GameFeatureData,为GF添加更多描述数据 UGameFeaturesSubsystemSettings::DefaultGameFeatureDataClass ,CoreGame - GameFeatureAction,更多Action,CoreGame&GF - GameFrameworkComponent,更多Component, CoreGame&GF - ModularActor,更多Actor, CoreGame&GF - IAnimLayerInterface,改变角色动画, CoreGame&GF ### GameFeature模块协作 一些朋友可能会问一个问题,一个游戏玩法用到的模块还是挺多的,GameFeature都能和他们协作进行逻辑的更改吗?下面这个图,我列了一些模块和GameFeature的协作交互方式,大家可以简单看一下。如果有别的模块需要交互,还可以通过增加新的Action类型来实现。 ![](https://pic1.zhimg.com/80/v2-31547d8ae95b2ea17018200c90b01e2c_720w.jpg) ### CoreGame预留好逻辑注入点 GameFeature的出现,虽然说大大解耦了游戏玩法和游戏本体之间的联系,CoreGame理论上来说应该对GF一无所知,极致理想情况下,CoreGame也不需要做任何改变。但GF明显还做不到这点,还是需要你去修改CoreGame的一些代码,事先预留好逻辑的注入点。要修改的点主要有这些: 原先的Gameplay Actor修改为从之前的Modular里继承,或者自己手动添加AddReceiver的调用。 在动画方面,可以利用Anim Layer Interface,在GF里可以定义不同的动画蓝图来实现这个接口。之后在组件里Link Anim Class Layers绑定上就可以修改动画了。 关于数据配置,既可以用AddDataRegistrySource添加数据源到CoreGame里现有的DataRegistry里,也可以在GF里用AddDataRegistry新增新的数据注册表。 至于其他的模块部分,例如UI、输入这些,之前已经讲过可以用Action和Component的配合来做。 移植步骤: 创建Plugins/GameFeatures目录 创建.uplugin文件 资产 移动资产到新的GF目录 在原目录Fixup redirectors 修复所有资产验证问题 代码 创建.Build.cs 迁移项目代码到GF下的Public/Private 修复include路径错误 修复代码引用错误 在DefaultEngine.ini里加CoreRedirects +ClassRedirects=(OldName="/Script/MyGame.MyClass", NewName="/Script/MyFeature.MyClass" ### Rethink in GF - 首先面对一个项目,你需要去思考哪些游戏功能应该可以拆分成GF?哪些是游戏的基本机制?哪些是可以动态开关的功能?就是确定哪些东西应该抽出来形成一个GF。 - Actor逻辑拆分到Component,重新以(组件==功能)的方式来思考,而不是所有逻辑都堆在Actor中。然后这些逻辑,在之前往往是直接写在各种Actor身上,现在你需要重新以(组件==功能)的方式来思考,在以前我们提倡Component是用来实现相对机械的独立于游戏逻辑的基础功能的,现在我们为了适应GameFeature,就需要把一部分Component用来单纯的当做给Actor动态插拔的游戏逻辑实现了。因此你需要把这些逻辑从Actor拆分到Component中去,并做好事件回调注册和转发的相应胶水衔接工作。 - 在Gameplay的各个方面考虑注入逻辑的可能,数据、UI、Input、玩法等。如果你是在做新的项目或者玩法,你在一开始设计的时候就要预想到未来支持GameFeature的可能,留好各种逻辑注入点。 - 进行资产区域划分,哪些是CoreGame,哪些是GameFeature。当然在资产部分,要进行区域划分,哪些是CoreGame用到的资产,哪些是只在Game Feature里使用的资产,各自划分到不同的文件夹里去。 - 考虑GF和CoreGame的通用通信机制,事件总线、分发器、消息队列等。在某些时候,你可能光是利用Action和Component还不够,为了解耦CoreGame和GF,你可能还会需要用到一些通用的通信机制,比如事件总线分发器消息队列之类的。 - PAK和DLC是对游戏主体的补充,GF是动态装载卸载功能,虽然也可通过PAK动态加载。自然的,有些人可能会想到GF跟Pak和dlc有一点点像,是不是可以结合一下呢。这里也注意要辨析一下,pak和dlc是在资产级别的补丁,一般来说是在资产内容上对游戏本体的补充。而GF是在逻辑层面,强调游戏功能的动态开关。但是GF也确实可以打包成一个pak来进行独立的分发下载加载,因此GF也是可以配合热更新来使用的。