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 的连接,如果连接有效就会执行复制操作,如果无效则什么都不会执行。该方法的常见实现来自 APawnAActor

   
// 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 RoleActor 在当前游戏实例中的角色,通过 GetLocalRole() 获取。例如,如果 Actor 在服务器上生成,并且当前的游戏实例也是服务器,那么这个版本的 Actor 就拥有权限,我们可以在它上面执行更关键的 Gameplay 逻辑。
  • 远程角色 Remote RoleActor 在远程游戏实例中的角色,通过 GetRemoteRole() 获取。例如,如果当前的游戏实例是服务器,那么它将返回该 Actor 在客户端中的角色。

这些角色的类型都是枚举 ENetRole,枚举的所有值如下:

  • ROLE_None:该 Actor 没有角色,因为它没有被复制。
  • ROLE_SimulatedProxy:当前游戏实例对该 Actor 没有权限,也不受 PlayerController 的控制。也就是说,该 Actor 的运动将通过使用它速度的最新值模拟 / 预测。
  • ROLE_AutonomousProxy:当前游戏实例对该 Actor 没有权限,但受 PlayerController 的控制。也就是说,可以基于玩家的输入向服务器发送该 Actor 的移动信息,这样更加准确。
  • ROLE_Authority:当前游戏实例对该 Actor 拥有完全权限。也就是说,如果该 Actor 在服务器上,对它进行修改后的结果将会强制同步到每个客户端上。

在服务器或客户端上生成 Actor,本地角色和远程角色的关系如下表:

服务器客户端
本地角色远程角色本地角色远程角色
服务器上生成 ActorROLE_AuthorityROLE_SimulatedProxyROLE_SimulatedProxyROLE_Authority
客户端上生成 Actor 不存在不存在 ROLE_AuthorityROLE_SimulatedProxy
服务器上生成玩家拥有的 PawnROLE_AuthorityROLE_AutonomousProxyROLE_AutonomousProxyROLE_Authority
客户端上生成玩家拥有的 Pawn 不存在不存在 ROLE_AuthorityROLE_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 上,或者 bRepPhysicstrue 的 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 支持大多数常见类型,但如 TSetTMap 等类型就不支持。需要注意的是可以传递任何 UObject 类及其子类的指针,如 Actor

如果创建一个带有 Actor 参数的 RPC,那么这个 Actor 也需要存在于远程游戏实例中,否则它的值为空。并且每个版本的 Actor 的实例名可能不同,也就是说,调用 RPC 时 Actor 的实例名可能和在远程实例上执行 RPC 的 Actor 的实例名不同。例如下图中,客户端控制的 Actor 实例名字为 BP_ThirdPersonCharacter_C_0,但服务器中看到的则是 BP_ThirdPersonCharacter_C_1

image-20250226133830483

直接在目标机器上执行 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() 中的 ReliableUnreliable 标识符描述了 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:如果其中一个玩家在初始阶段连接失败,会中止整个比赛。

玩家重生

玩家死亡想要重生时,通常有两种选择:

  1. 重用相同的 Pawn 实例,手动将其状态重置,并传送到重生位置;
  2. 销毁当前的 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),也会显示 GameModeGameStatePlayerStatePlayerControllerHUD 实例的值是否有效。

新建一个继承自 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 分;
  • 游戏将在没有可拾取物品时结束,所有玩家角色将被摧毁。五秒钟后,服务器会重新加载地图,再次开始游戏。

创建好第三人称项目后,首先创建一个继承自 GameStatePickupsGameState 类,它负责存放拾取数量,玩家排行等数据:

   
// .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》