摘自知乎《Inside UE4》:https://zhuanlan.zhihu.com/insideue4
APlayerController
PlayerController中大概归纳出几个模块:
Camera的管理,目的都是为了控制玩家的视角,所以有了PlayerCameraManager这一个关联很紧密的摄像机管理类,用来方便的切换摄像机。PlayerController的ControlRotation、ViewTarget等也都是为了更新Camera的位置
Input系统,包括构建InputStack用来路由输入事件,也包括了自己对输入事件的处理。所以包含了UPlayerInput来委托处理。
UPlayer关联
- HUD显示,用于在当前控制器的摄像机面前一直显示一些UI,这是从UE3迁移过来的组件,现在用UMG的比较多,等介绍UI模块的时候再详细介绍。
- Level的切换,PlayerController作为网络里通道,在一起进行Level Travelling的时候,也都是先通过PlayerController来进行RPC调用,然后由PlayerController来转发到自己World中来实际进行。
思考:哪些逻辑应该放在PlayerController中?
而PlayerController的职责应该是一边控制Pawn,一边负责内部正确的调用PlayerState的数据更新接口。
AAIController
同PlayerController对比,少了Camera、Input、UPlayer关联,HUD显示,Voice、Level切换接口,但也增加了一些AI需要的组件:
- Navigation,用于智能根据导航寻路,常用的MoveTo接口就是做这件事情的。而在移动的过程中,因为少了玩家控制的来转向,所以多了一个SetFocus来控制当前的Pawn视角朝向哪个位置。
- AI组件,运行启动行为树,使用黑板数据,探索周围环境,以后如果有别的AI算法方法实现成组件,也应该在本组件内组合启动。
- Task系统,让AI去完成一些任务,也是实现GameplayAbilities系统的一个接口。目前简单来说GameplayAbilities是为Actor添加额外能力属性集合的一个模块,比如HP,MP等。其中的GamePlayEffect也是用来实现Buffer的工具。另外GamePlayTags也是用来给Actor添加标签标记来表明状态的一种机制。目前来说该两个模块似乎都是由Epic的Game Team在维护,所以完成度不是非常的高,用的时候也往往需要根据自己情况去重构调整。
GameMode
GameMode里登记了游戏里基本需要的类型信息,通过UClass的反射可以自动Spawn出相应的对象添加进关卡。Controller的类型登记也是在此,GameMode就是比Controller更高一级的领导。
GameMode的作用:
- 游戏内实体的Spawn,不光登记,GameMode既然作为一场游戏的主要负责人,那么游戏的加载释放过程中涉及到的实体的产生,包括玩家Pawn和PlayerController,AIController也都是由GameMode负责。最主要的SpawnDefaultPawnFor、SpawnPlayerController、ShouldSpawnAtStartSpot这一系列函数都是在接管玩家实体的生成和释放,玩家进入该游戏的过程叫做Login(和服务器统一),也控制进来后在什么位置,等等这些实体管理的工作。GameMode也控制着本场游戏支持的玩家、旁观者和AI实体的数目。
- 游戏的进度,一个游戏支不支持暂停,怎么重启等这些涉及到游戏内状态的操作也都是GameMode的工作之一,SetPause、ResartPlayer等函数可以控制相应逻辑。
- Level的切换,或者说World的切换更加合适,GameMode也决定了刚进入一场游戏的时候是否应该开始播放开场动画(cinematic),也决定了当要切换到下一个关卡时是否要bUseSeamlessTravel,一旦开启后,你可以重载GameMode和PlayerController的GetSeamlessTravelActorList方法和GetSeamlessTravelActorList来指定哪些Actors不被释放而进入下一个World的Level。
-
多人游戏的步调同步,在多人游戏的时候,我们常常需要等所有加入的玩家连上之后,载入地图完毕后才能一起开始逻辑。因此UE提供了一个MatchState来指定一场游戏运行的状态,用一个状态机来标记开始和结束的状态,并触发各种回调。
namespace MatchState
{
extern ENGINE_API const FName EnteringMap; // We are entering this map, actors are not yet ticking
extern ENGINE_API const FName WaitingToStart; // Actors are ticking, but the match has not yet started
extern ENGINE_API const FName InProgress; // Normal gameplay is occurring. Specific games will have their own state machine inside this state
extern ENGINE_API const FName WaitingPostMatch; // Match has ended so we aren't accepting new players, but actors are still ticking
extern ENGINE_API const FName LeavingMap; // We are transitioning out of the map to another location
extern ENGINE_API const FName Aborted; // Match has failed due to network issues or other problems, cannot continue
}
思考:多个Level配置不同的GameMode时采用哪个GameMode?
一个World里只会有一个GameMode实例。当有多个Level的时候,一定是PersisitentLevel和多个StreamingLevel,就算它们配置了不同的GameModeClass,UE也只是第一次创建World时加载PersisitentLevel的时候创建GameMode,在后续的LoadStreamingLevel时候,并不会再动态创建出别的GameMode,所以GameMode从始至终只有一个,PersisitentLevel的那个。
思考:Level迁移时GameMode是否保持一致?
在travelling的时候,如果下一个Level的配置的GameModeClass和当前的不同,那么迁移后是哪个GameMode?
无论travelling采用哪种方式,当前的World都会被释放掉,然后加载创建新的World。但这个过程中,有点区别的是根据bUseSeamlessTravel的不同,UE可以选择哪些Actor迁移到下一个World中去(实现方式是先创建个中间过渡World进行二段迁移(为了避免同时加载进两个大地图撑爆内存),具体见引用3)。分两种情况:
不开启bUseSeamlessTravel,那么在travelling的时候(ServerTravel或ClientTravel),当前的World会被释放,所以当前的GameMode就被释放掉。新的World加载,就会根据新的GameModeClass创建新的GameMode。所以这时是不同的。
开启bUseSeamlessTravel,travelling时,当前World的GameMode会调用GetSeamlessTravelActorList:
void AGameMode::GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList)
{
UWorld* World = GetWorld();
// Get allocations for the elements we're going to add handled in one go
const int32 ActorsToAddCount = World->GameState->PlayerArray.Num() + (bToTransition ? 3 : 0);
ActorList.Reserve(ActorsToAddCount);
// always keep PlayerStates, so that after we restart we can keep players on the same team, etc
ActorList.Append(World->GameState->PlayerArray);
if (bToTransition)
{
// keep ourselves until we transition to the final destination
ActorList.Add(this);
// keep general game state until we transition to the final destination
ActorList.Add(World->GameState);
// keep the game session state until we transition to the final destination
ActorList.Add(GameSession);
// If adding in this section best to increase the literal above for the ActorsToAddCount
}
}
在第一步从CurrentWorld到TransitionWorld的迁移时候,bToTransition==true,这个时候GameMode也会迁移进TransitionWorld(TransitionMap可以在ProjectSettings里配置),也包括GameState和GameSession,然后CurrentWorld释放掉。第二步从TransitionWorld到NewWorld的迁移,GameMode(已经在TransitionWorld中了)会再次调用GetSeamlessTravelActorList,这个时候bToTransition==false,所以第二次的时候如代码所见当前的GameMode、GameState和GameSession就被排除在外了。这样NewWorld再继续InitWorld的时候,一发现当前没有GameMode,就会根据配置的GameModeClass重新生成一个出来。所以这个时候GameMode也是不同的。
结论是,UE的流程travelling,GameMode在新的World里是会新生成一个的,即使Class类型一致,即使bUseSeamlessTravel,因此在travelling的时候要小心GameMode里保存的状态丢失。不过Pawn和Controller默认是一致的。
思考:哪些逻辑应该写在GameMode里?哪些应该写在Level Blueprint里?
- 概念上,Level是表示,World是逻辑,一个World如果有很多个Level拼在一起,那么也就是有了很多个LevelScriptActor,无法想象在那么多个地方写一个完整的游戏逻辑。所以GameMode应该专注于逻辑的实现,而LevelScriptActor应该专注于本Level的表示逻辑,比如改变Level内某些Actor的运动轨迹,或者某一个区域的重力,或者触发一段特效或动画。而GameMode应该专注于玩法,比如胜利条件,怪物刷新等。
- 组合上,同Controller应用到Pawn一样道理,因为GameMode是可以应用在不同的Level的,所以通用的玩法应该放在GameMode里。
- GameMode只在Server存在(单机游戏也是Server),对于已经连接上Server的Client来说,因为游戏的状态都是由Sever决定的,Client只是负责展示,所以Client上是没有GameMode的,但是有LevelScriptActor,所以GameMode里不要写Client特定相关的逻辑,比如操作UI等。但是LevelScriptActor还是有的,而且支持RPC,即使如此,LevelScriptActor还是应该只专注于表现,比如网络中触发一个特效火焰。至于UI,可以通过PlayerController的RPC,然后转发到GameInstance来操作。
- 跟下层的PlayerController比较,GameMode关心的是构建一个游戏本身的玩法,PlayerController关心的玩家的行为。这两个行为是独立正交可以自由组合的。所以想想哪些逻辑属于游戏,哪些属于玩家,就应该清楚写在哪里了。
- 跟上层的GameInstance比较,GameInstance关注的是更高层的不同World之间的逻辑,虽然有时候他也把手伸下来做些UI的管理工作,不过严谨来说,在UE里UI是独立于World的一个结构,所以也还算能理解。因此可以把不同GameMode之间协调的工作交给GameInstance,而GameMode只专注自己的玩法世界。
GameState
APlayerState用来保存玩家的游戏数据,GameState来保存游戏的状态,跟APlayerState一样,GameState也选择从AInfo里继承,这样在网络环境里也可以Replicated到多个Client上面去。
第一个MatchState和相关的回调就是为了在网络中传播同步游戏的状态使用的(GameMode在Client并不存在,但是GameState是存在的,所以可以通过它来复制),第二部分是玩家状态列表,如果在Client1想看到Client2的游戏状态数据,则Client2的PlayerState就必须广播过来,因此GameState把当前Server的PlayerState都收集了过来,方便访问使用。
开发者可以自定义GameState子类来存储本GameMode的运行过程中产生的数据(那些想要replicated的),如果是GameMode游戏运行的一些数据,又不想要所有的客户端都可以看到,则也可以写在GameMode的成员变量中。重复遍,PlayerState是玩家自己的游戏数据,GameInstance里是程序运行的全局数据。
我们关心的是怎么协调好整个场景的表现(LevelBlueprint)和游戏玩法的编写(GameMode)。UE再次用Actor分化派生的思想,用同样套路的AGameMode和AGameState支持了玩法和表现的解耦分离和自由组合,并很好的支持了网络间状态的同步。同时也提供了一个逻辑的实体来负责创建关系内那些关键的Pawn和Controller们,在关卡切换(World)的时候,也有了一个负责对象来处理一些本游戏的特定情况处理。
UE把GameMode和GameState的共同基础部分抽到基类AGameModeBase和AGameStateBase里,并把现在的GameMode和GameState依然当作多人联机的默认实现。想实现简单单机GameMode从AGameModeBase里继承。
UPlayer
ULocalPlayer
本地玩家,从Player中派生下来LocalPlayer类。对本地环境中,一个本地玩家关联着输入,也一般需要关联着输出。玩家对象的上层就是引擎了,会在GameInstance里保存有LocalPlayer列表。
UE在初始化GameInstance的时候,会先默认创建出一个GameViewportClient,然后在内部再转发到GameInstance的CreateLocalPlayer:
ULocalPlayer* UGameInstance::CreateLocalPlayer(int32 ControllerId, FString& OutError, bool bSpawnActor)
{
ULocalPlayer* NewPlayer = NULL;
int32 InsertIndex = INDEX_NONE;
const int32 MaxSplitscreenPlayers = (GetGameViewportClient() != NULL) ? GetGameViewportClient()->MaxSplitscreenPlayers : 1;
//已略去错误验证代码,MaxSplitscreenPlayers默认为4
NewPlayer = NewObject<ULocalPlayer>(GetEngine(), GetEngine()->LocalPlayerClass);
InsertIndex = AddLocalPlayer(NewPlayer, ControllerId);
if (bSpawnActor && InsertIndex != INDEX_NONE && GetWorld() != NULL)
{
if (GetWorld()->GetNetMode() != NM_Client)
{
// server; spawn a new PlayerController immediately
if (!NewPlayer->SpawnPlayActor("", OutError, GetWorld()))
{
RemoveLocalPlayer(NewPlayer);
NewPlayer = NULL;
}
}
else
{
// client; ask the server to let the new player join
NewPlayer->SendSplitJoin();
}
}
return NewPlayer;
}
如果是在Server模式,会直接创建出ULocalPlayer,然后创建出相应的PlayerController。而如果是Client(比如Play的时候选择NumberPlayer=2,则有一个为Client),则会先发送JoinSplit消息到服务器,在载入服务器上的Map之后,再为LocalPlayer创建出PlayerController。
而在每个PlayerController创建的过程中,在其内部会调用InitPlayerState:
void AController::InitPlayerState()
{
if ( GetNetMode() != NM_Client )
{
UWorld* const World = GetWorld();
const AGameModeBase* GameMode = World ? World->GetAuthGameMode() : NULL;
//已省略其他验证和无关部分
if (GameMode != NULL)
{
FActorSpawnParameters SpawnInfo;
SpawnInfo.Owner = this;
SpawnInfo.Instigator = Instigator;
SpawnInfo.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnInfo.ObjectFlags |= RF_Transient; // We never want player states to save into a map
PlayerState = World->SpawnActor<APlayerState>(GameMode->PlayerStateClass, SpawnInfo );
// force a default player name if necessary
if (PlayerState && PlayerState->PlayerName.IsEmpty())
{
// don't call SetPlayerName() as that will broadcast entry messages but the GameMode hasn't had a chance
// to potentially apply a player/bot name yet
PlayerState->PlayerName = GameMode->DefaultPlayerName.ToString();
}
}
}
}
这样LocalPlayer最终就和PlayerState对应了起来。而网络联机时其他玩家的PlayerState是通过Replicated过来的。
前文提到说一个World管理多个Level,并负责它们的加载释放。那么,问题来了,一个游戏里是只有一个World吗?
WorldContext
答案是否定的,首先World就不是只有一种类型,比如编辑器本身就也是一个World,里面显示的游戏场景也是一个World,这两个World互相协作构成了我们的编辑体验。然后点播放的时候,引擎又可以生成新的类型World来让我们测试。简单来说,UE其实是一个平行宇宙世界观。
以下是一些世界类型:
namespace EWorldType
{
enum Type
{
None, // An untyped world, in most cases this will be the vestigial worlds of streamed in sub-levels
Game, // The game world
Editor, // A world being edited in the editor
PIE, // A Play In Editor world
Preview, // A preview world for an editor tool
Inactive // An editor world that was loaded but not currently being edited in the level editor
};
}
而UE用来管理和跟踪这些World的工具就是WorldContext:
FWorldContext保存着ThisCurrentWorld来指向当前的World。而当需要从一个World切换到另一个World的时候(比如说当点击播放时,就是从Preview切换到PIE),FWorldContext就用来保存切换过程信息和目标World上下文信息。所以一般在切换的时候,比如OpenLevel,也都会需要传FWorldContext的参数。一般就来说,对于独立运行的游戏,WorldContext只有唯一个。而对于编辑器模式,则是一个WorldContext给编辑器,一个WorldContext给PIE(Play In Editor)的World。一般来说我们不需要直接操作到这个类,引擎内部已经处理好各种World的协作。
不仅如此,同时FWorldContext还保存着World里Level切换的上下文:
struct FWorldContext
{
[...]
TEnumAsByte<EWorldType::Type> WorldType;
FSeamlessTravelHandler SeamlessTravelHandler;
FName ContextHandle;
/** URL to travel to for pending client connect */
FString TravelURL;
/** TravelType for pending client connects */
uint8 TravelType;
/** URL the last time we traveled */
UPROPERTY()
struct FURL LastURL;
/** last server we connected to (for "reconnect" command) */
UPROPERTY()
struct FURL LastRemoteURL;
}
这里的TravelURL和TravelType就是负责设定下一个Level的目标和转换过程。
思考:为何Level的切换信息不放在World里?
因为UE有一个逻辑,一个World只有一个PersistentLevel(见上篇),而当我们OpenLevel一个PersistentLevel的时候,实际上引擎做的是先释放掉当前的World,然后再创建个新的World。所以如果我们把下一个Level的信息放在当前的World中,就不得不在释放当前World前又拷贝回来一遍了。
GameInstance这个类可以跨关卡存在,不会因为切换关卡或者游戏模式而销毁,但是GameMode和PlayerController就会在切换关卡或游戏模式时销毁重置,里面的状态不能保存,如果想在下一关卡中保存上一关卡的角色位置,就在GameInstance中保存。
Engine
此处UEngine分化出了两个子类:UGameEngine和UEditorEngine。众所周知,UE的编辑器也是UE用自己的引擎渲染出来的,采用的也是Slate那套UI框架。好处有很多,比如跨平台比较统一,UI框架可以复用一套控件库,Dogfood等等,所以本质上来说,UE的编辑器其实也是个游戏!我们是在编辑器这个游戏里面创造我们自己的另一个游戏。所以UE会在不同模式下根据编译环境而采用不同的具体Engine类,而在基类UEngine里通过一个WorldList保存了所有的World。
GamePlay架构
一些相关的重要的类
UE基于UObject的机制出发,构建出了纷繁复杂的游戏世界,几乎所有的重要的类都直接或间接的继承于UObject,都能充分利用到UObject的反射等功能,大大加强了整体框架的灵活度和表达能力。比如GamePlay中最常用到根据某个Class配置在运行时创建出特定的对象的行为就是利用了反射功能;而网络里的属性同步也是利用了UObject的网络同步RPC调用(Actor中具有的属性应该是复制属性);一个Level想保存成uasset文件,或者USaveGame想存档,也都是利用了UObject的序列化;而利用了UObject的CDO(Class Default Object),在保存时候也大大节省了内存;这么多Actor对象能在编辑器里方便的编辑,也得益于UObject的属性编辑器集成;对象互相引用的从属关系有了UObject的垃圾回收之后我们就不用担心会释放问题了。想象一下如果一开始没有设计出UObject,那么这个GamePlay框架肯定是另一番模样了。
整个游戏的MVC层:
按照MVC的思想,我们可以把整个游戏的GamePlay分为三大部分:表现(View)、逻辑(Controller)、数据(Model)。一图胜千言: