1 虚幻中的网络

Pasted image 20231003202346
虚幻引擎使用标准的客户端 - 服务器 (Client-Server)架构

服务器是权威(Authoritative) 的。所有数据必须首先从客户端发送到服务器。之后,服务器验证数据并根据您的代码做出反应。

服务器是权威的,意味着服务器运行的游戏版本被认为是正确的版本

运行单人游戏时,UE 仍然使用 CS 架构,只不过客户端和服务器是同一台机器。

Pasted image 20231006211813

1.1 一个小例子​

当您作为客户端在多人游戏中移动角色时,您不会自己移动角色,而是告诉服务器您想要移动它。然后,服务器会为其他人(包括您)更新角色的变换。

[!info]

此外,为了防止本地客户端有 “滞后” 的感觉,程序员通常还让本地客户端直接控制他们的角色——尽管当客户端开始作弊时,服务器仍然可能覆盖角色的位置!这意味着客户端(几乎)永远不会直接与其他客户端“交谈”。

1.2 另一个例子​

当向另一个客户端(个人、公会、队伍等)发送聊天消息时,您首先将其发送到服务器,然后服务器将其传递给您想要联系的客户端。

[!danger]
永远不要相信客户端!信任客户端意味着您在执行客户端的操作之前不会测试它们。
这会允许他们作弊!
一个简单的例子是发射武器:确保在服务器上测试客户端是否拥有所需数量的弹药,之后再允许射击而不是直接处理射击!

2 GamePlay 架构 + 网络

2.1 架构总结

根据前面关于虚幻引擎的 CS 架构和常用类的信息,我们可以将虚幻类分为四类:

  • Server Only - 仅服务器 - 这些对象只存在于服务器上
  • Server & Clients - 服务器和所有客户端 - 这些对象存在于服务器和所有客户端中
  • Server & Owning Client - 服务器和拥有客户端(即本地客户端) - 这些对象只存在于服务器和拥有客户端上
  • Owning Client Only - 仅拥有客户端,这些对象只存在于拥有客户端上

拥有客户端(Owning Client) 是指拥有相关 Actor 的 player/client。就像你拥有自己的电脑一样。所有权(Ownership)对于后面章节中的 “RPC “非常重要。

下面两幅图向您展示了一些常见的类别,以及它们属于哪些类别。
8a1f656c0ccacdb389055b2e883b707f_MD5|"Common Classes layed out in the four sections mentioned above."

记忆方法,只记单个的AGameMode、PlayerController、UI

第二幅图展示了一个有两个连接客户端的专用服务器(dedicated server)的示例。

27b08f9e7b9ed6bc9c57882c9cbe197a_MD5|"Venn Diagram of the Classes in a Dedicated Server with two connected Clients example."

2.2 GameMode(仅服务器)

[!NOTE]
在 4.14 中,AGameMode 类分为 AGameModeBase 和 AGameMode。 AGameMode 在 AGameModeBase 基础上拓展了对网络的支持。

AGameMode 类用于定义游戏规则。这包括要生成的其他游戏框架类,例如 APawn、APlayerController、APlayerState 等。

它仅在服务器上可用。客户端没有 AGameMode 类的实例,并且在尝试检索它时只会得到 nullptr。

2.2.1 示例和用法​

游戏模式的一些用例可能来自较早的第一人称射击游戏,例如《虚幻竞技场》:

Deathmatch, Team Deathmatch or Capture the Flag.
死亡竞赛、团队死亡竞赛或夺旗。

这意味着 GameMode 可以定义如下内容:

  • 团队赛还是个人赛?
  • 获胜条件是什么?
    • 杀敌数到达多少胜利?
  • 积分是如何获得的?
    • 杀人?
    • 夺旗?
  • 将使用什么角色?
  • 允许携带哪些武器?
* 只有手枪吗?
* 只有刀?

对于多人游戏场景,GameMode 还具有一些有趣的功能,可以帮助我们管理玩家和比赛的总体流程。

2.2.1.1 函数

GameMode蓝图的 Override 函数部分:
cbde69313852572dab83dc34073ca75f_MD5

您可以实现这些函数的逻辑,以适应您的游戏的特定规则。 这包括更改 GameMode 生成 DefaultPawn 的方式或您想要如何决定游戏是否已准备好开始。

一个例子可能是检查所有玩家是否已加入服务器并准备好:

  • @ 蓝图:
    Pasted image 20231001162035

    玩家数到达最大玩家数时返回 true

  • @ C++:
    由于 ReadyToStartMatchBlueprintNativeEvent,因此该函数的实际 C++ 实现称为 ReadyToStartMatch_Implementation。这是我们想要覆盖的:

title:MyGameMode.h
1
2
3
4
// 本场比赛所需/允许的最大Player人数
int32 MaxNumPlayers;

virtual bool ReadyToStartMatch_Implementation() override;
title:MyGameMode.cpp
1
2
3
4
5
6
bool ATestGameMode::ReadyToStartMatch_Implementation()
{
Super::ReadyToStartMatch();

return MaxNumPlayers == NumPlayers;
}

但也有一些事件可以用来对整个比赛中发生的某些事情做出反应。
我经常使用的一个很好的例子是事件 OnPostLogin。每次新玩家加入游戏时都会调用此方法。该事件会向您传递一个有效的 PlayerController 引用,该 Controller 由连接玩家的 UConnection 拥有 (详情 [[#Actors 和他们的拥有关系]])。

  • @ 蓝图
    Pasted image 20231001160607|300
    Pasted image 20231001162106

  • @ C ++
    OnPostLogin 函数是虚函数,在 C++ 中简称为 PostLogin

    title:MyGameMode.h
    1
    2
    3
    4
    5
    6
    // List of PlayerControllers
    UPROPERTY()
    TArray<APlayerController*> PlayerControllerList;

    // Overriding the PostLogin function
    virtual void PostLogin(APlayerController* NewPlayer) override;
title:MyGameMode.cpp
1
2
3
4
5
6
void ATestGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);

PlayerControllerList.Add(NewPlayer);
}

这可以用于与该玩家进行交互,例如,为他们生成一个新的 Pawn,或者只是将其 PlayerController 保存在数组中以供以后使用。

正如已经提到的,您可以使用 GameMode 来管理游戏的一般比赛流程。为此,您可以找到一些功能,其中一些功能是可覆盖的,例如Ready To Start Match

这些函数和事件可用于控制当前的 MatchState(匹配状态)。当“Ready To Start Match”函数返回 TRUE 时,它们中的大多数将被自动调用,但您也可以手动使用它们。
Pasted image 20231001162147

New State”是一个简单的“FName”类型。您现在可能会问,“为什么这不在 AGameState 类中处理?”嗯,确实如此。这些 GameMode 函数与 GameState 协同工作。
这只是为了给您一个点来管理任何客户端都无法访问的 MatchState,因为 GameMode 只存在于服务器上!

2.2.1.2 变量​

这是已经继承的变量的列表。其中一些可以通过 GameMode 蓝图的 ClassDefaults 进行设置:

80d227b1ee73d70c373d2144046809bd_MD5

ac948e6651eb07cead5c485776186c8c_MD5

其中大多数命名都很直白,例如Default Player Name,它使您能够为每个连接的玩家提供一个可以通过 APlayerState 类访问的默认玩家名称。
还有 bDelayedStart,这将使游戏无法开始,即使 Ready To Start Match 的默认实现满足所有其他条件。

更重要的变量之一是 Options String。这些是选项,用“?”分隔,您可以通过OpenLevel函数或当您将ServerTravel作为控制台命令调用时传递这些选项。

您可以使用 Parse Option 来提取传递的选项,例如MaxNumPlayers
Pasted image 20231001163900|400
Pasted image 20231001164757

2.3 GameState(服务器+所有客户端)

服务端:ROLE_Authority
客户端:ROLE_SiimulatedProxy

在 GameState 上不能执行 Client RPC ,因为 GameState 同步时无法确认所属玩家。PlayerState 则可以。我们可以在 PlayerState 上执行 Client RPC,在其中调用 GameState 中的函数,以实现 GameState 上的 Client RPC 功能。

[!info]
在 4.14 中,GameState 类被分为 AGameStateBase 和 AGameState。 GameStateBase 的功能较少,因为某些游戏可能不需要旧 GameState 类的完整功能列表。

AGameState 类可能是服务器和客户端之间共享信息的最重要的类。
GameState 用于跟踪游戏 / 比赛的当前状态。对于多人游戏来说,这包括已连接玩家的列表 (APlayerState)。

此外,它会复制给所有客户端,因此每个人都可以访问它。这使得 GameState 成为多人游戏中信息方面最为核心的类之一。

虽然 GameMode 会告诉您需要多少杀敌数才能获胜,但 GameState 将跟踪每个玩家和 / 或团队当前的杀敌数!

您在这里存储什么信息完全取决于您。它可以是得分数组或自定义结构数组

2.3.1 示例和用法​

在多人游戏中,AGameState 类用于跟踪游戏的当前状态,其中还包括玩家及其 PlayerState

GameMode 确保调用 GameState 的 MatchState 函数,并且 GameState 本身也允许您在客户端上使用它们

与 GameMode 相比,GameState 并没有给我们太多的帮助,但这仍然允许我们创建我们的逻辑,该逻辑主要应该尝试将信息传播给客户端。

2.3.1.1 变量​

460a118ae262e844e17ac30050cdac94_MD5

我们从 AGameState 基类中获取一些可以利用的变量。 PlayerArray、MatchState 和 ElapsedTime 都会被复制,因此客户端也可以访问它们。

AuthorityGameMode 除外。只有服务器可以访问它,因为 GameMode 仅存在于服务器上。

PlayerArray 不会直接复制,但是,每个 PlayerState 都会被复制,并且它们会在构造时将自己添加到 PlayerArray 中。此外,它们由 GameState 收集,只是为了确保竞争条件不会导致问题。

以下是 C++代码示例,展示了将 PlayerState 收集到 PlayerArray 中的快速插入方法:
PlayerState 类本身的内部

1
2
3
4
5
6
7
8
9
10
11
12
13
void APlayerState::PostInitializeComponents()
{
// […]

UWorld* World = GetWorld();
// Register this PlayerState with the Game's ReplicationInfo
if (World->GameState != NULL)
{
World->GameState->AddPlayerState(this);
}

// […]
}

并且在 GameState 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void AGameState::PostInitializeComponents()
{
// […]

for (TActorIterator<APlayerState> It(World); It; ++It)
{
AddPlayerState(*It);
}
}

void AGameState::AddPlayerState(APlayerState* PlayerState)
{
if (!PlayerState->bIsInactive)
{
PlayerArray.AddUnique(PlayerState);
}
}

所有这一切都发生在服务器以及 Player 和 GameState 的客户端实例上!

2.3.1.2 示例

我可以为您提供的一个小的函数示例是跟踪 “A” 和“B”两支球队的得分。 假设我们有一个 CustomEvent,当球队得分时会调用该事件。

它传递一个布尔值,这样我们就知道哪支球队得分了。我们还可以传递 PlayerState、Team 或任何您用来识别得分者的信息。

稍后在 “Replication 复制” 章节中,您将了解只有服务器可以(并且应该)复制变量的规则,因此我们确保只有服务器可以调用此事件。

该事件是从另一个类调用的(例如杀死某人的武器),并且这应该发生在服务器上(总是!),因此我们在这里不需要 RPC。

  • @ 蓝图
    Pasted image 20231001173813
    由于这些变量和 GameState 是复制的,因此您可以使用这两个变量并将它们放入您需要的任何其他类中。例如,将它们显示在记分板 widget 中。

  • @ C++

为了重新创建这个例子,我们需要更多的代码,但是除了函数本身之外,设置复制所需的代码只需要每个类一次。

title:MyGameState.h
1
2
3
4
5
6
7
8
9
10
11
12
// You need this included to get the replication working.
#include “UnrealNetwork.h”

// Replicated specifier used to mark this variable to replicate
UPROPERTY(Replicated)
int32 TeamAScore;

UPROPERTY(Replicated)
int32 TeamBScore;

// Function to increase the score of a team
void AddScore(bool bTeamAScored);
title:MyGameState.cpp
1
2
3
4
5
6
7
8
9
10
11
void ATestGameState::AddScore(bool bTeamAScored)
{
if (bTeamAScored)
{
TeamAScore++;
}
else
{
TeamBScore++;
}
}

2.4 PlayerState (服务器+所有客户端)

服务端:ROLE_Authority
客户端:ROLE_SimulatedProxy

APlayerState 类是共享特定玩家信息的最重要的类。它旨在保存有关玩家的当前信息。每个玩家都有自己的 PlayerState

PlayerState 也会复制给每个人,并可用于在其他客户端上检索和显示数据。
访问所有 PlayerState 的一个简单方法是 AGameState 类中的 PlayerArray

您可能想要存储在 PlayerState 中的示例信息:

  • PlayerName - 玩家的当前名称
  • Score - 玩家当前的分数
  • Ping - 玩家当前的 ping
  • TeamID - 玩家所在团队的 ID
  • 或其他玩家可能需要了解的其他复制信息

2.4.1 示例和用法​

我能提供的大多数例子都非常具体。因此,我们将看看一些已经可用的属性,以及一些更有趣的函数。

2.4.1.1 蓝图示例​

蓝图暴露了一些变量,它们或多或少有用。遗憾的是,其中一些并未公开其所有函数,因此最好用您自己的函数替换它们。

104ed35e4fa0a9d27583ee86b3d4d64f_MD5

这些变量都会被复制,因此它们在所有客户端上保持同步。

遗憾的是,它们在蓝图中不容易设置,但没有什么可以阻止您创建它们的版本。

设置 PlayerName 变量的一个示例是通过调用 GameMode 函数ChangeName,并将其传递给玩家的 PlayerController。
Pasted image 20231001174629

PlayerState 还用于确保数据在无缝关卡更改或意外连接问题期间保持持久性。

PlayerState 有两个专门用于处理重新连接玩家和与服务器无缝切换到新地图的玩家的功能。PlayerState 负责将其已保存的信息复制到新的 PlayerState 中。这要么是通过关卡更新创建的,要么是因为玩家重新连接而创建的。
Pasted image 20231001175334

  • @ c++实现
    让我们看一下 C++ 中的相同函数。
    title:TestPlayerState.h
    1
    2
    3
    4
    5
    // Used to copy properties from the current PlayerState to the passed one
    virtual void CopyProperties(class APlayerState* PlayerState) override;

    // Used to override the current PlayerState with the properties of the passed one
    virtual void OverrideWith(class APlayerState* PlayerState) override;
    这些函数可以在您自己的 C++ PlayerState 子类中实现,以管理您添加到自定义 PlayerState 的数据。确保在末尾添加“override”说明符,并调用“Super::”,以便原始实现保持活动状态。

您的实现可能与此类似:

title:TestPlayerState.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void ATestPlayerState::CopyProperties(class APlayerState* PlayerState)
{
Super::CopyProperties(PlayerState);

if (IsValid(PlayerState))
{
ATestPlayerState* TestPlayerState = Cast<ATestPlayerState>(PlayerState);
if (IsValid(TestPlayerState))
{
TestPlayerState->SomeVariable = SomeVariable;
}
}
}

void ATestPlayerState::OverrideWith(class APlayerState* PlayerState)
{
Super::OverrideWith(PlayerState);

if (IsValid(PlayerState))
{
ATestPlayerState* TestPlayerState = Cast<ATestPlayerState>(PlayerState);
if (IsValid(TestPlayerState))
{
SomeVariable = TestPlayerState->SomeVariable;
}
}
}

2.5 Pawn / Character(服务器+所有客户端)

PlayerController 一次只能拥有一个 Pawn,但可以通过-possess 和 unpossess 来轻松切换 Pawn。

Pawn 大部分被复制到所有客户端。

Pawn 的子类 ACharacter 经常被使用,因为它带有一个已经联网的 MovementComponent,用于处理复制玩家角色的位置、旋转等。

2.5.1 示例和用法​

在多人游戏中,我们主要使用 Pawn 的 Replication 部分来显示角色并与其他人共享一些信息。一个简单的例子是角色的“Health”。

我们不仅仅复制“Health”以使其对其他玩家可见,我们还复制它以使服务器对其具有权限,以防止客户端作弊。

2.5.1.1 蓝图​

尽管有标准的可重载函数,Pawn 也有两个事件可以让您对它被 PlayerController 或 AIController 拥有时做出反应。
Pasted image 20231001180252|300

[!NOTE]

  • 由于 possess 逻辑发生在服务器上,这些事件仅在 Pawn/Character 的服务器版本上调用。
  • ReceiveControllerChanged 事件:在 Controller 变更时后调用,在客户端和服务端都能调用。

下图将展示如何使用 EventAnyDamage 函数和复制的 Health 变量来降低玩家的生命值。

这发生在服务器上而不是客户端上!

Pasted image 20231001181826

由于 Pawn 应该被复制,只要服务器调用 DestroyActor 节点,它也会销毁 Pawn 的客户端版本。

在客户端站点上,我们可以将复制的“Health”变量用于 HUD 或每个人头顶上的健康栏。您可以通过创建带有 ProgressBar 和对 Pawn 的引用的 UserWidget 来轻松完成此操作。

假设我们的“BP_Character”类上有一个“Health”和“MaxHealth”变量,全部设置为复制(如果 MaxHealth 永远不会运行时改变,您可以不设置复制)。

现在,在 UserWidget 和 ProgressBar 内部创建“BP_Character”引用变量后,我们可以将该条的百分比绑定到以下函数:

Health Bar Settings

Pasted image 20231001182325

此外,在设置 WidgetComponent 后,我​​们可以将“Widget Class To Use”设置为您的 HealthBar UserWidget,并在 BeginPlay 上执行以下操作:

Pasted image 20231001182420

**“BeginPlay”在 Pawn 的所有实例上(服务器和所有客户端上)调用

所以现在每个实例都将自己设置为它所拥有的 UserWidget 的 Pawn 引用。

由于 Pawn 和生命值变量被复制,我们在每个 Pawn 的头部上方都有正确的百分比。

Pasted image 20231001182430

2.5.1.2 C++

对于 C++ 示例,我不会重新创建 UserWidget 示例。要让 UserWidgets 在 C++ 中工作需要做太多的模板式的东西,我不想在这里讨论这个。

所以我们将重点关注占有和伤害事件。在 C++中,两个 Possess 事件被称为:

1
2
3
virtual void PossessedBy(AController* NewController);

virtual void UnPossessed();

注意,UnPossessed 事件不会传递旧的 PlayerController。

And we also want to recreate the Health example in C++. As always, if you don’t understand the steps of replication at this moment, don’t worry, the upcoming chapters will explain it to you.
我们还想用 C++ 重新创建 Health 示例。如果您现在不明白复制的步骤,请不要担心,接下来的章节将为您解释。

如果示例在复制方面看起来太复杂,请暂时跳过这些示例。

TakeDamage”函数相当于“EventAnyDamage”节点。为了造成伤害,您通常会对要对其造成伤害的 Actor 调用“TakeDamage”,如果该 Actor 实现了该函数,它将对此做出反应,类似于本示例的做法。

title:TestPawn.h
1
2
3
4
5
6
// Replicated Health variable
UPROPERTY(Replicated)
int32 Health;

// Overriding the TakeDamage event
virtual float TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override;
title:TestPawn.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 该函数是必需的,UPROPERTY 宏中的Replicated指示符会为我们声明该函数。我们只需实现它
void ATestPawn::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// 告诉 UE 我们要复制这个变量
DOREPLIFETIME(ATestPawn, Health);
}

float ATestPawn::TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
const float ActualDamage = Super::TakeDamage(Damage, DamageEvent, EventInstigator, DamageCauser);

// Lower the Health of the Player
Health -= ActualDamage;

// And destroy it if the Health is less or equal 0
if (Health <= 0.f)
{
Destroy();
}

return ActualDamage;
}

2.6 PlayerController (服务器+拥有客户端)

APlayerController 类可能是我们遇到的最有趣、最复杂的类。它也是大量客户端逻辑的中心,因为这是客户端真正 “拥有 (owns) “的第一个类

PlayerController 可以看作是玩家的 “Input“。它是玩家与服务器的链接。这进一步意味着每个客户端都有一个 PlayerController

客户端的 PlayerController 只存在于本客户端和服务器

  • 每个客户端只知道自己的 PlayerController,无法访问其他客户端的 PlayerController。
  • 服务器拥有所有客户端 PlayerControllers 的引用!

“Input”一词并不直接意味着所有实际输入(按键、鼠标移动、控制器轴等)都需要放在 PlayerController 中。一个好的做法是:

  • 将 Pawn/Character 特定的输入(汽车的工作方式与人类不同)放入 APawn/ACharacter 类中
  • 将适用于所有 Character 或甚至当 Character 对象无效时的输入,放入 PlayerController 中。

[!question]
如何获取正确的 PlayerController?

节点 GetPlayerController(0) 或代码行 UGameplayStatics::GetPlayerController(GetWorld(), 0); 在服务器和客户端上的工作方式不同:

  • 在监听服务器(Listen-Server)上调用它将返回监听服务器的 PlayerController
  • 在客户端上调用它将返回客户端的 PlayerController
  • 在专用服务器(Dedicated Server)上调用它将返回第一个客户端的 PlayerController

除 “0 “以外的其他数字将不会返回某个客户端的其他客户端 PlayerControllers。该索引用于本地玩家(分屏),我们在此不做介绍。

2.6.1 示例和用法

尽管 APlayerController 是网络中最重要的类之一,但默认情况下它的功能并不多。

因此,我们将创建一个小示例来说明为什么需要它。在 “所有权 (ownership) “一章中,你会了解到为什么 PlayerController 对于 RPC 非常重要。

下面的示例将向您展示如何利用 PlayerController,通过按下 UserWidget 按钮来递增 GameState 中的一个复制变量。

[!question] 为什么需要使用 PlayerController?
UserWidgets 只存在于本地播放器(客户端或 ListenServer)上,即使它们被客户端拥有,ServerRPC 也无法在服务器上运行它们的实例。它根本无法复制!

这意味着我们需要一种方法,将 button Press 发送到服务器,这样服务器就可以递增变量。

RPC 和所有权章节会有详细介绍!

[!question] 为什么不直接调用 GameState 上的 RPC?
因为它归服务器所有。ServerRPC 需要客户端作为所有者!

2.6.1.1 蓝图

因此,首先,我们需要一个简单的 UserWidget,上面有一个可以按下的按钮。

我将以相反的顺序张贴图片,这样你就能看到图片的结尾,以及哪些事件呼应了前面图片中的事件。

因此,从我们的目标 GameState 开始。它会收到一个普通事件,该事件会递增一个复制的整数变量:
Pasted image 20231001200904

该事件将在服务器端调用,就在我们的 PlayerController 中的 ServerRPC 内部:
Pasted image 20231001200911

最后,我们的按钮被按下并调用 ServerRPC:
Pasted image 20231001200919

因此,当我们点击按钮(客户端)时,我们使用 PlayerController 中的 ServerRPC 来进入服务器端(这是可能的,因为 PlayerController 是客户端所有的!),然后调用 GameState 的 “IncreaseVariable “事件来递增复制的整数变量。

由于这个整数变量是由服务器复制和设置的,因此现在会在 GameState 的所有实例上更新,这样客户端也能看到更新!

2.6.1.1.1 C++
title:TestGameState.h
1
2
3
4
5
6
7
// Replicated integer variable
UPROPERTY(Replicated)
int32 OurVariable;

public:
// Function to increment the variable
void IncreaseVariable();
title:file:TestGameState.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
//此函数是必需的,并且UPROPERTY宏中复制的说明符会为我们声明它。我们只需要实现它
void ATestGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// This tells UE that we want to replicate this variable
DOREPLIFETIME(ATestGameState, OurVariable);
}

void ATestGameState::IncreaseVariable()
{
OurVariable++;
}

在本例的 C++ 版本中,我将用 PlayerController 的 BeginPlay 代替 UserWidget。不过,用 C++ 实现 UserWidget 需要更多代码,我不想在此赘述。

title:TestPlayerController.h
1
2
3
4
5
6
// Server RPC. You will read more about this in the RPC chapter  
UFUNCTION(Server, unreliable, WithValidation)
void Server_IncreaseVariable();

// Also overriding the BeginPlay function for this example
virtual void BeginPlay() override;
title:TestPlayerController.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Otherwise we can't access the GameState functions
#include “TestGameState.h”

// You will read later about RPCs and why '_Validate' is a thing
bool ATestPlayerController::Server_IncreaseVariable_Validate()
{
return true;
}

// You will read later about RPCs and why '_Implementation' is a thing
void ATestPlayerController::Server_IncreaseVariable_Implementation()
{
ATestGameState* GameState = Cast<ATestGameState>(UGameplayStatics::GetGameState(GetWorld()));
GameState->IncreaseVariable();
}

void ATestPlayerController::BeginPlay()
{
Super::BeginPlay();

//BeginPlay在Actor的每个实例上都被调用,在该PlayerController的服务器版本上也是如此。
//我们希望确保,只有本地player调用此RPC。同样,这个例子不一定有多大意义
//因为我们可以翻转条件,根本不需要RPC,但是C++Widget,你知道。。。
//我们也可以在这里使用“IsLocalPlayerController()”
if (Role < ROLE_Authority)
{
Server_IncreaseVariable();
}
}

这是相当多的代码。如果你还不理解其中一些函数的用法和命名,不用担心。接下来的章节将帮助你理解为什么要这样做。

2.7 AIController(仅服务器)

2.8 AHUD(仅拥有客户端)

AHUD 类仅在每个客户端上可用,可通过 PlayerController 访问。它将由 PlayerController 自动生成。

在 UMG(虚幻动态图形)发布之前,AHUD 类一直用于在客户端的视口中绘制文本、纹理等。

现在,UserWidgets 在 99% 的情况下都取代了 HUD 类。

您仍然可以使用 AHUD 类进行调试,或者使用一个独立区域来管理 UserWidget 的创建、显示、隐藏和销毁。

由于 HUD 与多人游戏没有直接联系,因此示例只能显示单人游戏的逻辑,所以本课将跳过这些示例。

2.9 UUserWidget (仅拥有客户端)

UUserWidgets 用于 Epic Games 的用户界面系统,该系统被称为虚幻动态图形(UMG,Unreal Motion Graphics)

它们继承自 Slate,Slate 是一种用于在 C++ 中创建用户界面的语言,同时也用于虚幻引擎编辑器本身。

Widgets are only available locally. They don’t replicate and should not contain and replication code. Preferably they wouldn’t contain any gameplay code either, but some games might require it.
Widgets只能在本地使用。它们不会复制,也不应包含复制代码它们最好也不包含任何游戏代码,但有些游戏可能需要。

要了解有关 UMG 和小工具的更多信息,请使用上面提供的 API 链接。

在 APawn 示例中,我们已经有一个使用 Widgets 的小例子。因此,我将在此略过。

2.10 相关函数

Pasted image 20231117203904

3 NetMode 网络模式

NetMode 是 World 的属性 :ENetMode UWorld::GetNetMode()

可能是以下四个值:NM_Standalone, NM_DedicatedServer, NM_ClientSever, NM_Client

Pasted image 20231117133442
区分网络模式的依据:

  1. 游戏可以玩吗?即我们的 GameInstance 是否有 LocalPlayer,是否可以处理 Player 的输入并将 World 渲染到 Viewport。
  2. 是服务器吗?换句话说,我们的 GameInstance 是否有用权威的世界副本(其中包含 GameMode Actor)
  3. 如果是服务器。是否开放远程连接?其他 Player 可以作为客户端加入游戏。

前面的视频 The Unreal Engine Game Framework: From int main() to BeginPlay - YouTube (还没看,有空补一下)提到,当启动游戏时,会获得一个与进程生命周期相关的 GameInstance 对象,然后游戏引擎能够浏览 URL,这个 URL 可以是一个客户端的服务器地址,也可以是一个本地地图的地址。这会导致游戏加载 Map ,从而赋予他一个 World。
World 的网络模式将根据 GameInstance 的启动方式而有所不同:
Pasted image 20231117134600

  • 如果 GameInstance 已经连接了一个远程服务器,World 就是 NM_Client 网络模式。所以你的 world 只能按照服务器的 world 来更新。
  • 如果 GameInstance 已经在本地加载 Map,你的 world 就是 NM_Standalone 网络模式。因此你的 GameInstance 既是服务器也是客户端。它在单人配置中运行,不开放客户端连接。
  • 如果 GameInstance 在本地加载 Map,但是有监听选项,那么你的 world 就是 NM_ListenSever 网络模式。基本上与 NM_Standalone 相同,但是游戏的其他实例依然能作为客户端访问。
  • 如果 GameInstance 是 NM_DedicateServer 网络模式,这个游戏实例既没有 localplayer,也没有 viewport。这只是一个服务器端控制台应用程序,玩家可以作为服务端连接。

3.1 专用服务器与监听服务器

Pasted image 20231003203817

  • Playe Standalone:独立的客户端,无服务器
  • Play As Listen Server:本机作为监听服务器,小窗口为客户端
  • Play As Client:全部为客户端,服务器为专用服务器

3.1.1 Dedicated Server 专用服务器

专用服务器是不需要玩家托管的独立服务器。

它与游戏客户端分离运行,主要用于运行一个服务器,玩家可以随时加入/离开,而服务器不会随之关闭。

专用服务器可在 Windows 和 Linux 下编译,也可在云服务器上运行,玩家可通过固定 IP 地址连接到云服务器。

专用服务器没有视觉(visual)部分,因此不需要 UI,也没有 PlayerController 。它们在游戏中也没有 Character 或类似的代表。

3.1.2 Listen Server 监听服务器

监听服务器是指同时也是客户端的服务器。(用自己的电脑开服务器,还能同时玩游戏~)。

主机自身是没有延迟的

由于同时也是客户端,Listen-Server 需要 UI,并有一个代表客户端部分的 PlayerController。在监听服务器上获取 PlayerController(0)将返回该客户端的 PlayerController 。

由于监听服务器在客户端上运行,其他人需要连接的 IP 就是客户端的 IP。与专用服务器相比,这往往会带来玩家没有静态 IP 的问题。

不过,使用 OnlineSubsystem(详情[[4 会话管理#2 在线子系统概述]])可以解决更改 IP 的问题。

4 Replication 复制 (同步)

Pasted image 20240521132554

4.1 简介

Replication 是服务器将信息 / 数据传递给客户端的行为。

[!bug] 注意方向,不能反过来!

1
2
>flowchart LR
>服务器--Replication-->客户端;

每个 Actor 都维护一个包含全部属性的列表,其中包含 Replicated 说明符。每当复制的属性值发生变化时,服务器会向所有客户端发送更新。客户端会将其应用到 Actor 的本地版本上。这些更新只会来自服务器,客户端永远不会向服务器或其他客户端发送属性更新。

[!warning]
我们不推荐在客户端上更改复制的变量值。该值将始终与服务器端的值不一致,直到服务器下一次侦测到变更并发送更新为止。如果服务器版本的属性不是经常更新,那客户端就需要等待很长时间才能被纠正。

Actor 属性复制是可靠的。这意味着,Actor 的客户端版本的属性最终将反映服务器上的值,但客户端不必接受服务器上某个属性的每一个单独变更。例如,如果一个整数属性的值快速从100变成200,然后又变成了300,客户端将最终接受一个值为300的变更,但客户端不一定会知道这个值曾经变成过200。
属性同步的产生是为了维持对象的状态,是一个从概念上非常贴近“同步”二字的功能,一旦服务器上的同步属性发生了变化,就一定会发送给客户端(注意:属性同步只是服务器向客户端的同步,不存在客户端向服务器流通),也许中间会丢包会延迟(actor 首次同步时是 reliable 的),但是其内置的机制会保证属性的值最终送达到客户端。借用一句经典的话来说就是,同步数据也许会迟到,但是永远不会缺席


第一个可以复制属性的类是 AActor 类。虽然您也可以复制 UObject,但它们是通过 AActor 复制的,因此仍然需要某种 AActor 来处理复制。虚幻引擎中的复制子对象 | 虚幻引擎5.2文档 (unrealengine.com)
UActorComponent 就是一个很好的例子,它支持通过 AActor 复制 UObjects,而不需要我们做太多额外的工作。

前面提到的所有类都在某种程度上继承自 AActor,从而使它们能够在需要时复制属性。不过,并非所有类的复制方式都相同。
例如,AGameMode 不会复制,因为只存在于服务器上。而 AHUD、UUserWidget 只存在于客户端,也不会复制。

4.2 复制原理

4.2.1 复制流程

UE 复制系统依赖于三个重要的类:UNetDriverUNetConnectionUChannel
下面以 Dedicated Server 网络模式举例,我们有一个专用服务器和两个客户端。
Pasted image 20231117140830
无论客户端进程(这里理解成一个.exe 就是一个进程)还是服务器进程都有 UGameEngine* Engine 对象,而每个 Engine 都有自己的 UNetDriver* GameNetDriver 对象。

  • 当 Server 启动时,Engine 创建 GameNetDriver,GameNetDriver 开始监听远程进程的连接请求。
  • 当客户端启动时,Engine 创建 GameNetDriver,GameNetDriver 开始向服务器发送连接请求。
  • 一旦连接建立,Server 就会建立一个 UNetConnection 数组 ClientConnections 来维护所有客户端连接。
    • Server 会为每个连接的客户端单独建立一个 UNetConnection。
    • 但是客户端只有一个 UNetConnection* ServerConnection,代表自己与服务器的连接。
  • 每个 UNetConnection 内有都有许多不同的的 Channel,通常一个连接将具有一个 UControlChanel, 一个 UVoiceChannel,多个 UActorChannel(每个 ActorChannel 对应一个通过该连接复制的 Actor)。

4.2.2 Actor 复制

由上节可知,复制发生在 Actor 层级,如果需要 Actor 通过网络同步,就要设置 bReplicates=true 。服务器用 IsNotRelevantFor() 来检测这个 Actor 属于哪个 Player,然后在该 Player 的 UNetConnection 中打开 UActorChannel ,服务器和客户端将使用该通道来交换该 Actor 的信息。
Pasted image 20231117142017

如果 Actor 被复制到客户端,主要关注三件重要的事情:

  1. 生命周期:Actor 的生命周期在服务器和客户端之间保持同步。 服务器生成一个 bReplicates=true 的 Actor,客户端将收到通知,在本地复制该 Actor。如果这个 Actor 在服务器上被销毁,客户端复制的 Actor 同样也会被销毁。
    • 如果一个 Actor 的 bReplicates 设置为 true,那么该 Actor 将被生成并复制到所有客户端(如果该角色是由服务器生成的)。而且只有在服务器生成时才会复制。
    • 如果客户端生成了这个 Actor,该 Actor 将只存在于这个客户端上。
  2. 属性复制(Property Replication):如果 Actor 有一个标记为 Replicated 的属性,那么如果服务器上的 Property 改变,客户端的 Property 也会随之改变。
  3. RPC

4.3 如何使用 Replication

  1. 蓝图:类默认设置
    Pasted image 20231001225526|450

  2. C++:复制可以在 AActor 子类的构造函数中激活:
    Character构造函数示例

1
2
3
4
5
6
ATestCharacter::ATestCharacter(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
bReplicates = true;
bReplicateMovement = true;
}
  1. 可以在运行时打开或关闭复制:
    Pasted image 20231117150733|500

4.4 属性复制(属性同步)

4.4.1 Replicated

Pasted image 20231001225820|500

启用复制后,我们可以在 Actor 内部复制变量。有多种方法可以做到这一点。我们将从最基本的方法开始:

将 “复制” 下拉菜单设置为 “Replicated“,将确保此变量被复制到此 Actor 的所有复制实例中。
Pasted image 20231001230029|298

Replicated 变量用两个白圈标出。

  • @ 在 C++ 中复制变量所需的工作稍多一些:
    title:TestPlayerCharacter.h
    1
    2
    3
    // Create replicated health variable
    UPROPERTY(Replicated)
    float Health;

.cpp 文件需要实现 GetLifetimeReplicatedProps 函数。在将变量标记为 Replicated 时,UE 已经为我们创建了该函数的头声明。
在此函数中,您可以定义复制变量的规则。

title:TestPlayerCharacter.cpp
1
2
3
4
5
6
7
void ATestPlayerCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// 这里列出我们想要复制的变量
DOREPLIFETIME(ATestPlayerCharacter, Health);
}

4.4.2 RepNotify—ReplicatedUsing

如果需要在复制时运行某些代码,可以使用复制变量的另一种方法,是将变量标记为 ReplicatedUsing(C++)。

在蓝图中,这被称为 RepNotify(代表通知)。它允许指定一个函数,当变量的新值被复制到客户端时,该函数将被调用。
Pasted image 20231001233317

Set 变为“使用通知设置”

在蓝图中,一旦在 “复制” 下拉菜单中选择 “RepNotify“,该功能就会自动创建:
Pasted image 20231001233531|373

C++ 版本需要的更多,但工作原理相同:

title:ATestCharacter.h
1
2
3
4
5
6
7
// 创建 RepNotify Health 变量
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;

// 创建 OnRep 函数 | UFUNCTION() 宏很重要!
UFUNCTION()
void OnRep_Health(const float& Health);
title:ATestCharacter.cpp
1
2
3
4
5
6
7
void ATestCharacter::OnRep_Health(const float& Health)
{
if (Health <= 0.f)
{
PlayDeathAnimation();
}
}

通过 ReplicatedUsing=函数名,我们指定了变量复制成功后应调用的函数。该函数必须包含 “UNFUNCTION ()“ 宏,即使该宏为空!

RepNotify 也要实现 GetLifetimeReplicatedProps 函数!

[!NOTE] RepNotify 蓝图和 C++之间的区别
值得注意的是,C++ 和蓝图 对 RepNotify 的处理方式略有不同。

  • 在 C++ 中,OnRep 函数只调用客户端。

    • 当服务器更改值并要求同时调用 OnRep 函数时,您需要在调整变量后手动调用该函数。这是因为 OnRep 函数的作用是在变量复制到客户端时进行回调。
  • 在蓝图中,OnRep 函数将调用客户端和服务器

    • 这是因为 BP 版本的 OnRep 是 “属性已更改(Property Changed)” 回调。这意味着该函数不仅会调用服务器,而且如果客户端在本地更改了变量,也会调用客户端。

4.4.3 条件属性复制

默认情况下,每个复制属性都有一个内置条件:如果不发生变化就不会进行复制。

为了加强对属性复制的控制,UE 提供了宏来添加附加条件:

  • @ Replicated 使用 DOREPLIFETIME_CONDITION 宏。

    1
    2
    3
    4
    5
    6
    7
    void ATestPlayerCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
    {
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // 仅向该Object/Class的所有者复制变量
    DOREPLIFETIME_CONDITION(ATestPlayerCharacter, Health, COND_OwnerOnly);
    }
  • @ 对于 RepNotify,使用 DOREPLIFETIME_CONDITION_NOTIFY

    1
    2
    3
    4
    5
    6
    7
    8
    void ATestPlayerCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
    {
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    //COND_None:此属性没有条件,并将在其发生变化时随时发送(服务器)
    //REPNOTIFY_Always:属性复制时,客户端总是调用该属性的 RepNotify 函数(客户端)
    DOREPLIFETIME_CONDITION_NOTIFY (ATestPlayerCharacter, Health, COND_None, REPNOTIFY_Always);
    }
Condition 条件 说明
COND_InitialOnly 该属性仅在初始数据组尝试发送
COND_OwnerOnly 该属性只会发送给Actor的所有者(owner)
COND_SkipOwner 此属性会发送至除 Owner 之外的所有连接
COND_SimulatedOnly 此属性只会发送到模拟Actor(Simulated Actor)
COND_AutonomousOnly 该属性只会发送给自治Actor(autonomous Actor)
COND_SimulatedOrPhysics 该属性将发送到simulated 或 bRepPhysics Actor。
COND_InitialOrOwner 该属性将发送初始数据组,或发送给 Actor 的所有者
COND_Custom 该属性没有特定条件,但需要通过 SetCustomIsActiveOverride

优点:

  1. 节省带宽
  2. 对于不接收该属性的客户端而言,服务器无需干涉这个客户端的本地副本。

如果这样的控制力还不够,那该怎么办? 关于这个话题,还有一件事需要讨论。有一个名叫 DOREPLIFETIME_ACTIVE_OVERRIDE 的宏可以让您进行全面控制,利用您想要的任何定制条件来决定何时复制/不复制某个属性。需要注意的是,这种控制需针对每个 actor(而不是每条连接)逐一进行。换句话说,如果在定制条件中使用一个可根据连接而发生变化的状态,会存在一定的安全风险。具体示例如下。

1
2
3
4
void AActor::PreReplication( IRepChangedPropertyTracker & ChangedPropertyTracker )
{
DOREPLIFETIME_ACTIVE_OVERRIDE( AActor, ReplicatedMovement, bReplicateMovement );
}

现在 ReplicatedMovement 属性只会在 bReplicateMovement 为 true 时复制。

为何不一直使用这个宏?主要有两个原因:

  • 如果定制条件的值变化太大,这种做法会降低执行速度。
  • 您不能使用根据连接而变化的条件(此时不检查 RemoteRole)。

4.4.4 复制对象引用

一般而言,对象引用会在 虚幻引擎(UE) 多人游戏架构中自动处理。这就是说,如果您有一个已经复制的 UObject 属性,则对该对象的引用将作为服务器分配的专门 ID 通过网络进行发送。这个专门 id 是一个 FNetworkGUID。服务器将负责分配此 id,然后向所有已连接的客户端告知这一分配。

要复制对象引用,您只需将一个 UObject 属性标记为已复制,就像下面这样:

1
2
3
4
5
class ENGINE_API AActor : public UObject
{
UPROPERTY(Replicated)
AActor* Owner;
};

这时,”Owner” 属性将作为其引用的 actor 的一个复制引用。

对于通过网络合法引用的对象,必须对其提供支持以保证网络连接。要进行检查,您可以调用 UObject::IsSupportedForNetworking()。这通常被认为是一个底层函数,所以一般不需要在游戏代码中对其进行检查

您通常可以按照以下原则来确定是否可以通过网络引用一个对象:

  • 任何复制的 actor 都可以复制为一个引用
  • 任何未复制的 actor 都必须有可靠命名(直接从数据包加载)
  • 任何复制的组件都可以复制为一个引用
  • 任何未复制的组件都必须有可靠命名。
  • 其他所有 UObject(非 actor 或组件)必须由加载的数据包直接提供

4.4.4.1 拥有可靠命名的对象

拥有可靠命名的对象指的是存在于服务器和客户端上的同名对象。

如果 Actor 是从数据包直接加载(并非在游戏期间生成),它们就被认为是拥有可靠命名。

满足以下条件的组件即拥有可靠命名:

  • 从数据包直接加载
  • 通过简单构建脚本添加
  • 采用手动标记(通过 UActorComponent::SetNetAddressable 进行)
    • 只有当您知道要手动命名组件以便其在服务器和客户端上具有相同名称时,才应当使用这种方法(最好的例子就是 AActor C++ 构造函数中添加的组件)

4.4.5 Replicated 和 ReplicatedUsing 的区别

ChatGPT 生成

在虚幻引擎中,将变量标记为”Replicated”和”ReplicatedUsing”都是用于实现变量在网络中的同步。它们的区别在于同步的方式和实现的灵活性。

  1. Replicated“:
    • 当将变量标记为”Replicated”时,引擎会自动处理变量的同步。它会在服务器和客户端之间自动复制变量的值,并确保它们保持同步。
    • 这种方式适用于简单的同步需求,例如玩家的位置、血量等信息。引擎会自动处理同步细节,无需额外的代码。
  2. ReplicatedUsing=FunctionName“:
    • 当将变量标记为”ReplicatedUsing”时,需要为该变量编写自定义的同步函数。这个函数将负责处理变量的同步逻辑。
    • 这种方式适用于需要更复杂的同步需求,例如需要在同步时执行特定的逻辑或转换。通过自定义同步函数,可以更灵活地控制变量的同步过程。
    • 自定义同步函数需要在服务器和客户端上都实现,并使用相同的函数签名。

总结起来,”Replicated”是一种简单的同步方式,由引擎自动处理同步细节;而”ReplicatedUsing”是一种更灵活的同步方式,需要编写自定义的同步函数来控制同步逻辑。选择哪种方式取决于同步需求的复杂性和灵活性。

4.4.6 Actor 以及 Component 的同步顺序

一个对象的同步首先要给客户端上的对象与服务器上的对象建立关联,这样服务器的A变化了才会告诉客户端上的A也去变化。但是A是一个对象,对象也是需要同步的,一个场景里面有那么多的对象,同步肯定是按顺序的来的。这样就会经常出现A的对象里面有很多指向B对象的同步指针属性,但是A对象出现的时候B还没同步过来,所以在A的Beginplay里面访问B是不行的。
那么如何解决这个问题?答案是用属性回调,一旦执行了属性回调,就可以确保A的B指针是存在的。不过,属性回调并不能解决所有问题。假如B对象还有C对象的指针,回调的时候C还没同步过来,你想用B去访问C发现又是空指针。这问题目前在现在的虚幻引擎里面还没有完美的解决方案,所以我们要尽可能的避免这种情况。
Pasted image 20240714212608

4.4.7 属性同步和 RPC 比较

RPC 只适合临时的事件,如果要发送持久化状态,还是要使用 Replicated Properties。所有客户端最终都会与服务器同步。
Replicated Properties 同样和网络相关。尽管 Replicated Properties 可能发生延时,比如服务器上的 Actor 距离客户端操控的 Pawn 太远导致暂时没有相关性时,Actor 在服务器的更改客户端看不到,但是只要距离接近恢复相关性,会立刻把更新的 property 复制给你的 pawn。
属性赋值遵循更新频率和带宽限制

如何选择?

摘自: Replicated Properties vs RPCs - Programming & Scripting / Multiplayer & Networking - Epic Developer Community Forums (unrealengine.com)

本文旨在深入探讨复制属性与 RPC 的使用方法,并探讨何时以及如何使用复制属性最合适。

一般来说,性能问题不是在 RPC 和属性复制之间做出选择的充分理由。为了更好地确定使用哪种方法,你应该问自己几个问题:

  • 我是否真的需要在这些值每次变化时都发生响应事件,还是只关心其最新值?
  • 我是否需要在某个 Actor 成为相关 Actor、某个 Player 加入游戏过程或重放录制 / 刷新时恢复这些数据,还是说我可以接受这些数据 “永远消失”?
  • 这些值取决于 Actor 的其他属性的状态,还是它们本身就修改

一般来说,属性复制最适合需要最新数据,但不一定关心中间步骤的情况。
因为属性只有在实际发生变化时才会被发送,而且我们可能不会在每一帧都将每个 Actor 复制到每个连接,所以你可以省去一些工作,并确信你会得到正确的值。
此外,虽然属性数据的传输是 “不可靠的”,但我们会向客户发送多余的属性更改,直到收到确认为止。而不可靠的 RPC 则没有这种保证。

您可以通过可靠的 RPC 来确保接收事件,但这些 RPC 需要更多的开销,而且应该很少使用。此外,属性复制只适用于相关性、进行中的连接和重放擦除(replay scrubbing )情况。

无论何时,只要 Actor 第一次被复制到给定的连接,就能保证与该连接相关的属性会被复制到该连接。同样,RPC 并不提供这样的保证。

摘自 RPC 文档:”这些功能的主要用例是用于处理瞬时性或外观性 (transient or cosmetic) 的不可靠游戏事件”。这意味着 RPC 没有最终的一致性 (eventual consistency)。
如果数据包丢失,或者 RPC 没有发送到特定客户端,它就永远不会到达该客户端。在连接进行中、相关性和重放擦除等情况下,你将无法保证 Actor 状态的一致性。
不过,这也意味着,如果你真的只想立即发送信息,而不在乎它是否能到达客户端,那么你就不会浪费资源来比较每一帧的属性或冗余地传输数据。
同样重要的是,RPC 是客户端向服务器发送数据的唯一方式。

不过,使用不可靠的 RPC 是否会比使用复制属性更快,或者反之亦然,这一点可能并不清楚。同样,这也很难量化,取决于系统其他部分的设置情况。
RPC 最终需要通过 Actor(具体来说,就是由 UNetConnection 所拥有的 Actor)进行路由,而所有 Actor 都有一套基本的复制属性。这两种情况都会通过线路传输数据,而且格式大致相同。

然而,数据的路由和应用方式却有些不同。
首先,需要注意的是序列化几乎是相同的。例如,RPC 和属性复制都依赖于复制布局(参见 Engine\Source\Runtime\Engine\Public\Net\RepLayout. h )。
每个类将生成一个 RepLayout,每个 RPC 将生成一个 RepLayout,因此,添加一个新属性的内存开销将比添加一个新 RPC 的内存开销略少。当然,这可以很容易地被类的实例数量所抵消。
如果有很多实例,那么最终每个实例上的属性所占用的内存将超过创建 / 跟踪 RepLayout 函数的内存。

就性能而言,RPC 最终调用的虚拟函数调用和查找次数要比属性复制多得多,而且每次调用 RPC 时都需要进行这些操作。
此外,这些调用将在调用 RPC 的帧中发生,而不是在帧的末尾分批进行(如属性复制),因此单次 RPC 更有可能出现缓存缺失

RPC 的流程如下:

  • 你调用 RPC 函数。
  • 参数会被复制到一个参数结构体(由虚幻头文件工具 UHT 生成)中。
  • Next, we search for the UFunction object that represents the RPC (by name through a map lookup), and pass that and the params to UObject:: ProcessEvent (virtual call).
    接下来,我们搜索代表 RPC 的 UFunction 对象(通过映射查找名称),并将其和参数传递给 UObject::ProcessEvent(虚拟调用)。
  • In ProcessEvent, we need to figure out the callspace of the function (virtual call to determine if it’s Local, Remote, Both, or Suppressed).
    在 ProcessEvent 中,我们需要找出函数的调用空间(虚拟调用,以确定是本地、远程、两者或抑制)。
  • If it is remote we’ll call UObject:: CallRemoteFunction (again, another virtual).
    如果它是远程的,我们将调用 UObject::CallRemoteFunction(同样是另一个虚拟函数)。
  • Then we need to iterate over every available NetDriver associated with the world (usually there’s only 1 anyway) and call ProcessRemoteFunction (another virtual).
    然后,我们需要遍历与世界相关的每个可用 NetDriver(通常只有一个),并调用 ProcessRemoteFunction(另一个虚拟函数)。
  • There’s then a ton of lookups to resolve: the actor’s connection, the actor’s channel (and may result in actor channel creation if one doesn’t exist), the actual function being called, and the RepLayout for the function (again, creation may occur if one doesn’t exist).
    然后需要解决大量的查找问题:Actor 的连接、Actor 的通道(如果不存在,可能会创建 Actor 通道)、实际调用的函数以及函数的 RepLayout(同样,如果不存在,可能会创建 RepLayout)。
  • Next, we’ll serialize the properties.
    接下来,我们将对属性进行序列化。
  • At this point, if you’re using an unreliable multicast RPC or if we’re forcing the RPC to be queued, that data will be cached off and then sent with replicated property data later anyway. If we’re not queueing, we’ll go ahead and try to send it immediately.
    此时,如果你使用的是不可靠的多播 RPC,或者我们强制 RPC 必须排队,那么这些数据就会被缓存起来,随后再与复制的属性数据一起发送。如果没有排队,我们就会立即发送。

The flow of Property Data looks like this (note that this is for standard Net Driver replication, not using RepGraph):
属性数据的流程如下(注意,这是标准 Net 驱动程序复制,而不是使用 RepGraph):

At the end of the frame, the server will figure out all actors that may need to replicate.
在帧结束时,服务器会找出所有可能需要复制的角色。
The server will then prioritize those actors.
然后,服务器将优先处理这些行为者。
Each connection will then process the prioritized actors, replicating what they need to.
然后,每个连接都将处理优先级 Actor,复制它们需要的内容。
The first time in a frame an actor is replicated (or if we are forcing net comparisons), we will iterate over all replicated properties to generate a changelist (literally, a list of handles of properties that changed), unless push model is enabled, in which case only properties marked as dirty will be checked.
除非启用了推送模型,否则在这种情况下,只有标记为脏的属性才会被检查。
Then, the server will send just the changed properties.
然后,服务器将只发送已更改的属性。

Again, it’s important to note that the steps for RPCs happen every time an RPC is invoked, but the steps for Property Replication will happen every frame regardless.
同样,需要注意的是,RPC 的步骤在每次调用 RPC 时都会发生,但属性复制的步骤无论如何都会在每一帧发生。
So, if there’s a single new property that you want to add, that property isn’t immensely expensive to compare, and the actor/object is already replicating other properties, then there will be significantly less overhead to just adding a new property.
因此,如果你只想添加一个新属性,而该属性的比较成本并不高,并且 Actor / 对象已经在复制其他属性,那么只添加一个新属性的开销就会大大减少。
However, if the properties are extremely expensive to compare or you know you only need them to be updated rarely, using RPCs might be faster, and again, RPCs are the only way to get data back to the server from the client.
不过,如果属性的比较成本极高,或者你知道只需要很少更新这些属性,那么使用 RPC 可能会更快,同样,RPC 也是将数据从客户端传回服务器的唯一方法。

回到 RPC 文档中的那一部分:” 这些功能的主要用例是用于处理瞬时性或外观性的不可靠游戏事件。这些事件可能包括播放声音、产生粒子或其他对 Actor 运行并不重要的临时效果。以前,这些类型的事件通常会通过 Actor 属性进行复制 “。
同样,这里描述的情况不是出于性能原因,而是出于游戏原因。让我们来看看播放声音的例子。
如果您有一些因属性而触发的声音,而某个 Actor 又变得相关了(或有人加入了进程等),那么您可能会遇到这些声音被重新触发的情况。

所以一般来说,RPC 应该用于特效提示和类似的非关键网络信息传递,而属性则用于其他一切。 无论如何,属性复制都会在角色上发生,如果属性复制成为瓶颈,你可以开始使用大量的优化措施。如果你确实只需要触发一个一次性事件,而且你不在乎该事件是否会被丢弃,或者你需要从客户端向服务器发送数据,那么你当然可以使用 RPC。

4.5 例子

在你自定义的 actor 比如 weapon 设置 bReplicates = true,这个 bReplicates = true,就意味着如果服务器创建了这个 weapon,那么这个 actor 就会把这个 weapon 副本给客户端。

1
2
3
4
AWeapon::AWeapon()
{
bReplicates = true;
}

在使用这个 weapon 的 Character 类里,设置 UPROPERTY (ReplicatedUsing=OnRepXXX)。并重写 GetLifetimeReplicatedProps。被标注的 OverlappingWeapon 在服务器如果发生改变,服务器就会传给客户端。但是 on_RepXXX 这里只会在客户端执行。On_RepXXX 实际上是一个接收到服务器 replicate 后需要执行的函数,replicate 过程是单向的,所以 On_RepXXX 只能再客户端执行。

1
2
3
4
5
6
7
8
9
10
11
class BLASTER_API ABlasterCharacter : public ACharacter
{
UPROPERTY(ReplicatedUsing = OnRep_OverlappingWeapon)
class AWeapon* OverlappingWeapon;

UFUNCTION()
void OnRep_OverlappingWeapon();

virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

};

实现 GetLifetimeReplicatedProps,这里也是设置 replicate 条件的地方。

1
2
3
4
5
6
7
void ABlasterCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME(ABlasterCharacter, OverlappingWeapon);
DOREPLIFETIME_CONDITION(ABlasterCharacter, OverlappingWeapon, COND_OwnerOnly);//只对拥有者replicate
}

这里的 OverlappingWeapon 是一个指针,当它从 null 变为不是 null 的时候,就会触发客户端执行 OnRep_OverlappingWeapon 这个函数。UMG 永远不会同步,Do you also play as a client when using listen server? - Programming & Scripting / Blueprint - Epic Developer Community Forums (unrealengine.com)

d74a31f12be38036dc974072fea588e5_MD5

4.6 踩坑

  • 1.Client的RPC并不能保证一定在客户端执行。在服务器上,如果有一个没有connection信息的actor(比如不是同步的,完全由AI控制的。或者说他的remote role等于none),那么他的clientRPC只会在自己的客户端上面执行。最后可能造成的后果就是函数调用栈的无限循环进而崩溃。

  • 2.beginplay在客户端服务器都会执行,如果在beginplay执行另外一个actor的生成。可能会触发客户端和服务器都生成一遍自己的actor,结果客户端存在了两个Actor(一个自己生成的,一个服务器生产的)。之后在调用RPC的时候很可能会出现RPC执行失败,因为本地生成的Actor没有任何connection信息。

  • 3.客户端上对象的Beginplay是可能执行多次。在unreal中,如果一个actor是服务器创建并同步给客户端,那么服务器可以随时关闭这个对象的同步。一旦这个对象距离玩家角色非常远或者服务器主动关闭同步,客户端上的对象就会被删除掉。后期如果玩家又靠近了这个对象,那么就会重新同步到客户端,再执行一次Beginplay。这样某些数据进行两次初始化,可能不是我们想要的。

  • 4.我们经常会遇到“游戏状态恢复”的场景,比如网络游戏中的断线重连。然后你就可能会遇到一些对象在重连后状态不对,因为很多对象的变化是通过RPC去做的,RPC是一次性的。当你重连后,RPC不会再执行一次,所以客户端重连的状态与服务器其实是不同的。这时候需要使用属性同步来解决问题,但是属性回调在断线重连的时候你也并不一定想执行,所以要重新审视一下回调函数里面的内容。

  • 5.不要把随时可能被destroyed的对象传进RPC的参数里面,RPC参数里面没有判断对象是否是合法的。如果传递的过程中对象被destroy掉,后续可能触发序列化找不到NETGUID的相关崩溃。

  • 6.一般情况下,同步顺序在一个character内是严格按照属性的声明顺序的,不同actor无法保证

  • 7.一般回调会调到的函数,要注意里面有没有判空return的情况,这个时候其他actor的指针是有可能为空的。

  • 8.一个UObject指针类型的数组属性,可能会触发多次回调,最后一次可以确保所有指针都有值。

  • 9.属性回调执行的前提是客户端与服务器的值不同,如果你本地先修改一个值,然后服务器修改的与客户端相同,那么是不会触发回调的

  • 10.一般来说当Actor与PC解绑后,Actor就无法保证RPC的执行了。这种情况往往发生在角色死亡后执行unpossess时,所以在这时应该注意RPC的执行情况。

  • 11.如果属性没有同步到客户端或者不执行回调,注意一下是否使用了自定义的条件属性

  • 12.所有设置定时器来判断同步属性是否收到的逻辑都是不规范的,一旦服务器或者客户端变卡(一开始没有表现,但是随着游戏内容的增加可能出现各种诡异的bug)就可能导致信息丢失

5 RPC 远程过程调用

在计算机网络的概念里面,RPC叫做“远程过程调用(Remote Procedure Calls)”,本质上就是一种传递数据的手段,而其实现方式既可以是应用层的Http,也可以是传输层的TCP/UDP。在虚幻里面,由于很多游戏的同步(比如FPS)对网络延迟要求比较苛刻,所以放弃了需要三次握手的TCP而改用UDP(更不可能考虑HTTP了)

RPC 是在本地调用但在其他机器(不同于执行调用的机器)上远程执行的函数

类似电视遥控器对电视机的控制。

RPC 函数非常有用,可允许客户端或服务器通过网络连接相互发送消息。

1
2
3
flowchart LR
客户端-->服务器
服务器-->客户端

RPC 主要作用是执行那些不可靠的暂时性/修饰性游戏事件(不要用多播 RPC 来发送持久性状态! 而应该选择使用 [[#复制属性 Replicated Properties]])。 这其中包括播放声音、生成粒子或产生其他临时效果之类的事件,它们对于 Actor 的正常运作并不重要

在此之前,这些类型的事件往往要通过 Actor 属性进行复制。现在推荐直接使用 RPC 调用一个函数来播放粒子等效果。

在使用 RPC 时,还必须要了解 [[#6 Ownership 连接所有权]],因为它决定了大多数 RPC 将在哪里运行。
![[1 UE网络精粹#^uh7l5h]]

[!warning]
RPC 不能有返回值!要返回一些信息,您需要在另一个方向上使用第二个 RPC。

5.1 使用 RPC

5.1.1 蓝图

9237d6adf6779d0342e27319939ac35b_MD5|450

如果被标记为 RPC 的函数是从蓝图中调用,它们也会被复制。这时,它们将遵循相同的规则,就像是从 C++ 调用一样。在此情况下,您无法将函数动态标记为蓝图的 RPC。

然而,自定义事件却可以从蓝图编辑器内部被标记为复制

蓝图中的 RPC 是通过创建 CustomEvent 并将设置 Replicates 来创建的。RPC 不能有返回值,因此不能使用函数来创建 RPC。

d900cca98dc7a2a1829d62f9d4fcec4c_MD5

5.1.2 C++ (三种 RPC 函数)

[UE4 Network] RPC 之哪里调用和哪里执行 - 知乎 (zhihu.com)
Pasted image 20240714211828

5.1.2.1 相关概念的简介

  1. AActor->LocalRole == ROLE_Authority 检测是否为本地权威角色
  2. AActor::GetNetConnection() != NULL 检测 AActor 是否有所属链接
  3. AActor->RemoteRole != ROLE_None 检测 AActor 是否为参与属性同步

[!tip] 区分调用和执行
假设我们声明了一个名为 FunctionName() 的 RPC 函数,用户并不需要实现这个函数,而是要额外的实现一个名为 FunctionName_Implementation() 函数处理真正要执行逻辑。而这个 FunctionName() 函数会由 UHT 来为我们实现来处理 RPC 调用的逻辑。

我们将用户调用 FunctionName() 函数行为称为用户发起了这个 RPC 函数的调用
而将真正执行到 FunctionName_Implementation() 函数时称为这个 RPC 函数被执行

要将一个函数声明为 RPC,您只需将 ServerClient 或 NetMulticast 关键字添加到 UFUNCTION() 声明。

5.1.2.2 UFUNCTION(Server)

用于声明由客户端发起调用,在服务器执行的 RPC 函数。
Pasted image 20231117154247

执行空间:

  1. 在服务器调用时仅本地执行
  2. 在客户端调用时,对于本地权威角色的 AActor ,若其参与属性同步且有所属链接则远程执行,否则仅本地执行
  3. 在客户端调用时,对于非本地权威角色的 AActor ,若其参与属性同步且有所属链接则远程执行,否则终止执行

通过代码理解 Server RPC:
客户端(Player) 控制 Pawn 在服务器上的地图捡到一把武器(Weapon),写一个 Server RPC 函数(功能是,捡起这个武器,并 Attach 到客户端的 Pawn),这个函数需要在服务器上执行。
在捡到武器的时候,客户端向服务器发送一个 RPC 请求,让服务器执行这个函数。在上面那个 Actor 同步例子中,我们把 Weapon 的 bReplicate 设置为了 true,说明了这个 Weapon 只能由服务器创建,然后 Replicate 给客户端,所以当我们捡到这个武器时,事实上是在服务器上执行了将这个武器的 owner 设置为客户端Player,并 replicate 其他客户端。注意这个函数实现的命名形式,加了一个 Implementation。

1
2
3
4
5
6
7
UFUNCTION(Server, Reliable)
void ServerEquip();

void ABlasterCharacter::ServerEquip_Implementation()
{
//捡起武器,并 Attach 到客户端的 Pawn
}

5.1.2.3 UFUNCTION(Client)

用于声明由服务器发起调用,在客户端执行的 RPC 函数。
Pasted image 20231117154226

执行空间:

  1. 在客户端调用时则仅本地执行
  2. 在服务器调用时,对于本地权威角色的 AActor ,若其参与属性同步且有所属链接则远程执行,否则仅本地执行
  3. 在服务器调用时,对于非本地权威角色的 AActor ,若其参与属性同步且有所属链接则远程执行,否则终止执行

5.1.2.4 UFUNCTION(NetMulticast)

用于声明由服务器发起调用,并广播到所有端执行(服务器+客户端) 的 RPC 函数。

Pasted image 20231117154309

考虑相关性:
与 Server RPC 和 Client RPC 不同,相关性是多播 RPC 要考虑的因素。因为不拥有该 Actor 的客户端可能没有给该 Actor 开放 Channel,这种情况下客户端不会接收 RPC。这意味着不应该使用多播 RPC 来将持久状态的更改复制到客户端。
Pasted image 20231117154649
Pasted image 20231117154735

执行空间:

  1. 在客户端调用时仅本地执行(如果想要客户端广播到其他客户端,只能通过 Server RPC 间接调用 NetMulticast RPC,例如玩家死亡/位置更新要通知其他玩家)
  2. 在服务器调用时,对于参与同步的 AActor 即会本地执行也会远程执行,对于不参与同步的 AActor 仅本地执行

5.1.2.5 例子

1
2
3
// 这是一个 ServerRPC,被标记为unreliable,并且 WithValidation(需要!)。
UFUNCTION(Server, unreliable, WithValidation)
void Server_Interact();

CPP 文件将实现一个不同的函数。该文件需要以 “_Implementation“ 作为后缀。

1
2
3
4
5
6
7
// 这是实际的实现,而不是 Server_Interact(由 UHT 自动实现)。
// 但在调用时,我们使用 Server_Interact。
// 调用Server_Interact后,会执行该函数
void ATestPlayerCharacter::Server_Interact_Implementation()
{
// Interact with a door or so!
}

CPP 文件还需要一个以 _Validate 为后缀的版本,用于 [[#Validation 验证 (C++)]]。

1
2
3
4
bool ATestPlayerCharacter::Server_Interact_Validate()
{
return true;
}

其他两类 RPC 也是这样创建的:都需要标记为 reliableunreliable

1
2
3
4
5
6
7
//ClientRPC
UFUNCTION(Client, reliable)
void ClientRPCFunction();

//MulticastRPC
UFUNCTION(NetMulticast, unreliable)
void MulticastRPCFunction();

5.2 要求和注意事项

要使 RPC 完全发挥作用,需要满足一些要求:

  1. 它们必须在 Actor 或复制的子对象 SubObject(如 Component)上调用
  2. Actor(和 Component)必须被复制
  3. 如果 RPC 由服务器调用并在客户端执行(UFUNCTION (Client),则只有拥有该 Actor 的客户端才会执行该函数
  4. 如果 RPC 由客户端调用并在服务器上执行(UFUNCTION (Server),则客户端必须拥有 RPC 调用的 Actor
  5. 多播 RPC (UFUNCTION (NetMulticast)是个例外
    • 如果它们被服务器调用,服务器将在本地执行它们,并在当前连接的所有客户端上执行它们,这些客户端都有一个相关的 Actor 实例
    • 如果从客户端调用,多播只能在本地执行,而不会在服务器或其他客户端上执行
    • 目前,我们有一个针对多播事件的简单节流机制(throttling mechanism):
      • 在特定的 Actor 的网络更新周期内,多播函数的复制次数不会超过两次。

5.3 从服务器调用的 RPC

下面的表格根据执行调用的 actor 的所有权,总结了特定类型的 RPC 将在哪里执行。

Actor Ownership
所有权
未复制 NetMulticast Server Client
被客户端拥有 服务器上运行 服务器+所有客户端 服务器 在 actor 的所属客户端上运行
被服务器拥有 服务器 服务器+所有客户端 服务器 服务器
未被拥有 服务器 服务器+所有客户端 服务器 服务器

未标记 Replicated 则都在服务器上运行

5.4 从客户端调用的 RPC

Actor Ownership
所有权
未复制 NetMulticast Server Client
被执行调用的客户端拥有 在执行调用的客户端上运行 执行调用的客户端 服务器上 执行调用的客户端
被不同客户拥有 执行调用的客户端 执行调用的客户端 丢弃 执行调用的客户端
被服务器拥有 执行调用的客户端 执行调用的客户端 丢弃 执行调用的客户端
未被拥有 执行调用的客户端 执行调用的客户端 丢弃 执行调用的客户端

5.5 可靠性 Reliable

  • 默认情况下,RPC 不可靠(UDP),通过指定 Reliable 标记为可靠 (TCP)
  • 不要将每个 RPC 都标记为 Reliable!您只应在偶尔调用一次且需要它们到达目的地的 RPC 上这样做。 过渡使用可靠 RPC 会导致带宽饱和,并且在丢包时可能会导致瓶颈。
  • 在 Tick 上调用 reliable RPC 可能会产生副作用,如填满可靠缓冲区,从而导致其他属性和 RPC 不再被处理。

[!NOTE] 网络通信的可靠性与不可靠性
在网络通信中,可靠和不可靠是指数据传输的特性。

可靠传输意味着数据在传输过程中能够保证完整性和顺序性。这意味着发送的数据将按照发送的顺序到达接收端,并且不会丢失或损坏。如果发生数据丢失或损坏,可靠传输机制会自动进行重传,以确保数据的完整性和正确性。

不可靠传输则不提供数据的完整性和顺序性保证。在不可靠传输中,数据可能会丢失、损坏或乱序到达接收端,而且没有自动重传机制。

在网络通信中,可靠传输通常用于需要确保数据完整性和顺序性的场景,例如文件传输、实时视频流等。而不可靠传输通常用于对实时性要求较高,但对数据完整性和顺序性要求较低的场景,例如实时游戏中的网络同步。

5.6 Server Validation 验证

检测错误数据/输入的一个手段。这是一种作弊检测方法,适用于以影从客户端发送异常数据到服务器的情况,如果 ServerRPC 验证失败,发送该 RPC 的客户端将被踢出游戏。

验证的原理:如果 RPC 的验证函数检测到任何参数有问题,就会通知系统断开发起 RPC 调用的客户端 / 服务器。

现在,每个 ServerRPC 都需要验证UFUNCTION 宏中的 WithValidation 关键字就是用于此目的。

1
2
UFUNCTION(Server, unreliable, WithValidation)
void SomeRPCFunction(int32 AddHealth);

下面举例说明如何使用 _Validate 函数:

1
2
3
4
5
6
7
8
9
bool ATestPlayerCharacter::SomeRPCFunction_Validate(int32 AddHealth)
{
if (AddHealth > MAX_ADD_HEALTH)
{
return false; // 这将断开调用
}

return true; // 允许调用 RPC!
}

[!info]
Server RPC 要求使用 _Validate 函数,以确保服务器 RPC 功能的安全性,并尽可能方便用户添加代码,根据所有已知的输入约束条件检查每个参数是否有效。

6 Ownership 连接所有权

所有权是非常重要的一点。你已经看到了一个包含 “Client-owned Actor “等条目的表格 [[#5.3 从服务器调用的 RPC]]。

服务器或客户端可以 own(拥有) 一个Actor。

例如,本地 player(客户端或监听服务器)拥有 PlayerController。
另一个例子是场景中生成/放置的门。这主要由服务器所有。

但为什么会出现这样的问题呢?
如果你再查看一下前面的表格,就会发现,例如,如果客户端在不属于自己的 Actor 上调用 Server RPC,该 RPC 就会被丢弃。因此,客户端无法在服务器拥有的 Actor上调用 “Server_Interact“。但我们该如何解决这个问题呢?

我们使用客户端拥有的 Class/Actor,这就是 PlayerController 开始大显身手的地方。在讨论 PlayerController 类时,我们已经举过一个类似的例子,即根据 UserWidget 按钮的按压情况发送 RPC 以递增数值。

Pasted image 20231002095346

因此,与其在 Actor 上启用输入并在那里调用 ServerRPC,不如在 PlayerController 中创建 ServerRPC,让服务器调用 Actor 上的接口函数(例如 “Interact”)。

6.1 Actors 和他们的拥有连接

Owning Connections(拥有连接)

6.1.1 设置 Owner

每个 Acctor 都可以指定另一个 Actor 作为其 Owner(拥有者)。

  • 通常可以在 Spawn 时设置 Owner
    Pasted image 20231117144636|500
  • 也可以在运行时通过 SetOwner 设置
    Pasted image 20231117144705|450

6.1.2 PlayerController 的所有权

Gameplay 架构+网络 章节中提到,PlayerController 是客户端真正 “拥有 (own) “的第一个类。这意味着什么呢?

每个 UNetConnection 都连接着一个 Player,一旦玩家登录到游戏,就会有一个与之关联的 PlayerController (注意,PlayerController 也是 Actor)。从服务器的角度来看,这个 PlayerController 被该链接拥有(own),并且该连接拥有 PlayerController 拥有的所有 Actor

1
2
3
flowchart LR
Actor-->PlayerController-->Connection
Actor-->Connection

因此,当我们想确定某个 Actor 是否被某个人拥有时,我们会向上查询(递归),直到查询到最外层的所有者,如果是一个 PlayerController,那么拥有该 PlayerController 的 Connection 也拥有该 Actor。

Pasted image 20231117145651

举例:如果你 (Player) 控制游戏中的人物(APawn)拿了一把枪 (AWeapon),服务器可以追踪到这把枪是 Pawn 的,并复制给其他所有的玩家客户端,这样其他玩家都可以看到你操控的人物拿了一把枪。
甚至服务器可以递归查询 Weapon 被你拥有

Pawn/Character。它们被 PlayerController possess,在此期间,PlayerController 是 possessed Pawn 的所有者。这意味着拥有该 PlayerController 的 Connection 也拥有该 Pawn。 这只是在玩家控制器 possess Pawn 时的情况。un-possess 将导致客户端不再拥有该 Pawn。

在确定关联连接方面,Component 有一些特殊之处。这时,我们要首先确定 Component 的所有者,方法是遍历组件的”外链”,直到找出所属的 actor,然后确定这个 actor 的关联连接,像上面那样继续下去。

[!question] 所有权是以下情形中的重要因素:

  • RPC 需要确定哪个客户端将执行 Run-On-Client RPC(运行于客户端的 RPC)
  • Actor 复制(replication)和 Connection 相关性(relevancy)
  • 涉及所有者时的 Actor 属性复制条件

连接所有权对于 RPC 这样的机制至关重要,因为当您在 actor 上调用 RPC 函数时,除非 RPC 被标记为多播,否则就需要知道要在哪个客户端上执行该 RPC。它可以查找关联连接来确定将 RPC 发送到哪条连接。 ^uh7l5h

连接所有权会在 actor 复制期间使用,用于确定各个 actor 上有哪些连接获得了更新。对于那些将 bOnlyRelevantToOwner 设置为 true 的 actor,只有拥有此 actor 的连接才会接收这个 actor 的属性更新。默认情况下,所有 PlayerController 都设置了此标志,正因如此,客户端才只会收到它们拥有的 PlayerController 的更新。这样做是出于多种原因,其中最主要的是防止玩家作弊和提高效率。

对于那些要用到所有者的 需要复制属性的情形来说,连接所有权具有重要意义。例如,当使用 COND_OnlyOwner 时,只有此 actor 的所有者才会收到这些属性更新。

最后,关联连接对那些作为自治代理的 actor(Role 为 ROLE_AutonomousProxy)来说也很重要。这些 actor 的 Role 会降级为 ROLE_SimulatedProxy,其属性则被复制到不拥有这些 actor 的连接中。

6.1.3 PlayerControler 与 RPC 的关系

由于我们在发送同步数据的时候需要知道这个数据应该发向哪个客户端,而客户端与服务器的链接信息(IP等)又在Playercontroller里面,所以同步的逻辑与playercontroller密切相关。很多刚接触unreal的朋友经常会遇到RPC数据发不出去或者收不到的问题,就是没有认识到playercontroller其实是包含客户端与服务器的连接信息的。
最典型的,假如你有服务器上连着10个玩家客户端,服务器上有一辆车,让他执行Client RPC,他怎么知道发给哪个客户端?当然是通过这个车找到控制他的playercontroller,然后找到对应客户端的IP,如果这个车不被任何客户端控制,那他就不知道要发给谁。

7 Actor 的相关性和优先级

7.1 Relevancy 相关性

[!question]
什么是相关性,为什么我们需要它?

想象一下,游戏中的 Levels/Maps 大到足以让玩家认为其他玩家 “UnImportant”。如果玩家 “A “和玩家 “B “相隔万里,为什么还需要从玩家 “B “那里获取网络更新?

为了提高带宽,虚幻引擎的网络代码允许服务器只告诉客户端在其相关集合中的角色。

[!quote] 带宽
在计算机网络中,带宽是指在单位时间内传输数据的能力或速率。它通常以每秒传输的比特数(bps)或字节(Bps)来衡量。带宽决定了网络连接的数据传输速度,即能够在特定时间内传输多少数据量。

虚幻应用以下规则(按顺序)来**确定与玩家相关的 Actors 集合。这些测试在虚函数 AActor::IsNetRelevantFor() 中实现(以下规则可以通过重载该函数修改)。
Pasted image 20231002112835|350

  1. 如果 Actor 被标记为 “bAlwaysRelevant“(始终相关)、被 Pawn 或 PlayerController 所拥有、本身为 Pawn,或者 Pawn 是某些行为(如 noise 或 damage)的 Instigator,则其具有相关性。
    • 如果 Actor 被标记为 “bAlwaysRelevant“意味着只要他们有资格进行复制,服务器就会随时将它们复制到所有客户端
      • 有的 Actor 就是对于所有的客户端都复制,例如 GameState, PlayerState 默认始终相关。
      • 但是有些 Actor 只对于一个客户端有相关性,所以这个 Actor 只会复制给这个客户端。比如 PlayerController 这个 Actor 只对自己的客户端有相关性,也只会复制给自己的客户端。
  2. 如果 Actor 被标记为 “bNetUserOwnerRelevancy(使用所有者相关性) “且有一个所有者,则使用所有者的相关性。
  3. 如果 Actor 被标记为 “bOnlyRelevantToOwner“,并且没有通过第一轮检查,则不具有相关性。
  4. 如果该 Actor Attach 到另一个 Actor 的骨骼上,则其相关性由另一个 Actor 的相关性决定。
  5. 如果 Actor 是隐藏的(bHidden == true),并且根组件没有发生碰撞,那么不具有相关性。
    • 如果没有根组件,”AActor::IsNetRelevantFor() “将记录警告并询问是否应将 Actor 设置为 “bAlwaysRelevant = true
  6. 如果 “AGameNetworkManager “设置为使用基于距离的相关性,则如果某个 Actor 和 Pawnd 的距离小于 net cull distance ,则被视为具有相关性。

[!info]
Pawn 和 PlayerController 重载了 AActor::IsNetRelevantFor(),因此具有不同的相关性条件。

请注意,bStatic Actor(保留在客户端上)也是可以复制的。

7.2 Prioritization 优先级

Pasted image 20231002113159|400

优先级更高则意味着其复制的可能性更高

服务器的 UNetDriver 采用了负载平衡(load-balancing)技术,网络带宽是有限的,该技术会优先处理所有 Actors,并根据每个 Actors 对游戏的重要性为其提供合理的带宽份额。

默认距离玩家较近的 Actor 具有较高的优先级,而一段时间没有更新的 Actor 也具有较高的优先级。

  • Actor 的当前优先级通过虚函数 AActor::GetNetPriority() 计算。
  • 为避免饥饿(starvation),AActor::GetNetPriority() 使用 Actor 上次复制后经过的时间去乘以 NetPriority
  • GetNetPriority 函数还考虑了 “Actor “与 “观察者 “之间的相对位置和距离。

Actor 有一个名为 NetPriority(网络优先级)的浮点变量。这个数字越大,该 Actor 相对于其他 Actor 获得的带宽就越多。
NetPriority 为 2.0 的 Actor 的更新频率是 NetPriority 为 1.0 的 Actor 的两倍。

优先级只决定分配带宽的比例,显然无法通过提高所有优先级来提高虚幻的网络性能。

这些设置大多可以在蓝图的 “类默认值 “中找到,也可以在每个角色子代的 C++ 类中设置。

1
2
3
4
5
6
7
8
9
bOnlyRelevantToOwner = false;
bAlwaysRelevant = false;
bReplicateMovement = true;
bNetLoadOnClient = true;
bNetUseOwnerRelevancy = false;
bReplicates = true;
NetUpdateFrequency = 100.f;
NetCullDistanceSquared = 225000000.f;
NetPriority = 1.f;

8 网络对象判断

UE 网络对象判断有三种方法(建议使用网络角色/对象身份方法)
Pasted image 20240517232534

8.1 服务器判断

1
GetWorld->IsServer()

8.2 NetRole 网络角色

在网络同步中,始终存在三种形式的角色,分别是本地玩家控制的、服务器控制的以及其他玩家控制的,在unreal中分别对应着Autonomous、Authority与Simulate。这三种类型的存在本质上代表着角色的控制者是谁(哪个端可以直接通过命令操作他)。
从另一个角度讲这种分类其实是代表着玩家的操作是否有网络延迟以及延迟的大小。对于本地控制的Autonomous角色,他可以在本地直接响应你的操作,如果想把操作发给服务器,则需要经历一个client——server的延迟,而服务器想把这个操作同步给其他客户端又需要一个server——client的延迟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** Actor在local/remote网络上下文中的网络角色 */
UENUM()
enum ENetRole : int
{
/** No role at all. */
ROLE_None,
//此Actor的本地模拟代理
ROLE_SimulatedProxy,
//此Actor的本地自洽代理
ROLE_AutonomousProxy,
/** 对此Actor权威控制 */
ROLE_Authority,
ROLE_MAX,
};

服务器不会在每次更新时都复制 Actor。这会消耗太多的带宽和 CPU 资源。实际上,服务器将按照 AActor:: NetUpdateFrequency(网络更新频率) 属性指定的频率复制 Actor。
Pasted image 20231006204332

因此在 Actor 更新的间歇,会有一些时间数据被传递到客户端。这可能会导致 Actor 的动作看起来不连贯。为了弥补这一点,客户端将在更新的间歇模拟,目前共有两种类型的模拟 ROLE_SimulatedProxy 和 ROLE_AutonomousProxy 。

8.2.1  ROLE_SimulatedProxy 模拟代理

**Simulated proxy 意味着该 Actor 被其他客户端所控制。而不是本客户端。 **

这是标准的模拟路径,通常是根据上次获得的速率对移动进行推算。

当服务器为特定 Actor 发送更新时,客户端将向着新的方位调整其位置,然后利用更新的间歇,根据由服务器发送的最近的速率值来继续移动 actor。

使用上次获得的速率值进行模拟,只是普通模拟方式中的一种。您完全可以编写自己的自定义代码,在服务器更新的间隔使用其他的一些信息来进行推算。

8.2.2  ROLE_AutonomousProxy 自治代理

Autonomous(自治) 是指本客户端直接控制 Actor 的行为,尽管不是权威的。

一般只用于被 PlayerController possess 的 Actor。

这只是意味着这个 Actor 正在接收来自外部(玩家)的输入,因此当我们进行推算时,我们可以获得更多的信息,并使用实际的外部输入来填补缺失的信息(而不是根据上次获得的速率来进行推算)。

8.2.3 ROLE_Authority

注意在服务器和客户端上都可能表现为权威,一般只有确定是服务器时才使用 HasAuthority 来判断

8.2.3.1 HasAuthority()

对于普通的 Actor 我们只需要关心:这个 Actor 的 Role 是否为 ROLE_Authority(通常在 C++中调用 HasAuthority() 来判断,即本地机器是否有权管理该 Actor(决定其是否被复制)。

1
2
3
4
FORCEINLINE_DEBUGGABLE bool AActor::HasAuthority() const
{
return (GetLocalRole() == ROLE_Authority);
}

Pasted image 20231117165650

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ASomeActor : :BeginPlay()
{
super::BeginPlay();

//如果有权管理
if(HasAuthority()) //即 if(GetLocalRole() == ROLE_Authority)
{
// 执行时机:
// 网络模式为 NM_Standalone
// 或网络模式为 NM_DedicatedServer/NM_ListenServer
// 或网络模式为 NM_Client,但是该Actor在客户端生成(Spawn)
}
else
{
// 网络模式为 NM_Client,但是该Actor在服务器生成(Spawn)
}
}

8.3 LocalRole / RemoteRole

每个 Actor 都有两个 ENetRole 枚举类型的属性: RoleRemoteRole

  • Role (即LocalRole)表示了本地机器对该 Actor 的控制程度,由 GetLocalRole() 获取。
  • RemoteRole 表示了远程机器对该 Actor 的控制程度,由 GetRemoteRoleRole() 获取。

通过这两个属性,您可以知道:

  • 谁有权管理 Actor
  • Actor 是否被复制
  • 模拟模式
1
2
3
4
5
UFUNCTION(BlueprintCallable, Category=Networking)
ENetRole GetLocalRole() const { return Role; }

UFUNCTION(BlueprintCallable, Category=Networking)
ENetRole GetRemoteRole() const; {return RemoteRole;}

8.4 IsLocallyControlled()

对于 PlayerController 控制的 Pawn, 另一个重要问题是:是否是本地控制 IsLocallyControlled()

1
2
3
4
bool APawn::IsLocallyControlled() const
{
return ( Controller && Controller->IsLocalController() );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 是否是本地控制器
bool AController::IsLocalController() const
{
//获取网络模式
const ENetMode NetMode = GetNetMode();
//1. 网络模式为NM_Standalone
if (NetMode == NM_Standalone)
{
// Not networked.
return true;
}
//2. 网络模式为NM_Client且LocalRole为ROLE_AutonomousProxy
if (NetMode == NM_Client && GetLocalRole() == ROLE_AutonomousProxy)
{
// Networked client in control.
return true;
}
//3. RemoteRole 不是 ROLE_AutonomousProxy且 LocalRole为ROLE_Authority
if (GetRemoteRole() != ROLE_AutonomousProxy && GetLocalRole() == ROLE_Authority)
{
// Local authority in control.
return true;
}

return false;
}

Pasted image 20231117173631
比如使用专有服务器的网游中自己操控的角色就是本地控制,IsLocallyControlled()==true,其他玩家的角色就是远程控制 IsLocallyControlled()==false

[!info] 这与所有权(Ownership)不同!

[!NOTE]
就目前而言,只有服务器能够向已连接的客户端同步 Actor (客户端永远都不能向服务器同步)。始终记住这一点, 只有服务器才能看到 Role == ROLE_Authority 和 RemoteRole == ROLE_SimulatedProxy 或者 ROLE_AutonomousProxy

8.5 不同网络模式下的 Role

8.5.1 NM_Standalone

设置网络模式为 Play standalone

Pasted image 20231117213610|500

  • 本地机器即是客户端也是服务器,是权威的
  • 远程忽略,因为该模式只有本地。

8.5.2 NM_ListenServer

设置网络模式为 Play As Listen Server:

ListenServer 端:

  • 本地机器为服务器,都是权威的
  • 远程机器是客户端,都是模拟代理
    Pasted image 20231117213846

Client 端:

  • 本地机器都为客户端,拥有客户端的 LocalRole 为自洽代理,其他客户端的 LocalRole 为模拟代理。
  • 远程机器都为服务器,可以看到远程机器对客户端上的 Pawn 是权威的。
    Pasted image 20231117213942

8.5.3 NM_DedicatedServer

设置网络模式为:Play As Client:
Client1:
Pasted image 20231117214209
Client2:
Pasted image 20231117214245

Server:
无渲染场景,角色和 ListenServer 模式下 Server 表现相同
Pasted image 20231117213846

该模式下:

  • 本地机器都为客户端,拥有客户端的 LocalRole 为自洽代理,其他客户端的 LocalRole 为模拟代理。
  • 远程机器都为服务器,可以看到远程机器对客户端上的 Pawn 是权威的。

该模式下的 Role 关系:
Pasted image 20231117172834
PlayerController 被复制到 owning client 是 AutonomousProxy,并且关联的 Pawn 也是 AutonomousProxy,其他 Actor 是 SimulatedProxy。

8.5.4 选择

各种情况对应的可实现功能:
Pasted image 20231117205306

  • 权威/服务器:如果某些事情只需要在多人游戏或单人游戏的服务器上发生,记得判读 if(HasAuthority()) {...}
    • 生成复制的 actor、更新复制的属性、验证或修改 GameMode、对 Actor 造成伤害
  • 非权威/客户端:如果某些事情只需要在远程客户端运行时发生,记得判断 `if(!HasAuthority()) {…}
    • 插值复制的属性、延迟初始化知道复制的值初始化
  • 美化效果、仅视口显式:某些情况下,你希望专门排除 DedicatedServer,,记得判断 if(!IsRunningDedicatedServer()) {...}
    • spawn粒子效果、播放 audio、Spawning 非复制的演员纯粹是装饰
  • 本地/仅 Player:如果有纯粹的客户端功能,它通常与 Pawn 或 Controller 绑定,记得判断是否是本地控制:if(Pawn->IsLocally ())if(Controller->IsLocally ())
    • 配置输入、创建或更新 UI、在 PlayerController 中Spawn 仅在客户端显式的辅助对象

8.5.5 LocalRole/RemoteRole Reversal 对调

通过上面的示例,可以发现对于不同的数值观察者,它们的 Role 和 RemoteRole 值可能发生对调。

例如,在服务器上有这样的配置:

  • Role == Role_Authority
  • RemoteRole == ROLE_SimulatedProxy

客户端会将其识别为以下形式:

  • Role == ROLE_SimulatedProxy
  • RemoteRole == ROLE_Authority

这种情况是正常的,因为服务器要负责掌管 actor 并将其复制到客户端。而客户端只是接收更新,并在更新的间歇模拟 actor。

9 Travel 关卡切换

虚幻引擎中的关卡切换 | 虚幻引擎5.3文档 (unrealengine.com)

9.1 无缝与非无缝切换

Seamless and Non-seamless Travel

UE 中主要有两种关卡切换方式:无缝 和 非无缝方式

无缝切换和非无缝切换之间的区别:

  • 无缝切换是一种非阻塞(non-blocking)操作,而非无缝切换则将是阻塞(blocking)调用。
  • 客户端执行非无缝切换意时,他们与服务器断开连接,然后重新连接到同一服务器,而服务器将准备新的 Map 以供加载。

**Epic 建议虚幻引擎多人模式游戏尽量采用无缝切换**,因为这将带来更流畅的体验,并避免重新连接过程中可能出现的任何问题。

有三种情况必然发生非无缝切换

  • 第一次加载地图时
  • 初次作为客户端连接到服务器时
  • 想要终止一个多人模式游戏并启动新游戏时

9.2 主要切换函数

驱动切换的三大主要函数:

9.2.1 UEngine::Browser

  • 就像是加载新地图时**硬重置(hard reset)
  • 始终导致非无缝切换
  • 将导致服务器在切换到目的 Map 之前与当前客户端断开连接
  • 客户端将与当前服务器断开连接。
  • 专用服务器(Dedicated Serve)无法访问其他服务器,因此 Map 必须存储再本地的(不能是 URL)

9.2.2 UWorld::ServerTravel

  •  仅适用于服务器
  • 会将服务器跳转到新的 World/Level
  • 所有连接的客户端都会跟随
  • 这就是多人游戏从一个 Map 切换到另一个 Map 的方式,服务器负责调用此函数
  • 服务器将为所有连接的客户端玩家调用 APlayerController::ClientTravel
    1
    2
    3
    4
    5
    UWorld* World = GetWorld();
    if(World)
    {
    World->ServerTravel("/Game/Maps/TestLevel ? listen") //? listen可选,会将服务器设为监听服务器
    }

9.2.3 APlayerController::ClientTravel

  • 如果由客户端调用,将切换到新服务器
  • 如果由服务器调用,将指示特定客户端切换到新 Map(但保持与当前服务器的连接)
    1
    2
    3
    4
    5
    APlayerController* PlayerController = GetGameInstance()->GetFirstLocalPlayerController();
    if(PlayerController)
    {
    PlayerController->ClientTravel(FString& Address, ETravelType::TRACEL_Absolute);
    }

9.3 启用无缝切换​

无缝切换需要设置一个过渡 Map(Transition Map)。这是通过 UGameMapsSettings:: TransitionMap 属性进行配置的。
该属性默认为空,如果您的游戏保持默认状态,则会为过度地图创建一个空地图。

[!NOTE]

  • 过渡 Map 存在的原因是必须始终加载一个 World(用于存放 Map),因此我们无法在加载新 Map 之前释放旧 Map。
  • 由于 Map 可能非常大,因此将旧 Map 和新 Map 同时存储在内存中是个坏主意,因此这就是 Transition Map 的用武之地。过渡地图非常小,不会造成太大的资源消耗

设置好过渡地图后,将 AGameMode::bUseSeamlessTravel 设置为 true,这样就可以实现无缝切换了。

9.4 无缝切换流程

下面是执行无缝切换时的一般流程:

  1. 标记出要在过渡 Level 中的持久化actor(Persisting Actors)(更多信息请见下面)
  2. 转移到过渡 Level
  3. 标记出要在最终 Level 中持久化actor(更多信息请见下面)
  4. 转移到最终 Level

9.5 无缝切换中的持久化Actor

在使用无缝切换时,可以将(持久化)actor 从当前关卡带到新的关卡。这适用于一些特定的 actor,如道具栏物品和玩家等。

默认情况下,这些 actor 将自动存留:

  • GameMode actor(仅限服务器)
    • 通过 AGameModeBase::GetSeamlessTravelActorList 额外添加的任何 actor
  • 拥有一个有效的 PlayerState (仅限服务器)的所有Controller
  • 所有 PlayerController (仅限服务器)
  • 所有本地 PlayerController (服务器和客户端)
    • 通过 APlayerController::GetSeamlessTravelActorList (在本地PlayerControllers上调用)额外添加的任何 actor

10 如何开始多人游戏

开始多人游戏的最简单方法是在 “Play “下拉菜单中将”玩家数量”设置为大于 1 的数值。
Pasted image 20231002203934

这将自动在服务器和客户端之间建立网络连接。因此,即使您在主菜单层级中启动游戏,并将 “玩家人数 “设置为 2+ ,游戏也会连接起来!

这始终是一种网络连接。这不是本地多人游戏连接。这需要以不同的方式处理,目前不会涉及。

10.1  高级设置

9a85779ca82237f7738f54dcd6f05f1e_MD5|"Advanced Settings"
Pasted image 20231002204121

10.1.1 单进程下运行

  • 选中时,将在 UE 的单个实例中生成多个玩家窗口。
  • 不选中时,将为每个分配的 player 启动多个 UE 实例。

如果不选中 “Run Dedicated Server 运行专用服务器”,第一个 player 将是一个监听服务器 ListenServer。

另一方面,当标记为 “TRUE “时,所有 Player 都将成为客户端。

10.2 启动并连接服务器

查看 “会话管理(Session Management) “选项卡,了解如何通过会话系统设置会话/服务器。

让我们看看如何在没有会话的情况下启动和加入服务器。

10.2.1 启动(监听)服务器

  • @ 蓝图
    dbe15fd24cd12eb791c2da618741e0a5_MD5|"Listen Server"

在不使用会话系统的情况下启动服务器,只需使用 OpenLevel 节点,并将 “Level Name “和 “listen “选项传给它即可。
您还可以传入更多选项,以”? “分隔,这些选项可以在 AGameMode 类中检索到。

没有会话系统的专用服务器已经在正确的 Map 上启动,您可以在项目设置的 “地图和模式 “部分指定正确的地图。

  • @ C++
    与蓝图类似,您也可以使用这两个函数,其结果与蓝图节点相同。
1
2
//启动(监听)服务器
UGameplayStatics::OpenLevel(GetWorld(), “LevelName”, true, “listen”);

10.2.2 连接到服务器

  • @ 蓝图
    6e1d9a284e660e8e1a742103f4a3b4de_MD5|"Connect Via IP"

要连接服务器,只需在 “Execute Console Command (执行控制台命令) “节点上使用 “open IPADDRESS “命令,其中 “IPADDRESS “由服务器的实际 IP 地址代替。

  • @ C++
    1
    2
    3
    4
    //连接到服务器
    APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);

    PlayerController->ClientTravel(“IPADDRESS”, ETravelType::TRAVEL_Absolute);

10.2.3 通过命令行启动

基本命令行(这些命令使用编辑器,因此不需要熟数据(cooked data):

Type 类型 Command 指挥
Listen Server UE4Editor.exe ProjectName MapName?Listen -game
Dedicated Server UE4Editor.exe ProjectName MapName -server -game -log
Client UE4Editor.exe ProjectName ServerIP -game

[!info]
专用服务器默认情况下是无头的(headless)。如果不使用”-log”,就看不到任何显示专用服务器的窗口!

10.3  连接过程

如果一个服务器需要从网络连接的角度实现某种目的,它就必须要有客户端连接!

当新的客户端初次连接时,客户端要向服务器发送一个请求。服务器将处理这条请求。

主要步骤如下:

  1. 客户端发送连接请求。
  2. 如果服务器接受连接,则发送当前地图。
  3. 服务器等待客户端加载此地图。
  4. 加载之后,服务器将在本地调用 AGameModeBase::PreLogin
    • 这样可以使 GameMode 有机会拒绝连接
  5. 如果接受连接,服务器将调用 AGameModeBase::Login
    • 该函数的作用是创建一个 PlayerController,可用于在今后复制到新连接的客户端。成功接收后,这个 PlayerController 将替代客户端的临时 PlayerController (之前被用作连接过程中的占位符)。
    • 此时将调用 APlayerController::BeginPlay。应当注意的是,在此 actor 上调用 RPC 函数尚存在安全风险。您应当等待 AGameModeBase::PostLogin 被调用完成。
  6. 如果一切顺利,AGameModeBase::PostLogin 将被调用。
    • 这时,可以放心的让服务器在此 PlayerController 上开始调用 RPC 函数。

那么这里面第 5 点需要重点强调一下。我们知道所谓连接,不过就是客户端连接到一个服务器,在维持着这个连接的条件下,我们才能真正的玩 “网络游戏”。通常,如果我们想让服务器把某些特定的信息发送给特定的客户端,我们就需要找到服务器与客户端之间的这个连接。这个链接的信息就存储在 PlayerController 的里面,而这个 PlayerController 不能是随随便便创建的 PlayerController,一定是客户端第一次链接到服务器,服务器同步过来的这个 PlayerController(也就是上面的第五点,后面称其为拥有连接的 PlayerController)。进一步来说,这个 Controller 里面包含着相关的 NetDriver,Connection 以及 Session 信息。

对于任何一个 Actor(客户端上),他可以有连接,也可以无连接。一旦 Actor 有连接,他的 Role(控制权限)就是 ROLE_AutonomousProxy,如果没有连接,他的 Role(控制权限)就是 ROLE_SimulatedProxy 。

那么对于一个 Actor,他有三种方法来得到这个连接(或者说让自己属于这个连接)。

  1. 设置自己的 owner 为拥有连接的 PlayerController,或者自己 owner 的 owner 为拥有连接的 PlayerController。 也就说官方文档说的查找他最外层的 owner 是否是 PlayerController 而且这个 PlayerController 拥有连接。
  2. 这个 Actor 必须是 Pawn 并且 Possess 了拥有连接的 PlayerController。 这个例子就是我们打开例子程序时,开始控制一个角色的情况。我们控制的这个角色就拥有这个连接。
  3. 这个 Actor 设置自己的 owner 为拥有连接的 Pawn。 这个区别于第一点的就是,Pawn 与 Controller 的绑定方式不是通过 Owner 这个属性。而是 Pawn 本身就拥有 Controller 这个属性。所以 Pawn 的 Owner 可能为空。(Owner 这个属性在 Actor 里面,蓝图也可以通过 GetOwner 来获取)

对于组件来说,那就是先获取到他所归属的那个 Actor,然后再通过上面的条件来判断。

我这里举几个例子,玩家 PlayerState 的 owner 就是拥有连接的 PlayerController,Hud 的 owner 是拥有连接的 PlayerController,CameraActor 的 owner 也是拥有连接的 PlayerController。
而客户端上的其他 NPC(一定是在服务器创建的)是都没有 owner 的 Actor,所以这些 NPC 都是没有连接的,他们的 Role 就为 ROLE_SimulatedProxy。

11 Prediction 预测

在虚幻引擎中,Prediction(预测)是一种网络同步技术,用于在客户端和服务器之间实现平滑的游戏对象移动。它通过在客户端上进行预测性移动来减少网络延迟的影响,从而提供更流畅的游戏体验。

当玩家在客户端上执行操作时,例如移动角色,客户端会立即更新角色的位置和状态,而不必等待服务器的响应。同时,客户端会根据当前的输入和游戏规则进行预测性移动,以保持游戏的连贯性。这样,玩家在客户端上的操作会立即反馈给他们,而不会受到网络延迟的影响。

然后,客户端将这些操作发送给服务器进行验证。服务器会检查客户端的操作是否合法,并校正任何不一致的移动。服务器的校正会发送给客户端,以确保所有玩家在游戏中的位置和状态保持同步。

总结:

  1. 没有预测技术:客户端请求服务器改变值,服务器允许后才能更改(本地有延迟)
  2. 有预测技术:客户端先自己改了,再让服务器检查更改是否有效,如果有效就修改服务器端数据并广播到其他客户端,如果无效就修正。(本地没有延迟)

PredictionKey 是虚幻引擎中用于网络同步和预测的重要概念之一。它是一个标识符,用于跟踪网络上的状态变化和预测。在多人游戏中,每个客户端都会根据自己的输入进行操作,并将操作结果发送给服务器和其他客户端。服务器会根据接收到的操作结果进行模拟,并将模拟结果广播给其他客户端。

PredictionKey用于确保模拟结果在所有客户端上的一致性。当服务器接收到客户端的操作结果时,会为每个操作结果生成一个PredictionKey,并将其与操作结果一起发送给其他客户端。其他客户端根据PredictionKey来判断是否需要接受该操作结果,并将其应用到本地模拟中。

通过使用PredictionKey,虚幻引擎可以在网络延迟和不稳定性的情况下,实现客户端之间的状态同步和预测。它是实现多人游戏中流畅体验的重要机制之一。

12 其他博客文章和网站

参考

UE4网络同步-基础流程 - 知乎 (zhihu.com)
《Exploring in UE4》关于网络同步的理解与思考[概念理解] - 知乎 (zhihu. com)
使用虚幻引擎4年,我想再谈谈他的网络架构 (qq.com)

【UE4网络】网游开发中的RPC和OnRep(一) - 知乎 (zhihu.com)
[UE4 Network] RPC 之哪里调用和哪里执行 - 知乎 (zhihu. com)

细谈网络同步在游戏历史中的发展变化(上) - 知乎 (zhihu.com)

12.1.1 Wizardcell 发表的帖子

12.1.2 Vori (Vorixo) 发表的帖子

12.1.3  KaosSpectrum 发表的帖子

12.1.4  各种

12.1.5 Online Beacons 在线信标

12.1.6 资料库

12.1.7 视频和频道