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》