《Inside UE4》读书总结二

本文深入探讨了UE4中的核心游戏架构,包括PlayerController、AIController、GameMode、GameState等关键概念及其职责划分。详细解释了WorldContext的作用及World类型的区分,并介绍了GameInstance的重要性。最后概述了UE4游戏开发中的MVC架构。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

 

 

摘自知乎《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的作用:

  1. 游戏内实体的Spawn,不光登记,GameMode既然作为一场游戏的主要负责人,那么游戏的加载释放过程中涉及到的实体的产生,包括玩家Pawn和PlayerController,AIController也都是由GameMode负责。最主要的SpawnDefaultPawnFor、SpawnPlayerController、ShouldSpawnAtStartSpot这一系列函数都是在接管玩家实体的生成和释放,玩家进入该游戏的过程叫做Login(和服务器统一),也控制进来后在什么位置,等等这些实体管理的工作。GameMode也控制着本场游戏支持的玩家、旁观者和AI实体的数目。
  2. 游戏的进度,一个游戏支不支持暂停,怎么重启等这些涉及到游戏内状态的操作也都是GameMode的工作之一,SetPause、ResartPlayer等函数可以控制相应逻辑。
  3. Level的切换,或者说World的切换更加合适,GameMode也决定了刚进入一场游戏的时候是否应该开始播放开场动画(cinematic),也决定了当要切换到下一个关卡时是否要bUseSeamlessTravel,一旦开启后,你可以重载GameMode和PlayerController的GetSeamlessTravelActorList方法和GetSeamlessTravelActorList来指定哪些Actors不被释放而进入下一个World的Level。
  4. 多人游戏的步调同步,在多人游戏的时候,我们常常需要等所有加入的玩家连上之后,载入地图完毕后才能一起开始逻辑。因此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)。一图胜千言: 

### UE4 中实现体积方块效果的方法 在 Unreal Engine 4 (UE4) 中,要实现一个具有体积感的立方体效果并能够检测某个点是否位于该立方体内,可以按照以下方式设计逻辑。 #### 判断点是否处于长方体内 为了判断某一点是否在一个特定的长方体内,需要考虑以下几个因素: 1. **Transform 数据解析** 长方体的位置由 `Location` 表示,其方向通过 `Rotation` 定义(包括 Pitch、Yaw 和 Roll),而大小则由 `Scale` 控制。这些数据共同定义了一个变换矩阵,用于描述长方体的空间姿态[^1]。 2. **转换到局部坐标系** 将待测点的世界坐标转换至长方体的局部空间中。这一步可以通过逆向应用长方体的 Transform 来完成。具体操作如下: ```cpp FVector LocalPoint = LongBox->GetComponentTransform().InverseTransformPosition(WorldPoint); ``` 3. **边界条件验证** 在局部坐标系下,假设长方体是一个轴对齐的包围盒,则只需比较各维度上的范围即可得出结论。如果满足以下不等式,则说明目标点确实落在指定范围内: \[ -\frac{Extent.X}{2} \leq LocalPoint.X \leq \frac{Extent.X}{2} \] 同理适用于 Y 和 Z 方向。 #### 创建带有碰撞功能的体积方块 对于实际的游戏开发需求来说,仅仅依靠上述算法可能不够直观或者高效;因此推荐利用引擎内置的功能来简化流程。 1. **设置静态网格模型及其属性** 使用 Static Mesh Editor 编辑器加载合适的几何图形作为基础结构,并调整它的尺寸参数以匹配预期表现形式[^2]。 2. **配置物理模拟选项** 进入 Details 面板中的 Collision 设置部分,启用 Generate Overlap Events 功能以便后续捕捉事件触发情况。同时可以选择预设好的 Box Collider 或者手动绘制自定义形状。 3. **编写蓝图脚本处理交互行为** 当其他对象进入此区域时执行相应动作——比如改变材质颜色、播放音效等等。以下是伪代码示意如何监听重叠状态变化: ```blueprint Event BeginOverlap(OtherActor) SetMaterialToRed() Event EndOverlap(OtherActor) ResetMaterialColor() ``` 以上就是关于如何基于 UE4 构建具备功能性反馈机制的大致思路概述。 ```python def is_point_inside_box(world_position, box_component): local_pos = box_component.transform.inverse_transform_vector(world_position) extents = box_component.get_bounding_extents() inside_x = (-extents.x / 2 <= local_pos.x <= extents.x / 2) inside_y = (-extents.y / 2 <= local_pos.y <= extents.y / 2) inside_z = (-extents.z / 2 <= local_pos.z <= extents.z / 2) return all([inside_x, inside_y, inside_z]) ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值