8 - 网络系统入门
本文将初步介绍 UE5 中有关网络系统的入门知识,例如:
- 什么是 C/S 架构;
- 如何理解服务器和客户端;
- 如何打包多人项目;
- 探索连接;
- 理解角色和变量复制;
- 如何使用远程过程调用 RPC;
- 如何在多人游戏环境中使用 Gameplay 框架
C/S 架构
多人游戏是指一组通过网络(互联网 / 局域网)在服务器和连接的客户端间发送指令,以制造一种共享世界的虚拟体验。这涉及到服务器和客户端之间的通信,例如玩家在游戏中开枪,客户端和服务器之间的交流如下:
从中可以发现,要想让多人游戏能够正确运行,需要将代码分类成三种:
- 只在服务器上运行的代码;
- 只在客户端上运行的代码;
- 在服务器和客户端上同时运行的代码;
UE5 中的网络系统采用 C/S 架构,每个玩家控制一个客户端,客户端使用双向连接与权威服务器通信。服务器运行带有 Gamemode
的特定关卡,并控制信息流,以便客户端可以在游戏世界中看到并进行交互。
理解服务器
服务器是 C/S 架构中最关键的部分,它的主要职责如下:
创建和管理共享的世界实例:服务器在特定的关卡和游戏模式中运行游戏实例,它将作为所有连接的客户端之间的共享世界。
处理客户端加入和离开请求:如果客户端想要连接到服务器,它需要通过直接 IP 连接或在线子系统(如 Steam)向服务器发送连接请求,服务器将根据自身情况接受 / 拒绝请求。
服务器接受请求,给客户端分配一个
PlayerController
,并调用游戏模式中的PostLogin()
。之后客户端便进入多人世界了,如果客户端要退出服务器,该消息将通知所有其他客户端,并调用游戏模式中的Logout()
。服务器拒绝请求,通常的理由是服务器满了,或者玩家的游戏版本和服务器的不一样。
生成所有客户端都需要知道的 Actor:如果需要生成一个存在于所有客户端中的 Actor,需要在服务器上执行此操作。这是因为服务器具有相关权限,并且能告知所有客户端该 Actor 被创建的消息。
运行关键的游戏逻辑:为了保证游戏公平,杜绝开挂,关键的游戏逻辑必须在服务器上执行。如果扣血的逻辑在客户端上执行,本地玩家只需修改内存即可实现无敌。
处理变量的复制:如果有一个复制(Replicated)的变量,那么它的值只能在服务器上更改,这将确保所有客户端都将自动更新该值。如果在客户端上尝试修改它,将会始终被替换成服务器上的最新值,以防止作弊。
处理来自客户端的 RPC:上文玩家开枪的例子有提到,玩家向服务器发送了开火的 RPC,而服务器需要处理它。
专用服务器
专用服务器(Dedicated Server)只运行服务器逻辑,因此我们不会看到游戏运行的画面。所有的客户端都会连接到这个服务器,它唯一的工作就是协调客户端并执行关键的 GamePlay
逻辑。此外,在运行服务器前加上 -log
参数,就会跳出一个控制台窗口,记录服务器的各种信息。
专用服务器比监听服务器(Listen Server)更轻量,因此可以将其托管在服务器上。并且它更公平,网络条件对每个客户端相同,客户端也没有服务器权限。
运行专用服务器 DS 的命令行代码如下:
"path\to\UnrealEditor.exe" "path\to\UProject" {地图名字} -server -game -log
如果想要打包专用服务器,需要为其构建一个专门作为专用服务器运行的项目。
监听服务器
监听服务器同时充当服务器和客户端,也就是说在作为客户端玩游戏的同时也充当服务器。它的打包构建成本低,但不像 DS 那样轻量级,因此同时连接的客户端数量受限。
运行监听服务器的命令行代码如下:
"path\to\UnrealEditor.exe" "path\to\UProject" {地图名字}?Listen -game
如果项目已经打包好,那么运行监听服务器的命令行代码如下:
"path\to\Project.exe" {地图名字}?Listen -game
理解客户端
客户端是 C/S 架构中最简单的部分,客户端的主要职责如下:
- 强制从服务器复制变量:在服务器上更改复制变量的值时,客户端也要强制执行修改;
- 处理来自服务器的 RPC;
- 模拟时预测运动:当客户端模拟 Actor 时,它需要根据 Actor 的速度在本地预测 Actor 将来的位置。如果把物理模拟也交给服务器,会增加它的负担。
- 生成仅有特定客户端知道的 Actor:如果想生成只有该客户端知道的 Actor,只需要在该客户端上生成即可。一个例子是建造游戏中的半透明预览建筑,玩家控制它摆放的位置,其他人则看不到。
客户端可以通过不同方式连接到服务器,这里只介绍 IP 直连的方式:
在 Dev Build 中打开控制台输入:
open {IP}
使用
Execute Console Command
蓝图节点:通过
APlayerController
中的ConsoleCommand()
实现:PlayerController->ConsoleCommand("open {IP}");
通过命令行实现:
# 没打包 "path\to\UnrealEditor.exe" "path\to\UProject" {IP} -game # 打包 "path\to\Project.exe" {IP} -game
理解连接
当客户端加入服务器时,它将获得一个和它有连接(Connection)的新 PlayController
。如果 Actor 没有和服务器的有效连接,就无法进行复制操作,例如变量复制,调用 RPC 等。
网络框架通过 Actor->GetNetConnection()
获取该 Actor
的连接,如果连接有效就会执行复制操作,如果无效则什么都不会执行。该方法的常见实现来自 APawn
和 AActor
。
// Pawn.cpp class UNetConnection* APawn::GetNetConnection() const { // if have a controller, it has the net connection if ( Controller ) { return Controller->GetNetConnection(); } return Super::GetNetConnection(); }
APawn
的实现中,首先检查它是否拥有有效的 Controller
,有的话就使用它的连接,没有则使用 AActor
类的 GetNetConnection()
实现:
// Actor.cpp UNetConnection* AActor::GetNetConnection() const { return Owner ? Owner->GetNetConnection() : nullptr; }
AActor
的实现中,如果它的拥有者(父 Actor)有效,它将使用拥有者的连接,没有则返回无效连接。
在监听服务器中,由该服务器控制客户端的 Actor 的连接始终是无效的,因为该客户端已经是服务器的一部分,不需要连接。
从上面的实现中可以发现,GetNetConnection()
函数的调用是层次结构。在该层次结构中,如果该 Actor 最终被一个 PlayerController
所控制 / 拥有,那么该 Actor 就能进行复制操作。
例如下图中有一个武器 Actor,它默认不会复制操作,当它被玩家捡起(拥有)后,便能进行复制操作:
SetOwner()
操作需要在权威服务器上进行,如果在非授权的游戏实例上执行SetOwner()
,它将无法执行复制操作。
理解角色
在服务器上生成 Actor
时,将在服务器上创建一个版本,并在各个客户端上创建一个版本。也就是说,在游戏的不同实例(Server,Client1,Client2 等)中存在相同 Actor
的不同版本。了解这些版本很重要,它们将允许我们了解在每个实例中可以执行什么逻辑。
为了区分这些版本,每个 Actor
都有如下两个变量:
- 本地角色 Local Role:
Actor
在当前游戏实例中的角色,通过GetLocalRole()
获取。例如,如果Actor
在服务器上生成,并且当前的游戏实例也是服务器,那么这个版本的Actor
就拥有权限,我们可以在它上面执行更关键的 Gameplay 逻辑。 - 远程角色 Remote Role:
Actor
在远程游戏实例中的角色,通过GetRemoteRole()
获取。例如,如果当前的游戏实例是服务器,那么它将返回该Actor
在客户端中的角色。
这些角色的类型都是枚举 ENetRole
,枚举的所有值如下:
ROLE_None
:该Actor
没有角色,因为它没有被复制。ROLE_SimulatedProxy
:当前游戏实例对该Actor
没有权限,也不受PlayerController
的控制。也就是说,该Actor
的运动将通过使用它速度的最新值模拟 / 预测。ROLE_AutonomousProxy
:当前游戏实例对该Actor
没有权限,但受PlayerController
的控制。也就是说,可以基于玩家的输入向服务器发送该Actor
的移动信息,这样更加准确。ROLE_Authority
:当前游戏实例对该Actor
拥有完全权限。也就是说,如果该Actor
在服务器上,对它进行修改后的结果将会强制同步到每个客户端上。
在服务器或客户端上生成 Actor,本地角色和远程角色的关系如下表:
服务器 | 客户端 | |||
---|---|---|---|---|
本地角色 | 远程角色 | 本地角色 | 远程角色 | |
服务器上生成 Actor | ROLE_Authority | ROLE_SimulatedProxy | ROLE_SimulatedProxy | ROLE_Authority |
客户端上生成 Actor | 不存在 | 不存在 | ROLE_Authority | ROLE_SimulatedProxy |
服务器上生成玩家拥有的 Pawn | ROLE_Authority | ROLE_AutonomousProxy | ROLE_AutonomousProxy | ROLE_Authority |
客户端上生成玩家拥有的 Pawn | 不存在 | 不存在 | ROLE_Authority | ROLE_SimulatedProxy |
理解变量复制
服务器保持客户端同步的方法之一是使用变量复制(Variable Replication)。服务器中的变量复制系统会在每秒按特定次数(在每个 Actor 的 AActor::NetUpdateFrequency
变量中定义,暴露给蓝图)检查客户端中是否有需要更新的任何复制变量。
只有在以下情况中,服务器的变量才会发给客户端进行更新:
- 变量被设置为 “复制(Replicate)”;
- 变量在服务器上被修改;
- 变量在客户端上的值与服务器的不同;
- Actor 启用了复制;
- Actor 和该变量相关,且满足所有复制条件;
在服务器上修改变量的值以后,并不会马上更新客户端。因为决定变量复制的逻辑每秒只会执行 AActor::NetUpdateFrequency
次。
复制标识符
可以在 UPROPERTY
中填写变量复制的标识符,以规定该变量复制的逻辑。常见的复制标识符如下:
Replicated:只想说明一个变量是复制的。
UPROPERTY(Replicated) float Health = 100.f;
ReplicatedUsing:说明一个变量是复制的,并且在更新时调用函数。
UPROPERTY(ReplicatedUsing = OnRepNotify_Health) float Health = 100.f; UFUNCTION() void OnRepNotify_Health() { /* ... */ }
要调用的函数应该被
UFUNCTION()
标识。
编写复制逻辑
除了要将变量标识为 Replicated
,也要在 Actor 的源代码中重写 GetLifetimeReplicatedProps()
函数。
该函数的目的就是实现每个复制变量的复制逻辑,这需要 DOREPLIFETIME
宏的帮助。这个以类中的复制变量(入参)作为标识的宏,将会复制到所有客户端,不需要额外条件:
DOREPLIFETIME({类名}, {复制变量名});
例如要复制 Health 变量:
void AVariableReplicationActor::GetLifetimeReplicatedProps( TArray<FLifetimeProperty>& OutLifetimeProps ) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AVariableReplicationActor, Health); }
如果希望只将变量复制到满足条件的客户端上,需要用 DOREPLIFETIME_CONDITON
宏:
DOREPLIFETIME_CONDITION({类名}, {复制变量名}, {条件});
条件参数可以是以下值之一(详见 ELifttimeCondition
枚举类型):
COND_InitialOnly
:该变量只会在初始复制时复制一次;COND_OwnerOnly
:变量只会复制到 Actor 的所有者;COND_SkipOwner
:变量不会复制到 Actor 的所有者;COND_SimulatedOnly
:变量只会复制到模拟角色的 Actor 上;COND_AutonomousOnly
:变量只会复制到自主操控角色的 Actor 上;COND_SimulatedOrPhyscis
:变量会复制到模拟角色的 Actor 上,或者bRepPhysics
为true
的 Actor 上;COND_InitialOrOwner
:变量会在初始复制时复制一次,并复制给 Actor 的所有者;COND_Custom
:变量只会在它的SetCustomIsActiveOverride
条件(在AActor::PreReplication()
中使用)为真时复制;- …
理解 RPC
远程过程调用(Remote Procedure Call,RPC)可以让我们在远程游戏实例(如客户端到服务器)中执行自定义代码逻辑,UE5 中支持如下三种 RPC:
- 服务器 RPC(Server RPC)
- 多播 RPC(Multicast RPC)
- 客户端 RPC(Client RPC)
服务器 RPC
如果客户端想要在服务器上对定义了 RPC 的 Actor 调用函数,需要使用服务器 RPC。使用服务器 RPC 有两个原因:
- 安全性:当我们制作多人游戏时(尤其是多人竞技类),必须假设客户端会尝试作弊,因此需要在服务器上执行关键逻辑。
- 同步性:在服务器上执行逻辑的同时,重要变量的修改也会通过变量复制强制更新到所有客户端上。
例如当客户端要开枪时,会调用服务器 RPC,让服务器验证该客户端是否可以开枪。
要想声明服务器 RPC,可以在 UFUNCTION()
中使用 Server
标识符:
UFUNCTION(Server, Reliable, WithValidation) void ServerRPCFunction(int32 IntegerParam, float FloatParam, AActor* ActorParam);
由于 RPC 通常在不同设备上异步执行,它不能拥有返回值。
多播 RPC
如果服务器想要在所有客户端上对定义了 RPC 的 Actor 调用函数,需要使用多播 RPC。
例如一个客户端要开枪,且服务器通过开枪校验,接下来需要执行一个多播 RPC,让所有客户端都知道这个客户端开枪了(播放动作、声音)。
要想声明多播 RPC,可以在 UFUNCTION()
中使用 NetMulticast
标识符:
UFUNCTION(NetMulticast, Unreliable) void MulticastRPCFunction(int32 IntegerParam, float FloatParam, AActor* ActorParam);
客户端 RPC
如果服务器想要在指定客户端上对定义了 RPC 的 Actor 调用函数,需要使用客户端 RPC。
例如当一个客户端的 Character 被炮弹击中,服务器会对该 Character 调用客户端 RPC,只让该客户端播放受伤的声音。
要想声明客户端 RPC,可以在 UFUNCTION()
中使用 Client
标识符:
UFUNCTION(Client, Unreliable) void ClientRPCFunction(int32 IntegerParam, float FloatParam, AActor* ActorParam);
注意事项
RPC 非常有用,但在使用它们时需要考虑以下几点:
实现
RPC 函数的实现应该为 {名字}_Implementation()
,例如上面客户端 RPC 的实现:
void ARPCTest::ClientRPCFunction_Implementation(int32 IntegerParam, float FloatParam, AActor* ActorParam) { /* ... */ }
重写
通过在没有 UFUNCTION()
的情况下在子类中声明和实现 xxx_Implementation()
,可以重写 RPC 的实现来扩展或绕过父类的功能。
例如在父类中有服务器 RPC 如下:
UFUNCTION(Server, Reliable) void ServerRPCTest(int32 IntegerParam);
如果想要重写父类的该函数,可以在子类中声明如下:
virtual void ServerRPCTest_Implementation(int32 IntegerParam) override;
这样就能绕过父类 RPC 的功能了,如果是扩展,加入 Super 调用即可。
有效连接
拥有有效连接的 Actor 才能调用 RPC,如果在无效连接的 Actor 上调用 RPC,远程实例上将无事发生。
支持的参数类型
RPC 支持大多数常见类型,但如 TSet
,TMap
等类型就不支持。需要注意的是可以传递任何 UObject
类及其子类的指针,如 Actor
。
如果创建一个带有 Actor
参数的 RPC,那么这个 Actor
也需要存在于远程游戏实例中,否则它的值为空。并且每个版本的 Actor
的实例名可能不同,也就是说,调用 RPC 时 Actor 的实例名可能和在远程实例上执行 RPC 的 Actor
的实例名不同。例如下图中,客户端控制的 Actor 实例名字为 BP_ThirdPersonCharacter_C_0
,但服务器中看到的则是 BP_ThirdPersonCharacter_C_1
。

直接在目标机器上执行 RPC
也能直接在目标机器上执行 RPC:
- 在服务器上执行服务器 RPC,将在服务器上执行逻辑;
- 在客户端上执行客户端 RPC 或多播 RPC,只在调用 RPC 的客户端上执行逻辑;
在这样的环境下,可以直接调用_Implementation()
版本,因为它只包含要执行的逻辑,不包含通过网络创建并发送 RPC 请求的开销:
void ARPCTest::CallServerRPC(int32 IntegerParameter) { // 如果在本地环境中调用RPC, 直接用_Implementation版本 if(HasAuthority()) { ServerRPCFunction_Implementation(IntegerParameter); } else ServerRPCFunction(IntegerParameter); }
参数验证
UFUNCTION()
中的 WithValidation
标识符强调该 RPC 调用需要进行参数校验,防止(如作弊等原因)无效参数调用 RPC。这需要我们强制实现一个 {名字}_Validate
函数以实现参数校验:
UFUNCTION(Server, Reliable, WithValidation) void ServerSetHealth(float NewHealth); // 参数校验函数 bool ARPCTest::ServerSetHealth_Validate(float NewHealth) { return NewHealth >= 0.0f && NewHealth <= MaxHealth; } // RPC逻辑 void ARPCTest::ServerSetHealth_Implementation(float NewHealth) { Health = NewHealth; }
这样在调用 RPC 的时候,会先执行_Validate()
,校验通过后才执行_Implementation()
。
可靠性
UFUNCTION()
中的 Reliable
和 Unreliable
标识符描述了 RPC 调用的可靠性:
- Reliable:希望通过重复请求直到远程计算机确认接收,确保 RPC 被执行。应该只用于执行重要逻辑的 RPC。
- Unreliable:不关心 RPC 是否由于恶劣网络条件而执行。适用于不是很重要的 RPC,如播放声音,或者经常被调用的 RPC(偶尔失去调用不要紧)。
使用 Gameplay 框架
UE 引擎的 Gameplay 框架提供了一些类,它们实现了大多数游戏需要的常见功能。例如定义游戏规则的 GameMode
类,控制玩家角色的 PlayerController
类和 Pawn/Character
类。当我们想要在多人游戏环境中使用这一框架,需要分清楚这些类到底在哪一游戏实例上:
- 仅服务器:该类的实例只存在于服务器上;
- 服务器和所有客户端:该类的实例存在于服务器和所有客户端上;
- 服务器和所属客户端:该类的实例将仅存在于服务器和它所属的客户端上;
- 仅所属客户端:该类的实例将仅存在于它所属的客户端上;
对 Gameplay 框架中的类,常见的分类如下:
以下是对该分类标准的解释:
仅服务器:
- GameMode 类:该类定义了游戏规则,它的实例只能由服务器访问。以 Dota2 为例,游戏模式类定义了它不同的游戏阶段(如游戏前的英雄挑选,游戏实际阶段,游戏后的结算),它对 Gameplay 至关重要,不允许客户端访问它。
服务器和所有客户端:
- GameState 类:存储游戏的状态,可以被服务器和所有客户端访问。以 Dota2 为例,它可以存储游戏时长,每个团队的分数等。
- PlayerState 类:存储玩家的状态,可以被服务器和所有客户端访问。以 Dota2 为例,它可以存储玩家的 ID,所选英雄,战绩等。
- Pawn 类:是玩家的可视化表示,可以被服务器和所有客户端访问。以 Dota2 为例,它可以是英雄等,由玩家控制。
服务器和所属客户端:
PlayerController 类:代表玩家的意图,将玩家输入传递给被控制的 Pawn 类,只能被服务器和所属客户端访问。
出于安全考虑,客户端不能访问其他客户端的玩家控制器,只能通过服务器来通信。如果客户端调用
UGameplayStatics::GetPlayerController()
得到的索引不是 0(即不是自己的控制器),则返回无效实例。服务器可以访问所有客户端的玩家控制器,可以通过AController::IsLocalController()
来检查一个玩家控制器实例是否在它所属客户端中。
仅所属客户端:
- HUD 和 UMG 控件类:用于显示游戏 UI,服务器和其他客户端不需要知道它,因此只能被所属客户端访问。
GameMode
可通过构造函数来设置它的成员初值:
ATestGameMode::ATestGameMode() { DefaultPawnClass = AMyCharacter::StaticClass(); PlayerControllerClass = AMyPlayerController::StaticClass(); PlayerStateClass = AMyPlayerState::StaticClass(); GameStateClass = AMyGameState::StaticClass(); }
可通过如下代码获取游戏模式类的实例:
AGameModeBase* GameMode = GetWorld()->GetAuthGameMode();
处于安全原因,只有在服务器上获取的实例是有效的,而客户端是无效的。
比赛状态
之前的内容都是 AGameModeBase
基类中的,如果想要获得对比赛相关功能的支持(如大厅系统),还需要使用它的子类 AGameMode
。比赛状态由状态机实现,一共有如下状态:
EnteringMap:世界仍在加载时的初始状态,Actor 还没有
Tick
。WaitingToStart:世界加载完成后,Actor 正在
Tick
的状态。此时玩家的 Pawn 还未生成,因为游戏还没正式开始。当状态机进入此状态时,会调用HandleMatchIsWatingToStart()
回调。如果
ReadyToStartMatch()
返回true
,或某处调用了StartMatch()
函数,此状态下的状态机会进入 InProgress 状态。InProgress:游戏正式开始运行的状态。当状态机进入此状态时,它将会为玩家生成 Pawn,对世界中的所有 Actor 调用
BeginPlay()
,并调用HandleMatchHasStarted()
回调。如果
ReadyToEndMatch()
返回true
,或某处调用了EndMatch()
函数,此状态下的状态机会进入 WaitingPostMatch 状态。WaitingPostMatch:比赛结束时的状态。此时 Actor 仍在
Tick
,但新玩家无法加入游戏。当状态机进入此状态时,会调用HandleMatchHasEnded()
回调。如果开始卸载世界,将转换到 LeavingMap 状态。
LeavingMap:正在卸载世界时的状态。当状态机进入此状态时,会调用
HandleLeavingMap()
回调。当状态机开始加载新关卡时,将会进入 EnteringMap 状态。
Aborted:失败状态,当比赛发生异常时调用
AbortMatch()
函数进入,防止比赛错误进行。
以 Dota2 为例,解释一下上面的状态:
- EnteringMap:地图加载时;
- WatingToStart:地图加载完毕,玩家开始选择英雄。
ReadyToStartMatch()
函数会检查所有玩家的英雄选择情况,如果都选好了就会开始比赛。 - InProgress:游戏正在进行中,玩家开始打打杀杀。
ReadyToEndMatch()
函数会检查双方基地是否被摧毁,有的话比赛就结束了。 - WaitingPostMatch:游戏结束,玩家可以看到相关结算页面。
- LeavingMap:卸载地图时;
- Aborted:如果其中一个玩家在初始阶段连接失败,会中止整个比赛。
玩家重生
玩家死亡想要重生时,通常有两种选择:
- 重用相同的 Pawn 实例,手动将其状态重置,并传送到重生位置;
- 销毁当前的 Pawn 实例,并生成一个新的。
后者中的生成部分刚好是 AGameModeBase::RestartPlayer()
函数的逻辑,因此第二种选择可以这样实现:
// 玩家死亡后的回调 void ATestGameMode::OnDeath(APlayerController* VictimController) { if(VictimController == nullptr) { return; } // 销毁当前的Pawn APawn* Pawn = VictimController->GetPawn(); if(Pawn != nullptr) { Pawn->Destroy(); } // 生成一个新的 RestartPlayer(VictimController); }
默认情况下,新的 Pawn 实例会在玩家首次生成的 Player Start Actor 处生成。如果想要在随机的 Player Start Actor 处生成,需要重写如下函数,并让它返回 false
:
bool ATestGameMode::ShouldSpawnAtStartSpot(AController* Player) { return false; }
PlayerState
玩家状态类存储了其他客户端需要知道的关于特定玩家的信息(如该玩家的分数、KDA 等),方便无法获取特定客户端 PlayerController 类的客户端。常使用的内置函数如下:
GetPlayerName()
:获取玩家的名称;GetScore()
:获取玩家的分数;GetPingInMilliseconds()
:获取玩家的延迟;
玩家状态类的实例可通过多种方式获取:
AController::PlayerState
:该变量包含与控制器相关联的玩家状态,只能由服务器和所属客户端访问。APlayerState* PlayerState = Controller->PlayerState;
AController::GetPlayerState()
:前一种方法的函数版本。// 普通 APlayerState* PlayerState = Controller->GetPlayerState(); // 模板: 可以获取特定类型的玩家状态 ATestPlayerState* MyPlayerState = Controller->GetPlayerState<AT estPlayerState>();
APawn::GetPlayerState()
:返回与拥有 Pawn 的 Controller 相关联的玩家状态,能由服务器和所有客户端访问。// Default version APlayerState* PlayerState = Pawn->GetPlayerState(); // Template version ATestPlayerState* MyPlayerState = Pawn->GetPlayerState<ATestPlayerState>();
AGameState::PlayerArray
:可以从游戏状态中获取每个玩家的玩家状态实例,支持在服务器和所有客户端上访问。TArray<APlayerState*> PlayerStates = GameState->PlayerArray;
GameState
游戏状态类存储其他客户端需要知道的关于游戏的信息(如比赛持续时间,目标分数等),因为它们无法直接访问游戏模式类。使用最多的变量就是 PlayerArray
,刚刚有提到。
可以通过如下方法来访问游戏状态实例:
UWorld::GetGameState()
:返回与世界相关联的游戏状态,可以从服务器和所有客户端上访问。// Default version AGameStateBase* GameState = GetWorld()->GetGameState(); // Template version AMyGameState* MyGameState = GetWorld()->GetGameState<AMyGameState>();
AGameModeBase::GameState
:返回与游戏模式相关联的游戏状态,只能在服务器上访问。AGameStateBase* GameState = GameMode->GameState;
AGameModeBase::GetGameState()
:前一种方法的函数版。// Default version AGameStateBase* GameState = GameMode->GetGameState<AGameStateB ase>(); // Template version AMyGameState* MyGameState = GameMode->GetGameState<AMyGameState>();
实践
理论完了就该实践了,这里的实践会和前面的理论相呼应(大概吧)。
测试多人游戏
编辑器
在引擎编辑器的 GUI 中也能开启多人游戏,在开启多人游戏前,需要在播放按钮的右侧三个点中配置相关参数:
参数的解释如下:
- 需要的客户端数量;
- 网络模式:
- Standalone:单人模式;
- Listen Server:使用监听服务器运行游戏;
- Client:在 DS 上运行游戏。
这里用 3 个客户端,使用监听服务器:
打包
接下来看看如何在打包构建中启用多人游戏,首先得打包项目。完成项目后,最好将其打包,这样就有了一个不使用虚幻引擎编辑器的纯独立版本,运行得更快更轻量。
这里以打包 Windows 平台为例,在 Platforms
中选中 Windows,选中打包,选择打包路径后等待打包完成。
打包完成后,进入打包文件夹,给游戏可执行程序创建一个快捷方式,然后在快捷方式的 “属性” 界面中添加如下目标:
ThirdPersonMap?Listen -server
这个快捷方式就是监听服务器的启动方式了,接下来再新建一个快捷方式作为客户端的启动方式,添加目标如下:
127.0.0.1
然后就能启动服务器和客户端了。
实现所有权和角色
在这个实践中,我们将测试所有权和角色相关的内容,实现如下功能:
- 创建一个名为
OwnershipTestActor
的 Actor,它具有一个静态网格体组件作为根组件,并且在每 Tick 中执行如下操作:- 检查一定半径内哪个玩家离他最近,并将其设置为所有者。如果半径内没有玩家,所有者为空。
- 显示它的本地角色,远程角色,连接信息;
首先需要在项目头文件 TestMultiplayer.h
中添加以下宏定义:
#define ROLE_TO_STRING(Value) FindObject<UEnum>(ANY_PACKAGE, TEXT("ENetRole"), true)->GetNameStringByIndex(static_cast<int32>(Value))
这个宏定义利用反射系统将 ENetRole
类型枚举转换为 FString
。
然后创建这个 Actor 类:
// OwnershipTestActor.h UCLASS() class TESTMULTIPLAYER_API AOwnershipTestActor : public AActor { GENERATED_BODY() public: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Ownership Test Actor") class UStaticMeshComponent* MeshComp; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Ownership Test Actor") float OwnershipRadius = 400.0f; // Sets default values for this actor's properties AOwnershipTestActor(); // Called every frame virtual void Tick(float DeltaTime) override; };
//.cpp #include "OwnershipTestActor.h" #include "TestMultiplayer.h" #include "TestMultiplayerCharacter.h" #include "Kismet/GameplayStatics.h" // Sets default values AOwnershipTestActor::AOwnershipTestActor() { // Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it. PrimaryActorTick.bCanEverTick = true; MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh Component")); RootComponent = MeshComp; // 告诉引擎该Actor应该启用网络同步 bReplicates = true; } // Called every frame void AOwnershipTestActor::Tick(float DeltaTime) { Super::Tick(DeltaTime); // 可视化所有权半径 DrawDebugSphere(GetWorld(), GetActorLocation(), OwnershipRadius, 32, FColor::Yellow); // 获取所有权半径内最接近的Character, 如果与当前拥有者不同, 将其设置为拥有者 if (HasAuthority()) { TArray<AActor*> Actors; UGameplayStatics::GetAllActorsOfClass(this, ATestMultiplayerCharacter::StaticClass(), Actors); AActor* NextOwner = nullptr; float MinDistance = OwnershipRadius; for (AActor* Actor : Actors) { if (const float Distance = GetDistanceTo(Actor); Distance <= MinDistance) { MinDistance = Distance; NextOwner = Actor; } } if (GetOwner() != NextOwner) { SetOwner(NextOwner); } } // 输出Debug信息: 本地角色,远程角色,所有者, 连接信息 const FString LocalRoleStr = ROLE_TO_STRING(GetLocalRole()); const FString RemoteRoleStr= ROLE_TO_STRING(GetRemoteRole()); const FString OwnerStr = GetOwner() != nullptr ? GetOwner()->GetName() : TEXT("No Owner"); const FString ConnectionStr = GetNetConnection() != nullptr ? TEXT("Valid") : TEXT("Invalid"); const FString DebugMsg = FString::Printf(TEXT("LocalRole = %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"), *LocalRoleStr, *RemoteRoleStr, *OwnerStr, *ConnectionStr); DrawDebugString(GetWorld(), GetActorLocation(), DebugMsg, nullptr, FColor::White, 0.0f, true); }
接下来给玩家角色类添加这个 Actor:
// .h virtual void Tick(float DeltaSeconds) override;
// .cpp #include "TestMultiplayer.h" // ... void ATestMultiplayerCharacter::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); // 输出Debug信息: 本地角色,远程角色,所有者, 连接信息 const FString LocalRoleStr = ROLE_TO_STRING(GetLocalRole()); const FString RemoteRoleStr= ROLE_TO_STRING(GetRemoteRole()); const FString OwnerStr = GetOwner() != nullptr ? GetOwner()->GetName() : TEXT("No Owner"); const FString ConnectionStr = GetNetConnection() != nullptr ? TEXT("Valid") : TEXT("Invalid"); const FString DebugMsg = FString::Printf(TEXT("LocalRole = %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"), *LocalRoleStr, *RemoteRoleStr, *OwnerStr, *ConnectionStr); DrawDebugString(GetWorld(), GetActorLocation(), DebugMsg, nullptr, FColor::White, 0.0f, true); }
服务器视角如下:
变量复制
接下来尝试使用变量复制标识符和辅助宏来说实现变量复制。我们将向角色添加两个变量,它们的逻辑如下:
- 变量 A 是浮点类型,使用
Replicated
标识符和DOREPLIFETIME
宏复制; - 变量 B 是整数类型,使用
ReplicatedUsing
标识符和DOREPLIFETIME_CONDITON
宏复制; - 如果角色有权限,它的 Tick 函数应该在每秒自增 A 和 B,并调用
DrawDebugString()
在它身旁显示值。
首先是两个变量在角色类中的定义:
// .h UPROPERTY(Replicated) float A = 100.f; UPROPERTY(ReplicatedUsing = OnRepNotify_B) int32 B; // ... virtual void Tick(float DeltaSeconds) override; UFUNCTION() void OnRepNotify_B();
然后是变量复制逻辑的实现:
// .cpp #include "Net/UnrealNetwork.h" // ... void ATestMultiplayerCharacter::GetLifetimeReplicatedProps( TArray<FLifetimeProperty>& OutLifetimeProps ) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(ATestMultiplayerCharacter, A); DOREPLIFETIME_CONDITION(ATestMultiplayerCharacter, B, COND_OwnerOnly); } void ATestMultiplayerCharacter::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); if (HasAuthority()) { ++A; ++B; } const FString DebugMsg = FString::Printf(TEXT("A = %.2f, B = %d"), A, B); DrawDebugString(GetWorld(), GetActorLocation(), DebugMsg, nullptr, FColor::White, 0.0f, true); } void ATestMultiplayerCharacter::OnRepNotify_B() { const FString Msg = FString::Printf(TEXT("B was changed by server, now is %d"), B); GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Red, Msg); }
结果如下:
对于服务器视角(右边窗口,右一角色),发现计数增加正确,且客户端角色的值比服务器的小。这是因为客户端是后加入的。
对于客户端视角(左边窗口,左一角色),发现服务器角色的 B 值是 0,这是因为客户端角色不是 B 的所有者(所有者是监听服务器的客户端),B 的值不会复制给它。
RPC 调用
接下来尝试使用 RPC 调用:
- 每次玩家按下鼠标左键时,客户端将执行一个可靠的服务器 RPC,用来检查角色是否有足够的弹药。
- 如果检查通过,减小该客户端的弹药,并调用一个不可靠的多播 RPC,用来在所有客户端中播放该客户端的开火动画。
- 如果检查不通过,就调用一个不可靠的客户端 RPC,只让该客户端播放子弹不足的声音。
有关输入绑定,动画制作等内容略,这里只看 RPC 相关的东西。
首先是弹药,三个 RPC 的声明:
UPROPERTY(Replicated) int32 Ammo = 5; // 验证能否开火的服务器RPC UFUNCTION(Server, Reliable, WithValidation, Category = "RPC Character") void ServerFire(); // 在所有客户端播放开火动画的多播RPC UFUNCTION(NetMulticast, Unreliable, Category = "RPC Character") void MulticastFire(); // 播放声音的客户端RPC UFUNCTION(Client, Unreliable, Category = "RPC Character") void ClientPlaySound2D(USoundBase* Sound);
接下来要实现 GetLifetimeReplicatedProps()
函数,以及上面的 RPC:
void ATestMultiplayerCharacter::GetLifetimeReplicatedProps( TArray<FLifetimeProperty>& OutLifetimeProps ) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); // 同步弹药变量 DOREPLIFETIME(ATestMultiplayerCharacter, Ammo); } bool ATestMultiplayerCharacter::ServerFire_Validate() { return true; } void ATestMultiplayerCharacter::ServerFire_Implementation() { // 如果在开火冷却期, 不开火 if (GetWorldTimerManager().IsTimerActive(FireTimer)) { return; } // 客户端没子弹, 让该客户端播放没子弹声音 if (Ammo == 0) { ClientPlaySound2D(NoAmmoSound); return; } // 有子弹, 让该客户端子弹减少, 并调用多播RPC Ammo--; GetWorldTimerManager().SetTimer(FireTimer, 1.5f, false); MulticastFire(); } void ATestMultiplayerCharacter::MulticastFire_Implementation() { if (FireAnimMontage) { PlayAnimMontage(FireAnimMontage); } } void ATestMultiplayerCharacter::ClientPlaySound2D_Implementation(USoundBase* Sound) { UGameplayStatics::PlaySound2D(GetWorld(), Sound); }
然后就能开始游戏了:
显示 Gameplay 框架实例值
接下来尝试显示 Gameplay 框架实例值,我们将添加如下内容:
- 在所属客户端上,
PlayerController
类创建并添加一个简单的 UMG 控件到视口中,它将显示菜单实例名; - 在
Tick()
中,玩家角色将显示它实例的值(作为 Pawn),也会显示GameMode
,GameState
,PlayerState
,PlayerController
,HUD
实例的值是否有效。
新建一个继承自 PlayerController
的类:
// .h protected: UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Test Player Controller") TSubclassOf<UUserWidget> MenuClass; UPROPERTY() UUserWidget* Menu; virtual void BeginPlay() override;
// .cpp #include "Blueprint/UserWidget.h" void ATestPlayerController::BeginPlay() { Super::BeginPlay(); if (IsLocalController() && MenuClass) { Menu = CreateWidget<UUserWidget>(this, MenuClass); if (Menu) { Menu->AddToViewport(0); } } }
在编辑器中编写这个 UI,主要是在左上角修改 Text,显示控件名字。它的事件蓝图如下:
在玩家角色类中重写 Tick:
void ATestMultiplayerCharacter::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); // 获取Gameplay框架类的实例 const AGameModeBase* GameMode = GetWorld()->GetAuthGameMode(); const AGameStateBase* GameState = GetWorld()->GetGameState(); const APlayerController* PlayerController = Cast<APlayerController>(GetController()); const AHUD* HUD = PlayerController ? PlayerController->GetHUD() : nullptr; //输出是否有效 const FString GameModeStr = GameMode ? TEXT("Valid") : TEXT("Invalid"); const FString GameStateStr = GameState ? TEXT("Valid") : TEXT("Invalid"); const FString PlayerStateStr = GetPlayerState() ? TEXT("Valid") : TEXT("Invalid"); const FString PlayerControllerStr = PlayerController ? TEXT("Valid") : TEXT("Invalid"); const FString HUDStr = HUD ? TEXT("Valid") : TEXT("Invalid"); const FString PawnStr = GetName(); const FString DebugStr = FString::Printf(TEXT("Game Mode: %s\nGame State: %s\nPlayer State: %s\nPawn: %s\nPlayer Controller: %s\nHUD: %s"), *GameModeStr, *GameStateStr, *PlayerStateStr, *PawnStr, *PlayerControllerStr, *HUDStr); DrawDebugString(GetWorld(), GetActorLocation(), DebugStr, nullptr, FColor::White, 0.0f, true); }
最终结果如下,可以发现 Gameplay 框架类在各游戏实例中是否是有效的,左边是客户端,右边是服务器:
实现一个简易多人拾取游戏
接下来将运用本节所有知识,实现一个简易多人拾取游戏,要点如下:
- 在所属客户端中,PlayerController 将创建一个 UMG 控件并添加到视口中,内容为每个玩家的得分和拾取物,并按分数降序排序。
- 设置
Kill Z
为 - 500,玩家每次从世界坠落后重生,并失去 10 分; - 实现拾取物 Actor,为捡起来的玩家提供 10 分;
- 游戏将在没有可拾取物品时结束,所有玩家角色将被摧毁。五秒钟后,服务器会重新加载地图,再次开始游戏。
创建好第三人称项目后,首先创建一个继承自 GameState
的 PickupsGameState
类,它负责存放拾取数量,玩家排行等数据:
// .h: 存放拾取物相关内容 protected: UPROPERTY(Replicated, BlueprintReadOnly) int32 PickupsRemaining; UFUNCTION(BlueprintCallable) TArray<APlayerState*> GetPlayerStatesOrderedByScore(); virtual void BeginPlay() override; virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; public: void RemovePickup() { PickupsRemaining--; } bool HasPickup() { return PickupsRemaining > 0; }
// .cpp TArray<APlayerState*> APickupsGameState::GetPlayerStatesOrderedByScore() const { // 获得PlayerArray的拷贝, 并据此排序 TArray<APlayerState*> PlayerStates(PlayerArray); PlayerStates.Sort([](const APlayerState& lhs, const APlayerState& rhs) { return lhs.GetScore() > rhs.GetScore(); }); return PlayerStates; } void APickupsGameState::BeginPlay() { Super::BeginPlay(); // 通过获取世界中所有Pickup Actor来初始化PickupsRemaining TArray<AActor*> Pickups; UGameplayStatics::GetAllActorsOfClass(this, APickup::StaticClass(), Pickups); PickupsRemaining = Pickups.Num(); } void APickupsGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(APickupsGameState, PickupsRemaining); }
接下来创建一个继承自 PlayerState
类的 PickupsPlayerState
类,它负责存放有关玩家得分的数据:
// .h protected: // 该玩家收集的拾取物数量 UPROPERTY(Replicated, BlueprintReadOnly) int32 Pickups; virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override; public: void AddPickup() { Pickups++; }
// .cpp void APickupsPlayerState::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(APickupsPlayerState, Pickups); }
然后创建一个继承自 PlayerController
类的 PickupsPlayerController
类,它负责初始化并显示用于记分牌的 UMG 控件:
// .h protected: UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickup Player Controller") TSubclassOf<UUserWidget> ScoreboardMenuClass; UPROPERTY() UUserWidget* ScoreboardMenu; virtual void BeginPlay() override;
// .cpp void APickupsPlayerController::BeginPlay() { Super::BeginPlay(); // 对于拥有该Controller客户端, 创建得分板UMG if (IsLocalController() && ScoreboardMenuClass) { ScoreboardMenu = CreateWidget<UUserWidget>(this, ScoreboardMenuClass); if (ScoreboardMenu) { ScoreboardMenu->AddToViewport(0); } } }
现在来编辑游戏模式类,将它继承的类更改为 AGameMode
后,编辑如下代码:
// .h protected: UPROPERTY() class APickupsGameState* MyGameState; AMultiplayerPickupGameMode(); void RestartMap() const; virtual void BeginPlay() override; virtual bool ShouldSpawnAtStartSpot(AController* Player) override; // 比赛状态相关 virtual void HandleMatchHasStarted() override; virtual void HandleMatchHasEnded() override; virtual bool ReadyToStartMatch_Implementation() override; virtual bool ReadyToEndMatch_Implementation() override;
// .cpp void AMultiplayerPickupGameMode::RestartMap() const { // 服务器重新加载该关卡, 并让客户端也是 GetWorld()->ServerTravel(GetWorld()->GetName()); } void AMultiplayerPickupGameMode::BeginPlay() { Super::BeginPlay(); MyGameState = GetGameState<APickupsGameState>(); } bool AMultiplayerPickupGameMode::ShouldSpawnAtStartSpot(AController* Player) { // 希望玩家在随机Player Start处重生, 而不是同一个 return false; } void AMultiplayerPickupGameMode::HandleMatchHasStarted() { Super::HandleMatchHasStarted(); GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, "The game has started!"); } void AMultiplayerPickupGameMode::HandleMatchHasEnded() { Super::HandleMatchHasEnded(); GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Red, "The game has ended!"); // 销毁所有玩家角色 TArray<AActor*> Characters; UGameplayStatics::GetAllActorsOfClass(this, AMultiplayerPickupCharacter::StaticClass(), Characters); for (AActor* Character : Characters) { Character->Destroy(); } // 设置计时器, 重启地图 FTimerHandle RestartMapTimer; GetWorldTimerManager().SetTimer(RestartMapTimer, this, &AMultiplayerPickupGameMode::RestartMap, 5.0f); } bool AMultiplayerPickupGameMode::ReadyToStartMatch_Implementation() { // 比赛可以立即开始 return true; } bool AMultiplayerPickupGameMode::ReadyToEndMatch_Implementation() { // 只有地图中所有可拾取物都被捡走, 比赛才结束 return MyGameState && !MyGameState->HasPickup(); }
然后是修改玩家角色类,首先为它添加下落和落地时候的声音:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickups Character") USoundBase* FallSound; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickups Character") USoundBase* LandSound;
接着声明给该玩家加分和拾取物品的函数,以及在所属客户端中播放声音的客户端 RPC:
void AddScore(const float Score) const; void AddPickup() const; UFUNCTION(Client, Unreliable) void ClientPlayerSound2D(USoundBase* Sound);
在角色死亡时播放下落的声音,可以重写 EndPlay()
:
void AMultiplayerPickupCharacter::EndPlay(const EEndPlayReason::Type EndPlayReason) { Super::EndPlay(EndPlayReason); if (EndPlayReason == EEndPlayReason::Destroyed) { UGameplayStatics::PlaySound2D(GetWorld(), FallSound); } }
在角色落地时播放落地的声音,可以重写 Landed()
:
void AMultiplayerPickupCharacter::Landed(const FHitResult& Hit) { Super::Landed(Hit); UGameplayStatics::PlaySound2D(GetWorld(), LandSound); }
接下来需要通过重写 FellOutOfWorld()
来实现游戏逻辑,当玩家掉出世界后,扣十分,摧毁角色后让它重生:
void AMultiplayerPickupCharacter::FellOutOfWorld(const class UDamageType& dmgType) { AController* TempController = Controller; AddScore(-10); Destroy(); // 服务器负责重生玩家 if (AGameMode* GameMode = GetWorld()->GetAuthGameMode<AGameMode>()) { GameMode->RestartPlayer(TempController); } }
最后实现剩余函数:
void AMultiplayerPickupCharacter::AddScore(const float Score) const { if (APlayerState* PlayerState = GetPlayerState()) { const float CurScore = PlayerState->GetScore(); PlayerState->SetScore(CurScore + Score); } } void AMultiplayerPickupCharacter::AddPickup() const { if (APickupsPlayerState* PlayerState = GetPlayerState<APickupsPlayerState>()) { PlayerState->AddPickup(); } } void AMultiplayerPickupCharacter::ClientPlayerSound2D_Implementation(USoundBase* Sound) { UGameplayStatics::PlaySound2D(GetWorld(), Sound); }
现在来处理拾取物 Pickup
类:
// .h protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Pickup") UStaticMeshComponent* Mesh; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Pickup") class URotatingMovementComponent* RotatingComponent; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Pickup") USoundBase* PickupSound; UFUNCTION() void OnBeginOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIdx, bool bFromSweep, const FHitResult& Hit); // Called when the game starts or when spawned virtual void BeginPlay() override;
// .cpp // Sets default values APickup::APickup() { // Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it. PrimaryActorTick.bCanEverTick = false; Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh")); Mesh->SetCollisionProfileName("OverlapAll"); RootComponent = Mesh; RotatingComponent = CreateDefaultSubobject<URotatingMovementComponent>(TEXT("Rotating Movement")); RotatingComponent->RotationRate = FRotator(0.f, 90.f, 0.f); bReplicates = true; } // Called when the game starts or when spawned void APickup::BeginPlay() { Super::BeginPlay(); Mesh->OnComponentBeginOverlap.AddDynamic(this, &APickup::OnBeginOverlap); } void APickup::OnBeginOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIdx, bool bFromSweep, const FHitResult& Hit) { if (AMultiplayerPickupCharacter* Character = Cast<AMultiplayerPickupCharacter>(OtherActor)) { // 关键游戏逻辑, 必须交给服务器执行 if (!HasAuthority()) { return; } // 被玩家拾取, 在服务器上减少拾取物总量 if (APickupsGameState* GameState = GetWorld()->GetGameState<APickupsGameState>()) { GameState->RemovePickup(); } // 向所有玩家播放该拾取物被拾取的声音 Character->ClientPlayerSound2D(PickupSound); // 给该玩家加分 Character->AddScore(10); Character->AddPickup(); Destroy(); } }
代码写完后,接下来就是制作资产了,这里略过大部分内容(如动画,声音),只介绍部分内容(如 UI 相关)。来看看积分板的 UI 是怎么制作的,首先先制作表头 WBP_Scoreboard_Header
:
然后复制,粘贴变成表的一行 WBP_Scoreboard_Enrty
,并将所有字变白,接下来开始制作表的一行。表的 Name 文本块添加绑定:
Score 和 Pickups 同理。然后创造一个 GetTypeface()
纯函数,用于突出显示本玩家的条目,其他玩家的条目则用默认显示方式,它返回条目要用的字体名称:
然后是表一行的 Event Construct 事件,它利用我们刚写的函数设置字体:
最后新建一个 WBP_ScoreBoard
控件蓝图,把之前做的都整合起来:
其中,PickupsRemaining 的文本绑定是:
然后在事件图表处新建一个自定义事件 Add Scoreboard Header
,将 Header 添加到垂直框 PlayerStates
中:
同理,接下来是 Add Scoreboard Entries
事件:
还有 Update Scoreboard
事件,用于更新排行榜:
最后写一下 Event Construct
事件就完成了(图里有错误,set time 节点的时间应该是 0.5s):
然后创建玩家控制器蓝图和游戏模式蓝图,修改项目设置。最终效果如下:
参考资料
中文翻译:《UE5 C++ 游戏开发完全学习教程》
英文原版:《Elevating Game Experiences with UE5》