前言

有一段时间没有更新这个合集了,主要是前面实现的功能比较零散,不太好总结。目前进度为 114 集,主要在处理 GameMode 以及客户端与服务器的时间同步相关的内容,仔细研究之后发现对于网络同步又有了新的理解,这里记录一下。

显示游戏时间

5.png

项目中游戏时间的处理放在了 PlayerController 中,但目前看来应该是重复造轮子了,明明 GameState 就有现成的方法获取服务器时间。但了解这里的同步处理还是能掌握不少新内容的。

为了保证客户端与服务器的时间同步,这里采用了如下的策略:每间隔固定的时间客户端向服务器发起同步请求,服务器将客户端需要的关于时间同步的参数作为 ClientRPC 的参数返回给客户端,客户端根据参数计算本地的时间与服务器时间的差值,并在时间的显示中进行修正。

时间的显示方面,虽然 SetHUDTime() 放在了 Tick() 中,但内部会进行判断,当显式的时间与真实时间相差超过 1s 时才更新一次 UI 界面,防止每一帧都调用。

下面来看具体实现。

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
protected:
virtual void BeginPlay() override;
void SetHUDTime();
void PollInit();

/**
* Sync time between client and server
*/

// Requests the current server time, passing in the client's time when the request was sent
UFUNCTION(Server, Reliable)
void ServerRequestServerTime(float TimeOfClientRequest);

// Reports the current server time to the client in response to ServerRequestServerTime
UFUNCTION(Client, Reliable)
void ClientReportServerTime(float TimeOfClientRequest, float TimeServerReceivedClientRequest);

float ClientServerDelta = 0.f; // difference between client and server time

UPROPERTY(EditAnywhere, Category = Time)
float TimeSyncFrequency = 5.f;

float TimeSyncRunningTime = 0.f;

void CheckTimeSync(float DeltaTime);

以上是 PlayerController.h 中相关函数及变量的定义。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
void ABlasterPlayerController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);

SetHUDTime(Deltatime);
CheckTimeSync(DeltaTime);
PollInit();
}

/// @brief 每隔一段时间向服务器请求时间同步
/// @param DeltaTime 同步间隔
void ABlasterPlayerController::CheckTimeSync(float DeltaTime)
{
TimeSyncRunningTime += DeltaTime;
if (IsLocalController() && TimeSyncRunningTime > TimeSyncFrequency)
{
ServerRequestServerTime(GetWorld()->GetTimeSeconds());
TimeSyncRunningTime = 0.f;
}
}

void ABlasterPlayerController::SetHUDTime()
{

float TimeLeft = 0.f;

if (MatchState == MatchState::WaitingToStart)
{
TimeLeft = WarmupTime - GetServerTime() + LevelStartingTime;
}
else if (MatchState == MatchState::InProgress)
{
TimeLeft = WarmupTime + MatchTime - GetServerTime() + LevelStartingTime;
}

uint32 SecondsLeft = FMath::CeilToInt(TimeLeft);

if (CountdownInt != SecondsLeft)
{
if (MatchState == MatchState::WaitingToStart)
{
SetHUDAnnouncementCountdown(TimeLeft);
}
if (MatchState == MatchState::InProgress)
{
SetHUDMatchCountdown(TimeLeft);
}
}

CountdownInt = SecondsLeft;
}

void ABlasterPlayerController::SetHUDMatchCountdown(float CountdownTime)
{
BlasterHUD = BlasterHUD == nullptr ? Cast<ABlasterHUD>(GetHUD()) : BlasterHUD;
bool bHUDValid = BlasterHUD &&
BlasterHUD->CharacterOverlay &&
BlasterHUD->CharacterOverlay->MatchCountdownText;
if (bHUDValid)
{
int32 Minutes = FMath::FloorToInt(CountdownTime / 60.f);
int32 Seconds = CountdownTime - Minutes * 60;

FString CountdownText = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);
BlasterHUD->CharacterOverlay->MatchCountdownText->SetText(FText::FromString(CountdownText));
}
}

Tick() 中每一帧调用 SetHUDTime()ChecktimeSync() 以设置 UI 及同步时间;

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 ABlasterPlayerController::ServerRequestServerTime_Implementation(float TimeOfClientRequest)
{
float ServerTimeOfReceipt = GetWorld()->GetTimeSeconds();
// 将 客户端请求时的时间 与 服务器接收到请求时的时间 传入
ClientReportServerTime(TimeOfClientRequest, ServerTimeOfReceipt);
}

void ABlasterPlayerController::ClientReportServerTime_Implementation(float TimeOfClientRequest, float TimeServerReceivedClientRequest)
{
// RTT:Round Trip Time 消息发送到服务器再返回客户端所需要的时间
// 客户端收到服务器的响应时的时间 - 客户端发送请求时的时间 = RTT
float RoundTripTime = GetWorld()->GetTimeSeconds() - TimeOfClientRequest;
// GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, FString::Printf(TEXT("RTT: %f"), RoundTripTime));

// RTT / 2 可以近似认为是消息从服务器发送到客户端所需要的时间
// 这样一来,客户端在收到服务器的消息时服务器的时间就可以近似等于:服务器传来的时间 + RTT / 2
float CurrentServerTime = TimeServerReceivedClientRequest + (0.5f * RoundTripTime);
// 服务器端时间与客户端时间的差值
ClientServerDelta = CurrentServerTime - GetWorld()->GetTimeSeconds();
}

float ABlasterPlayerController::GetServerTime()
{
if (HasAuthority()) return GetWorld()->GetTimeSeconds();
// 服务器时间 = 客户端的时间 + 服务器端时间与客户端时间的差值
else return GetWorld()->GetTimeSeconds() + ClientServerDelta;
}

值得注意的是这里 ServerRequestServerTime()ClientReportServerTime() 的设计。

当客户端向服务器请求同步时,向函数中传入当前的时间 t0t_0,服务器将 t0t_0 及本地的时间 T0T_0 传回客户端。客户端接收到 ClientRPC 时,根据客户端本地当前的时间 t1t_1t0t_0 计算出 RTT(Round Trip Time),RTT=t1t0RTT = t_1 - t_0。RTT 便是信息往返服务器所需要的时间,进一步地,信息从服务器到客户端的时间可以近似认为是 RTT2\frac{RTT}{2}。因此,T0+RTT2T_0 + \frac{RTT}{2} 可以近似认为是客户端接收到信息时,服务器当前的时间。根据这个值来更新 ClientServerDelta,即服务器与客户端的时间差值,用于更新 UI 显示。

1
2
3
4
5
6
7
8
9
10
11
/// @brief 在客户端与服务器建立连接后,向服务器请求时间同步
void ABlasterPlayerController::ReceivedPlayer()
{
// Called after this PlayerController's viewport/net connection is associated with this player controller.
Super::ReceivedPlayer();
if (IsLocalController())
{
// 请求服务器时间时,传入客户端的当前的时间
ServerRequestServerTime(GetWorld()->GetTimeSeconds());
}
}

这里还重写了 ReceivedPlayer() 函数,该函数会在客户端与服务器建立连接后调用,因此会自动进行一次时间的同步。

游戏状态切换

在当前的项目中,比赛开始前会有一段热身时间,在这段时间里玩家可以在关卡中自由地飞行,这个过程之后才进入正式的游戏阶段。

UE 的 GamePlay 框架 中,总结了关于 GameMode 的知识点,为了实现上述的功能,就需要使用到 GameMode 中的一些功能。

AGameMode 相较于 AGameModeBase多了个一个 MatchState 的 命名空间,可以很方便地设置各种游戏状态,首先还是查看 MatchState 的定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
/** Possible state of the current match, where a match is all the gameplay that happens on a single map */
namespace MatchState
{
extern ENGINE_API const FName EnteringMap; // We are entering this map, actors are not yet ticking
extern ENGINE_API const FName WaitingToStart; // Actors are ticking, but the match has not yet started
extern ENGINE_API const FName InProgress; // Normal gameplay is occurring. Specific games will have their own state machine inside this state
extern ENGINE_API const FName WaitingPostMatch; // Match has ended so we aren't accepting new players, but actors are still ticking
extern ENGINE_API const FName LeavingMap; // We are transitioning out of the map to another location
extern ENGINE_API const FName Aborted; // Match has failed due to network issues or other problems, cannot continue

// If a game needs to add additional states, you may need to override HasMatchStarted and HasMatchEnded to deal with the new states
// Do not add any states before WaitingToStart or after WaitingPostMatch
}

AGameMode 提供很方便的 Get 和 Set MatchState 的接口,并且提供一个 OnMatchStateSet 的回调函数,用于处理切换到某个 State 时要实现的功能。

1
2
3
4
5
6
7
8
9
/** Returns the current match state, this is an accessor to protect the state machine flow */
UFUNCTION(BlueprintCallable, Category="Game")
FName GetMatchState() const { return MatchState; }

/** Updates the match state and calls the appropriate transition functions */
virtual void SetMatchState(FName NewState);

/** Overridable virtual function to dispatch the appropriate transition functions before GameState and Blueprints get SetMatchState calls. */
virtual void OnMatchStateSet();

OnMatchStateSet 内部就是根据不同的 State 调用相应的 Handle。

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
void AGameMode::OnMatchStateSet()
{
FGameModeEvents::OnGameModeMatchStateSetEvent().Broadcast(MatchState);
// Call change callbacks
if (MatchState == MatchState::WaitingToStart)
{
HandleMatchIsWaitingToStart();
}
else if (MatchState == MatchState::InProgress)
{
HandleMatchHasStarted();
}
else if (MatchState == MatchState::WaitingPostMatch)
{
HandleMatchHasEnded();
}
else if (MatchState == MatchState::LeavingMap)
{
HandleLeavingMap();
}
else if (MatchState == MatchState::Aborted)
{
HandleMatchAborted();
}
}

目前的项目简单地使用了上述的一部分功能。

1
2
3
4
5
6
7
UPROPERTY(EditDefaultsOnly)
float WarmupTime = 10.f;

UPROPERTY(EditDefaultsOnly)
float MatchTime = 120.f;

float LevelStartingTime = 0.f;

首先定义了相应的热身阶段持续时间以及比赛持续时间。

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
ABlasterGameMode::ABlasterGameMode()
{
// Whether the game should immediately start when the first player logs in.
// Affects the default behavior of ReadyToStartMatch
bDelayedStart = true;
}

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

LevelStartingTime = GetWorld()->GetTimeSeconds();
}

void ABlasterGameMode::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);

if (MatchState == MatchState::WaitingToStart)
{
CountdownTime = WarmupTime - GetWorld()->GetTimeSeconds() + LevelStartingTime;
if (CountdownTime <= 0.f)
{
// Transition from WaitingToStart to InProgress.
// You can call this manually, will also get called if ReadyToStartMatch returns true
StartMatch();
}
}
}

这里设置了 GameMode 的 bDelayedStart = true,一旦这样设置,进入游戏就会一直处于 MatchState::WaitingToStart 状态,直到我们显式地调用 StartMatch(),游戏将会切换为 MatchState::InProgress 状态。

这里设置了一个定时器,在 Tick 中倒计时 WarmupTime 的时间,时间到了就切换游戏状态,开始游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ABlasterGameMode::OnMatchStateSet()
{
Super::OnMatchStateSet();

// GameMode 只存在于服务器,因此这里遍历的是服务器上的 PlayerController
// 客户端的状态同步需要使用 RepNotify
for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
{
ABlasterPlayerController* BlasterPlayer = Cast<ABlasterPlayerController>(*It);
if (BlasterPlayer)
{
BlasterPlayer->OnMatchStateSet(MatchState);
}
}
}

这里重写了 OnMatchStateSet 函数,并没有使用 handle 处理游戏状态切换时的行为,而是在 PlayerController 中定义了一个同名的函数,游戏状态切换时会自动遍历所有的 PlayerController 并执行响应函数。

这里需要注意的是,由于 GameMode 只存在于 Server,因此遍历的只是 Server 上的 PlayerController,想要让客户端执行某些行为还需要使用 RPC 或者 Replicate 完成。

查看 PlayerController 中关于这部分功能的定义:

1
2
3
4
5
UPROPERTY(ReplicatedUsing = OnRep_MatchState)
FName MatchState;

UFUNCTION()
void OnRep_MatchState();

这里同步了一个 MatchState 属性并且在改变时会调用 OnRep_MatchState。观察函数的实现会发现,目前只针对 MatchState::InProgress 状态进行了处理,会在服务器与客户端都显示 CharacterOverlay UI 界面并关闭 Announcement 的显示。

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
30
31
void ABlasterPlayerController::OnMatchStateSet(FName State)
{
MatchState = State;

// 当游戏状态为 InProgress 时,显示角色信息
if (MatchState == MatchState::InProgress)
{
HandleMatchHasStarted();
}
}

void ABlasterPlayerController::OnRep_MatchState()
{
if (MatchState == MatchState::InProgress)
{
HandleMatchHasStarted();
}
}

void ABlasterPlayerController::HandleMatchHasStarted()
{
BlasterHUD = BlasterHUD == nullptr ? Cast<ABlasterHUD>(GetHUD()) : BlasterHUD;
if (BlasterHUD)
{
BlasterHUD->AddCharacterOverlay();
if (BlasterHUD->Announcement)
{
BlasterHUD->Announcement->SetVisibility(ESlateVisibility::Hidden);
}
}
}

这里有一点需要注意,由于 Server 上有所有玩家的 Controller,因此 HandleMatchHasStarted() 会在 Server 调用很多次,但只有 Server 自己控制的 PlayerController 在执行 BlasterHUD = BlasterHUD == nullptr ? Cast<ABlasterHUD>(GetHUD()) : BlasterHUD; 时才会得到非 nullptr 的结果,因此 Server 也只会显示一个 HUD。而客户端的 HUD 是由 OnRep_MatchState() 调用 HandleMatchHasStarted() 显示出来的,由于客户端只有本地控制的一个 PlayerController,因此也不会有问题。

热身阶段倒计时的显示

4.png

在第一节显示游戏时间的部分已经涉及了热身阶段倒计时的更新方法,这一节来分析一下这部分信息是如何同步的。

1
2
3
4
5
UFUNCTION(Server, Reliable)
void ServerCheckMatchState();

UFUNCTION(Client, Reliable)
void ClientJoinMidgame(FName StateOfMatch, float Warmup, float Match, float StartingTime);

首先定义了一个 ServerRPC 与一个 ClientRPC,用于客户端确认游戏状态。

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 ABlasterPlayerController::ServerCheckMatchState_Implementation()
{
ABlasterGameMode* GameMode = Cast<ABlasterGameMode>(UGameplayStatics::GetGameMode(this));
if (GameMode)
{
WarmupTime = GameMode->WarmupTime;
MatchTime = GameMode->MatchTime;
LevelStartingTime = GameMode->LevelStartingTime;
MatchState = GameMode->GetMatchState();
ClientJoinMidgame(MatchState, WarmupTime, MatchTime, LevelStartingTime);
}
}

// void ABlasterPlayerController::ClientJoinMidgame(
void ABlasterPlayerController::ClientJoinMidgame_Implementation(
FName StateOfMatch, float Warmup, float Match, float StartingTime)
{
WarmupTime = Warmup;
MatchTime = Match;
LevelStartingTime = StartingTime;
MatchState = StateOfMatch;
OnMatchStateSet(MatchState);
if (BlasterHUD && MatchState == MatchState::WaitingToStart)
{
BlasterHUD->AddAnnouncement();
}
}

与第一节时间同步的逻辑一致,这里也是 Server 将信息通过 ClientRPC 的函数参数传递给 Client,Client 在得知目前的 State 为 MatchState::WaitingToStart 时会为当前的界面添加 Announcement 的 UI,显示倒计时信息。

这里也需要注意一个细节,当客户端调用 ServerCheckMatchState 后,该函数会向这个 Client 发送 ClientJoinMidgame,之后就会在这个 Client 上显示 UI。但 Server 自己控制的角色呢?Server 控制的角色,Controller 只会有一份,存在于 Server 上,因此,Server 端调用 ServerCheckMatchState 后,这个函数依然会向 Server 发送 ClientJoinMidgame,因此 Server 上也能正确地显示 UI 界面。这一点在 UE 网络入门 中也有所提及。