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》