前言

(99+ 封私信 / 80 条消息) UE多人联机入门笔记(一) - 知乎

https://zhuanlan.zhihu.com/p/719982909

一、同步

帧同步和状态同步

(99+ 封私信 / 80 条消息) 【网络同步】浅析帧同步和状态同步 - 知乎

需要说明的是,由于状态同步的安全性比帧同步高很多,且对网络延迟有较大容忍,FPS普遍采用状态同步的方案,而UE作为射击游戏发家的引擎,其提供的rpc也是为状态同步设计的,所以本笔记和项目是基于状态同步方案实现的,此外本笔记所有功能基于AActor,其他类型(F类)可能需要另外实现。

ue角色的联机属性:

拆分了权威服务器后,我们宇宙中的角色就可以分成三种:

  • 权威服务器(server)上的角色,该角色不管是不是这个宇宙真正的主人(即房主),都是最权威的,权威服务器的运行代码说B左移50米,不管 B宇宙中的真B 此刻可能是往右移了50米,所有宇宙的B都是左移50米,且B宇宙中的真B也会因”不可抗力”左移到权威服务器说它应该在的位置。
  • 普通客户端(client)上的正在操纵的角色,也就是A宇宙中的A,B宇宙中的B。
  • 普通客户端(client)上的复制角色,也就是A宇宙中的BCD,B宇宙中的ACD

对此,UE构建了个枚举enum类ENetRole:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UENUM(BlueprintType)
enum ENetRole : int
{
/** No role at all. /
ROLE_None UMETA(DisplayName = "None"),
/* Locally simulated proxy of this actor. /
//普通客户端(client)上的复制角色
ROLE_SimulatedProxy UMETA(DisplayName = "Simulated Proxy"),
/* Locally autonomous proxy of this actor. /
//普通客户端(client)上的正在操纵的角色
ROLE_AutonomousProxy UMETA(DisplayName = "Autonomous Proxy"),
/* Authoritative control over the actor. */
//权威服务器(server)上的角色
ROLE_Authority UMETA(DisplayName = "Authority"),
ROLE_MAX UMETA(Hidden),
};

我们可以使用Actor的GetLocalRole()来确定当前Character的ENetRole,由于这是个枚举类,我们甚至可以写出

if (GetLocalRole() > ENetRole::ROLE_SimulatedProxy)

来排除掉ROLE_None 和复制人。

此外需要强调的是,UE中的actor默认是关闭网络复制功能的,如果某个actor类需要使用网络功能(即后面的RPC和OnRep等),请设置

bReplicates = true;

但是Apawn类是默认开启的,我们可以在其构造函数中找的这句,ACharacter类继承自Apawn也是默认开启

现在我们角色三六九等划分清楚了,是时候该让不同宇宙同步了。

RPC

https://dev.epicgames.com/documentation/zh-cn/unreal-engine/remote-procedure-calls-in-unreal-engine

  • Client在此Actor的所属客户端连接上执行RPC。
  • Server在服务器上执行RPC。
  • Remote在连接的远程端执行RPC。
  • NetMulticast在服务器上以及与Actor相关的所有当前连接的客户端上执行RPC。

上述为元数据说明符,可添加在UFUNCTION()里面:

​ UFUNCTION(Server, Reliable, WithValidation)

​ void ServerFire(const FVector_NetQuantize& TraceHitTarget, float FireDelay);

在这个样例里,无论在权威宇宙还是普通宇宙(当然普通宇宙必须持有该actor),如果该actor的ServerFire()被调用了, 那么权威宇宙就会收到信号,并开始执行ServerFire里的逻辑,并且只有权威宇宙执行。

同理,如果添加的是Client,则是只有持有该Actor的宇宙执行ServerFire。

如果是Remote,则是另一端(无论是权威还是普通)执行。

如果是NetMulticast, 则是所有宇宙执行ServerFire, 也就是广播。此时注意,只有权威宇宙调用能广播,如果是普通宇宙调用ServerFire,则只有它自己执行(其他宇宙不鸟它)。

直接贴项目代码Fire:

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
 .h

void Fire();

UFUNCTION(Server, Reliable, WithValidation)
void ServerFire(const FVector_NetQuantize& TraceHitTarget, float FireDelay);

UFUNCTION(NetMulticast, Reliable)
void MulticastFire(const FVector_NetQuantize& TraceHitTarget);
cpp代码,此处的HasAuthority()用于检测当前宇宙是否是权威的,并且添加RPC的函数实现需要添加_Implementation尾缀:
.cpp

//某个普通客户端 或 服务器上控制的角色A触发开火
void UCombatComponent::Fire() {
if (EquippedWeapon && FatherCharacter) {
CrosshairHitTarget = EquippedWeapon->bUseScatter ? EquippedWeapon->TraceEndWithScatter(CrosshairHitTarget) : CrosshairHitTarget;
//执行一些本地开火逻辑,如播放开火动画, 但如果在服务器上开火,则有serverfire或multicast处理
//为什么:网络有延迟,当前玩家按下开火就应该开火, 但坐在服务器上的玩家没延迟,这也算一种客户端预测,后续文章会讲
if (!FatherCharacter->HasAuthority()) LocalFire(CrosshairHitTarget);
ServerFire(CrosshairHitTarget, EquippedWeapon->FireDelay);//让服务器执行ServerFire
}

void UCombatComponent::ServerFire_Implementation(const FVector_NetQuantize& TraceHitTarget, float FireDelay) {
MulticastFire(TraceHitTarget);//服务器广播这个角色A开火
}

//所有客户端和服务器的A被广播的开火逻辑
void UCombatComponent::MulticastFire_Implementation(const FVector_NetQuantize& TraceHitTarget) {
if (FatherCharacter && FatherCharacter->IsLocallyControlled() && !FatherCharacter->HasAuthority()) {
return; //说明当前宇宙是调用fire的那个人,已经在Fire里面LocalFire过了
}
LocalFire(TraceHitTarget);//当前是其他宇宙的复制人,或者是服务器的权威人,执行一些本地开火逻辑(复用)
}

Reliable

RPC通信默认是不可靠的,即如果数据包被丢弃,则不执行该RPC。

而如果某些函数是重要的,比如ServerFire告知服务器这个玩家开火,我们需要添加Reliable来标记可靠“将重新发送该RPC,直到它被接收方确认。在确认该RPC之前,将暂停所有后续RPC执行”。

当然,如果学过TCP和UDP, 就知道可靠传输代价会更高一些。

WithValidation

RPC还提供了验证功能,需要我们在cpp中添加_Validate实现:

……

//上面是ServerFire_Implementation,validate需要额外实现

bool UCombatComponent::ServerFire_Validate(const FVector_NetQuantize& TraceHitTarget, float FireDelay) {

​ if (EquippedWeapon) {

​ //判断开火间隔有没有被玩家篡改成一秒六棍,如果差别在0.01f以上说明被篡改过

​ bool bNearlyEqual = FMath::IsNearlyEqual(EquippedWeapon->FireDelay, FireDelay, 0.01f);

​ return bNearlyEqual;

​ }

​ return true;

}

上面是一个简单的验证的实现,上线项目需要检测更多的属性。

注意,我们需要确保当前AActor派生的类被设置为在派生的Actor的构造函数内复制:

bReplicates = true;

属性复制

  • Replicated属性为属性复制提供了指定特定条件的选项,将属性复制限制在特定连接上。你也可以设置自定义复制条件,为属性复制定义自己的逻辑。
  • ReplicatedUsing属性需要你提供RepNotify函数,当相关属性被复制时,客户端就会调用该函数。

.h

​ UPROPERTY(Replicated)

​ bool bDisableGameplay = false;

​ UPROPERTY(ReplicatedUsing = OnRep_Health)

​ float Health = 100.f;

​ UFUNCTION()

​ void OnRep_Health(float LastHealth);

​ virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override;

注意,绑定的方法必须有UFUNCTION(),我们还必须要 override GetLifetimeReplicatedProps 并添加宏调用,以在派生的Actor实例的生命周期内复制需要的属性。

void ABlasterCharacter::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const {

​ Super::GetLifetimeReplicatedProps(OutLifetimeProps);

​ DOREPLIFETIME(ABlasterCharacter, Health);

​ DOREPLIFETIME(ABlasterCharacter, bDisableGameplay);

​ DOREPLIFETIME_CONDITION(ABlasterCharacter, OverlappingWeapon, COND_OwnerOnly);

}

眼尖的人一下就能看见这里OverlappingWeapon似乎有一点不同,这里就是条件复制。默认情况是将属性复制到所有A的复制体和它自己,但这里我们可以添加条件 COND_OwnerOnly 并用宏 DOREPLIFETIME_CONDITION 使 OverlappingWeapon 的变化仅复制到Actor的所有者(即仅A宇宙的A身上)。可定义的条件有很多,请参考官方文档:

https://dev.epicgames.com/documentation/zh-cn/unreal-engine/replicate-actor-properties-in-unreal-engine

某个结构体整体会被复制,但里面可能有个开销很大的数据类我们不想复制,我们可以使用UPROPERTY(NotReplicated) 来指定该数据不复制。

Component 复制

我的项目中,我的CombatComponent是挂在Character下的组件,自建的组件默认是不开启网络复制的,该组件需要网络复制,则需要:

​ Combat->SetIsReplicated(true);

结合文档,我们看到如果是静态Actor组件,即在Actor结构函数中创建的Actor组件,例如:

AMyActor::AMyActor()

​ {

​ bReplicates = true;

​ MyActorComponent = CreateDefaultSubobject(TEXT(“MyActorComponent”));

​ }

则应该在Actor组件结构函数中设置

UMyActorComponent::UMyActorComponent()

​ {

​ SetIsReplicatedByDefault(true);

​ }

此外,运动组件是默认开启网络复制的

二、预测

(99+ 封私信 / 80 条消息) UE多人联机入门笔记(二) 客户端预测和服务器倒带 - 知乎

延迟

科普向:竞技游戏的延迟与延迟补偿_游戏热门视频

简单来说,由于物理上的限制,客户端上的数据不可能一瞬间到达权威服务器(用上一篇文章的类比,权威宇宙),所以在这里引入Ping的概念,Ping测量从源主机发送到目标计算机并返回到源主机的消息的往返时间,如果Ping值为100ms, 则指的是从一个测试ping的数据包从客户端发到服务器并立刻返回到客户端花费的时间为100ms。 换句话说,如果A玩家的Ping为100ms,那么A玩家在某年某月某日12.00.00. 0000(时.分.秒.毫秒)发射了一颗子弹,服务器则会在某年某月某日12.00.00. 0050收到A发射了一颗子弹的消息,假设是一瞬间计算出了结果(事实上应该要花费至少一帧的时间),那么A玩家在某年某月某日12.00.00. 0100 才会收到产生的结果,这就是网络延迟。

这里需要强调,从客户端到服务器 和 从服务器到客户端 的时间不一定是相同的(比如发过去走的是一条网络线路,往回走可能该线路崩了得走另一条),但为了方便计算我们还是按ping的1/2计算单程时间,大部分情况下来往时间也确实是相同的。

而这种延迟在客户端和服务端造成了很大的困惑,我们先来看客户端:

客户端预测

(99+ 封私信 / 80 条消息) FPS游戏中,在玩家的延时都不一样的情况下是如何做到游戏的同步性的? - 知乎

那么我们如何使客户端能及时响应输入,且还兼顾权威服务器的验证需求呢?方案是我们把每一次上传的消息包都打上时间戳,并在本地把历史存起来。

A 进行移动

时刻 A 服务器
0ms {posA(10,0,0), 0ms } 发往服务器
50ms {posA(20,0,0), 50ms } 发往服务器 收到 {posA(10,0,0), 0ms }
100ms 收到 {posA(20,0,0), 50ms } 抛弃 {posA(10,0,0), 0ms }

既然如此,为什么我们要比对,直接让客户端想干啥干啥更好吗?这里就牵扯到延迟带来的纠正问题。

时间回退到第50ms,此时此时服务器收到了A的带着0ms时间戳的位移到(10,0,0)的消息,然而期间其他玩家也有操作,B创建了一堵墙挡住了A的前进,该消息在第30ms传到了服务器,此时在50ms,服务器判定A移动到(10,0,0)不合法,并告知玩家A你应仍在(0,0,0),带着0的时间戳返回给A。

时间推进到第100ms(当然B造了一堵墙的消息可能在第80ms通知给了A,这个就看设定的服务器通信频率了),A收到带着0时间戳的(0,0,0),并于自己在0时间戳的信息进行了比对,发现不合法,就需要纠正了。

粗暴点的做法就是直接挪到(0,0,0),并清空历史重新记录,玩家看到有堵墙在那被挡住了,也能理解这种纠正(当然100ms也就是0.1s,玩家感知上也不会太有问题)。

玩家按键操作之后,客户端把该输入发给服务器,同时客户端本地直接处理该输入、产生部分结果/表现,而不用等待服务器的返回,这就是客户端预测

引自FPS游戏中,在玩家的延时都不一样的情况下是如何做到游戏的同步性的?

客户端预测代码案例

  • 属性复制

此时Ammo使用属性复制,在服务器那边因为延迟,在50ms子弹数才减少到25, 客户端在第100ms收到该消息,子弹数从20蹦跶到25.

1
2
3
4
5
UPROPERTY(EditAnywhere, ReplicatedUsing = OnRep_Ammo)
int32 Ammo;

UFUNCTION()
void OnRep_Ammo();
  • RPC
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
.h
//子弹数
UPROPERTY(EditAnywhere)int32 Ammo = 30;

//通知A宇宙的A更改手中子弹数
UFUNCTION(Client, Reliable)void ClientUpdateAmmo(int32 ServerAmmo);
//由Fire调用,服务器,客户端均会执行
void SpendRound();
//预测序列号,当然说成是预测变更子弹数更好理解
int32 AmmoNetSequence = 0;
.cpp

void AWeapon::SpendRound() {
//保证子弹数量大于0,小于最大子弹数MagCapacity
Ammo = FMath::Clamp(Ammo - 1, 0, MagCapacity);
//更新UI
SetHUDAmmo();
//是否在权威服务器上
if (HasAuthority()) {
ClientUpdateAmmo(Ammo);
} else {
++AmmoNetSequence;
}
}

void AWeapon::ClientUpdateAmmo_Implementation(int32 ServerAmmo) {
if (HasAuthority()) return;
Ammo = ServerAmmo;
--AmmoNetSequence;
Ammo -= AmmoNetSequence; // Ensure Ammo is always positive
SetHUDAmmo();
}

RPC timeline

时刻 A AmmoNetSequence UI 服务器 服务器回调 A服务器接收 A接收后
0ms 调用Fire(),并触发SpendRound() AmmoNetSequence++ 更新ui中子弹数为29(原有30) - - - -
50ms A本地A又开了两枪 AmmoNetSequence = 3 A本地ui子弹数为27 收到A 0ms调用Fire(),并触发SpendRound() 通知A客户端更新Ammo子弹数为29,调用ClientUpdateAmmo(29); - -
100ms A相比50ms又开了两枪 AmmoNetSequence为5 ui子弹数为25 - - 收到回调,设置Ammo为29 AmmoNetSequence– ,AmmoNetSequence为4
50ms 不合法 同50ms 同50ms 收到A 0ms消息 ,判断Fire不合法 通知A不合法,ClientUpdateAmmo(30); - -
100ms不合法 同100ms 同100ms A本地ui子弹数为26(0ms 的fire 不合法) - - 收到回调,设置Ammo为30 AmmoNetSequence,AmmoNetSequence为5

服务器倒带

  • 主要作用

服务器记录一段时间内每个人的历史位置和Pose,收到A的射击事件时,服务器把A之外的所有玩家都挪回到一个恰当的历史位置去,使服务器上其他人的位置与A看到的其他人位置基本一致。

对表

我们马上想到,当今世界的网络时间是联网同步的,所以获取玩家的设备的本地时间就能对齐了吧!

想法很美好,但先不论时区问题,如果玩家自己关闭了全球同步,并手动调快时间了1年,这时候以此同步就会出现很奇怪的结果(不要觉得这种操作很奇怪,极限竞速地平线就可以通过这种手段, 调到限时活动时间段从而拿到限时活动的活动车)。

UE是自带计时器的,我们能拿到自世界生成后的时间 :

1
GetWorld()->GetTimeSeconds();

把权威服务器(房主)的时间同步给其他客户端(普通玩家)就可以了,不可能有其他客户端比房主早,并且在中途加入的玩家也要进行同步。

可以重写APlayerController::ReceivedPlayer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.h

virtual void ReceivedPlayer() override; // Sync with server clock time as soon as the player is received

//Sync time between client and server

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

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

//记录server的GetWorld()->GetTimeSeconds() 和 client的差
float ClientServerDelta = 0.f; // The difference between the client and server time, used to sync time

APlayerController::ReceivedPlayer 文档中解释:“Called after this PlayerController’s viewport/net connection is associated with this player controller”,可以用于同步。在ReceivedPlayer中调用ServerRequestServerTime,向权威服务器申请时间,因为网络有延迟,我们要带上申请时自己的时间过去,服务器收到后把客户端发送带的时间戳和自己的时间再转发回去:

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
cpp


void ABlasterPlayerController::ReceivedPlayer() {
Super::ReceivedPlayer();
// Sync with server clock time as soon as the player is received
//是宇宙A的A的controller
if (IsLocalController()) {
float ClientRequestTime = GetWorld()->GetTimeSeconds();
//带上自己的发送时间戳去问服务器
ServerRequestServerTime(ClientRequestTime);
}
}

void ABlasterPlayerController::ServerRequestServerTime_Implementation(float TimeOfClientRequest) {
float ServerTimeOfReceipt = GetWorld()->GetTimeSeconds();
//带上传过来的客户端的询问的时间和自己的时间返回给客户端
ClientReportServerTime(TimeOfClientRequest, ServerTimeOfReceipt);
}

void ABlasterPlayerController::ClientReportServerTime_Implementation(float TimeOfClientRequest, float TimeServerReceivedClientRequest) {
//来回跑一趟的时间
float RoundTripTime = GetWorld()->GetTimeSeconds() - TimeOfClientRequest;
SingleTripTime = RoundTripTime * 0.5f; // Half of the round trip time
//此时服务器上的时间
float CurrentServerTime = TimeServerReceivedClientRequest + SingleTripTime;
ClientServerDelta = CurrentServerTime - GetWorld()->GetTimeSeconds();
}

时间推进,传回到客户端上的TimeServerReceivedClientRequest已经不是当前服务器的时间了,所以我们要算一下单程的时间,添加到TimeServerReceivedClientRequest,即CurrentServerTime ,再用该时间减去客户端世界时间,即服务器和客户端的时间差ClientServerDelta。

这样时间就对齐了,但高枕无忧了吗? 如果有玩家的配置太烂,World的时钟的误差可能会越来越大,所以我们每隔一段时间就得同步一下时间:

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
.cpp

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

CheckTimeSync(DeltaTime);


}

void ABlasterPlayerController::CheckTimeSync(float DeltaTime) {
TimeSyncRunningTime += DeltaTime;
//每隔TimeSyncFrequency同步一下时间,这里更推荐用计时器
if (IsLocalController() && TimeSyncRunningTime >= TimeSyncFrequency) {
ServerRequestServerTime(GetWorld()->GetTimeSeconds());
TimeSyncRunningTime = 0.f;
}
}

//public,获取当前服务器时间
float ABlasterPlayerController::GetServerTime() {
if (HasAuthority()) {
return GetWorld()->GetTimeSeconds();
}
return GetWorld()->GetTimeSeconds() + ClientServerDelta;
}

HitBox

HixBox选择

存储每一个人碰撞箱的位置信息

对于碰撞箱的判定,若对于判定要求不高或者服务器的配置不高,可以只用一个胶囊体去代表历史位置

img

但是对于严格判定,并且服务器配置好的情况下,要将hitbox列入游戏平衡。我们就要在骨骼上绑定碰撞箱。

img

Hitbox结构体

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
.h

TMap<FName, class UBoxComponent*> HitCollisionBoxes;

UPROPERTY(EditAnywhere)
class UBoxComponent* head;

UPROPERTY(EditAnywhere)
UBoxComponent* pelvis;
//后续省略

.cpp

head = CreateDefaultSubobject<UBoxComponent>(TEXT("head"));
head->SetupAttachment(GetMesh(), FName("head"));
HitCollisionBoxes.Add(FName("head"), head);

pelvis = CreateDefaultSubobject<UBoxComponent>(TEXT("pelvis"));
pelvis->SetupAttachment(GetMesh(), FName("pelvis"));
HitCollisionBoxes.Add(FName("pelvis"), pelvis);

//省略其他的hitbox
//设置每个hitbox的碰撞属性
for (auto Box : HitCollisionBoxes)
{
if (Box.Value)
{
Box.Value->SetCollisionObjectType(ECC_HitBox);
Box.Value->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
Box.Value->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block);
Box.Value->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
}

ULagCompensationComponent

ULagCompensationComponent组件来处理服务器倒带逻辑。

该组件开启tick,并在每一帧中存储该玩家所有hitbox的信息,信息用struct抽象。

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
.h

//每个box的信息
USTRUCT(BlueprintType)
struct FBoxInformation
{
GENERATED_BODY()
//box的location
UPROPERTY()
FVector Location;
//box的rotation
UPROPERTY()
FRotator Rotation;
//box的尺寸
UPROPERTY()
FVector BoxExtent;
//三者拼起来就能还原box的位置和大小
};

//每帧存储的信息
USTRUCT(BlueprintType)
struct FFramePackage
{
GENERATED_BODY()
//当前帧的时间戳
UPROPERTY()
float Time;
//FBoxInformation 拼起来的当前角色当前帧的所有hitbox信息
UPROPERTY()
TMap<FName, FBoxInformation> HitBoxInfo;
//当前帧属于哪个角色
UPROPERTY()
ABlasterCharacter* Character;
};

//双向链表存FFramePackage,便于开头去掉过期的帧,结尾加上最新帧
TDoubleLinkedList<FFramePackage> FrameHistory;
//最大倒带时间,超过就不回滚了并且删除记录
UPROPERTY(EditAnywhere)
float MaxRecordTime = 4.f;
//是否开启倒带逻辑

每次tick需要判定FrameHistory的最古老的一帧是否过期(超过MaxRecordTime),也就是服务器倒带限制的时间,不照顾那些超级高ping战士(csgo设置为200-300ms,瓦降到了140ms被红迪说不照顾非洲兄弟)

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
.cpp

ULagCompensationComponent::ULagCompensationComponent()
{
// Set this component to be initialized when the game starts, and to be ticked every frame. You can turn these features
// off to improve performance if you don't need them.
PrimaryComponentTick.bCanEverTick = true;

}

// Called every frame
void ULagCompensationComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) {
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

if (FatherCharacter == nullptr || !FatherCharacter->HasAuthority()) return;
SaveFramePackagePerTick();
}

//生成并存储当前帧的FFramePackage
void ULagCompensationComponent::SaveFramePackagePerTick() {
if (FatherCharacter == nullptr || !FatherCharacter->HasAuthority()) return;
if (FrameHistory.Num() <= 1) {
FFramePackage ThisFrame;
SaveFramePackage(ThisFrame);
FrameHistory.AddHead(ThisFrame);
} else {
//当前链表中总时长
float HistoryLength = FrameHistory.GetHead()->GetValue().Time - FrameHistory.GetTail()->GetValue().Time;
while (HistoryLength > MaxRecordTime) {
//删除超过MaxRecordTime的帧信息
FrameHistory.RemoveNode(FrameHistory.GetTail());
HistoryLength = FrameHistory.GetHead()->GetValue().Time - FrameHistory.GetTail()->GetValue().Time;
}
FFramePackage ThisFrame;
//构造当前帧的包信息
SaveFramePackage(ThisFrame);
FrameHistory.AddHead(ThisFrame);

//ShowFramePackage(ThisFrame, FColor::Red);
}
}

void ULagCompensationComponent::SaveFramePackage(FFramePackage& Package) {
FatherCharacter = FatherCharacter ? FatherCharacter : Cast<ABlasterCharacter>(GetOwner());
if (FatherCharacter)
{
Package.Time = GetWorld()->GetTimeSeconds();
Package.Character = FatherCharacter;
//遍历当前角色的HitCollisionBoxes中的所有hitbox
for (auto& BoxPair : FatherCharacter->HitCollisionBoxes)
{
FBoxInformation BoxInformation;
BoxInformation.Location = BoxPair.Value->GetComponentLocation();
BoxInformation.Rotation = BoxPair.Value->GetComponentRotation();
BoxInformation.BoxExtent = BoxPair.Value->GetScaledBoxExtent();
Package.HitBoxInfo.Add(BoxPair.Key, BoxInformation);
}
}
}

回滚判定

现在我们的A朝B开了一枪,触发了Fire():

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
.cpp


//HitTarget为本次射击的终点位置,单纯的起点+射击方向向量*有效射程
void AHitScanWeapon::Fire(const FVector& HitTarget) {
Super::Fire(HitTarget);

APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (OwnerPawn == nullptr) return;
AController* InstigatorController = OwnerPawn->GetController();

const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName("MuzzleFlash");
if (MuzzleFlashSocket) {
FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
//子弹生成的位置
FVector Start = SocketTransform.GetLocation();

FHitResult FireHit;
//看看打中了什么并存到FireHit里面
WeaponTraceHit(Start, HitTarget, FireHit);
//是否打中了一个玩家
ABlasterCharacter* HitCharacter = Cast<ABlasterCharacter>(FireHit.GetActor());
if (HitCharacter && InstigatorController) {
bool bCauseAuthDamage = !bUseServerSideRewind || OwnerPawn->IsLocallyControlled();
if (HasAuthority() && bCauseAuthDamage) {
//服务器开的枪,代码省略
}
if (!HasAuthority() && bUseServerSideRewind) { //当前为客户端,且开启了serverRewind功能
BlasterOwnerCharacter = BlasterOwnerCharacter ? BlasterOwnerCharacter : Cast<ABlasterCharacter>(OwnerPawn);
BlasterOwnerController = BlasterOwnerController ? BlasterOwnerController : Cast<ABlasterPlayerController>(InstigatorController);
if (BlasterOwnerCharacter && BlasterOwnerController && BlasterOwnerCharacter->GetLagCompensation()) {
ULagCompensationComponent* LagCompensation = BlasterOwnerCharacter->GetLagCompensation();
LagCompensation->ServerScoreRequest(
HitCharacter, //被击中的人
Start, //子弹射击生成位置
HitTarget, //子弹射击终点
BlasterOwnerController->GetServerTime() - BlasterOwnerController->SingleTripTime, //时间戳
this
);
}
}

}

这里有个细节:当前A客户端上的B因为延迟,其实是在单程延迟(50ms)时间前B在服务器上的位置,所以判定的时间要BlasterOwnerController->GetServerTime()- BlasterOwnerController->SingleTripTime。

视角转到服务器上的ULagCompensationComponent:

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
.h
//仅服务器运行且可靠
UFUNCTION(Server, Reliable)
void ServerScoreRequest(
ABlasterCharacter* HitCharacter,
const FVector_NetQuantize& TraceStart,
const FVector_NetQuantize& HitLocation,
float HitTime,
class AWeapon* DamageCauser
);

struct FServerSideRewindResult
{
GENERATED_BODY()
//确定命中
UPROPERTY()
bool bHitConfirmed;
//是爆头命中
UPROPERTY()
bool bHeadShot;
};

.cpp

void ULagCompensationComponent::ServerScoreRequest_Implementation(ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime, AWeapon* DamageCauser) {
//检查是否命中,命中结果存储在Confirm 中
FServerSideRewindResult Confirm = ServerSideRewind(HitCharacter, TraceStart, HitLocation, HitTime);

if (FatherCharacter && HitCharacter && DamageCauser && Confirm.bHitConfirmed)
{
const float Damage = Confirm.bHeadShot ? DamageCauser->GetHeadShotDamage() : DamageCauser->GetDamage();

UGameplayStatics::ApplyDamage(
HitCharacter,
Damage,
FatherCharacter->Controller,
DamageCauser,
UDamageType::StaticClass()
);
}
}

FServerSideRewindResult ULagCompensationComponent::ServerSideRewind(ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime) {
//找到对应的存储帧
FFramePackage FrametoCheck = GetFrameToCheck(HitCharacter, HitTime);
//返回命中验证
return ConfirmHit(FrametoCheck, HitCharacter, TraceStart, HitLocation);
}



GetFrameToCheck 和 ConfirmHit的实现细节:

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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
.cpp


FFramePackage ULagCompensationComponent::GetFrameToCheck(ABlasterCharacter* HitCharacter, float HitTime)
{
bool bReturn =
HitCharacter == nullptr ||
HitCharacter->GetLagCompensation() == nullptr ||
HitCharacter->GetLagCompensation()->FrameHistory.GetHead() == nullptr ||
HitCharacter->GetLagCompensation()->FrameHistory.GetTail() == nullptr;
if (bReturn) return FFramePackage();
// 返回的对应的存储帧
FFramePackage FrameToCheck;
bool bShouldInterpolate = true;
// 拿到FrameHistory列表
const TDoubleLinkedList<FFramePackage>& History = HitCharacter->GetLagCompensation()->FrameHistory;
const float OldestHistoryTime = History.GetTail()->GetValue().Time;
const float NewestHistoryTime = History.GetHead()->GetValue().Time;
if (OldestHistoryTime > HitTime)
{
// 传过来的Hittime过期了,返回空,未命中
return FFramePackage();
}
if (OldestHistoryTime == HitTime)
{
FrameToCheck = History.GetTail()->GetValue();
bShouldInterpolate = false;
}
if (NewestHistoryTime <= HitTime)
{
//HitTime是最新的甚至比服务器时间还新,都按服务器最新处理
FrameToCheck = History.GetHead()->GetValue();
bShouldInterpolate = false;
}

TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* Younger = History.GetHead();
TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* Older = Younger;
while (Older->GetValue().Time > HitTime) // 查找到HitTime所在的时间(这里可以优化成二分)
{
// 直到: OlderTime < HitTime < YoungerTime
if (Older->GetNextNode() == nullptr) break;
Older = Older->GetNextNode();
if (Older->GetValue().Time > HitTime)
{
Younger = Older;
}
}
if (Older->GetValue().Time == HitTime) // 幸运的,发过来的时间戳刚好在服务端上有
{
FrameToCheck = Older->GetValue();
bShouldInterpolate = false;
}
if (bShouldInterpolate)
{
// 因为延迟等问题,发过来的时间戳没有,用最近的两个帧插值
FrameToCheck = InterpBetweenFrames(Older->GetValue(), Younger->GetValue(), HitTime);
}
FrameToCheck.Character = HitCharacter;
return FrameToCheck;
}
//InterpBetweenFrames插值代码参见项目

FServerSideRewindResult ULagCompensationComponent::ConfirmHit(const FFramePackage& Package, ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation) {
if (HitCharacter == nullptr) return FServerSideRewindResult();

FFramePackage CurrentFrame;
//cache 角色当前所有hitbox的位置到CurrentFrame
CacheBoxPositions(HitCharacter, CurrentFrame);
//把这些box挪到历史位置
MoveBoxes(HitCharacter, Package);
//关闭角色模型的碰撞,以免当前角色模型造成干扰(当前角色模型未回滚)
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::NoCollision);

// 先开启头的碰撞盒
UBoxComponent* HeadBox = HitCharacter->HitCollisionBoxes[FName("head")];
HeadBox->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
HeadBox->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block);

FHitResult ConfirmHitResult;
const FVector TraceEnd = TraceStart + (HitLocation - TraceStart) * 1.25f;
UWorld* World = GetWorld();
if (World) {
World->LineTraceSingleByChannel(
ConfirmHitResult,
TraceStart,
TraceEnd,
ECC_HitBox
);
if (ConfirmHitResult.bBlockingHit) {
//仅开启了头部盒子,所以是爆头,还原所有hitbox的位置,开启当前角色碰撞
ResetHitBoxes(HitCharacter, CurrentFrame);
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics);
return FServerSideRewindResult{ true, true };
} else {
//没打中头部盒子,开启其他盒子,并进行碰撞判定
for (auto& HitBoxPair : HitCharacter->HitCollisionBoxes) {
if (HitBoxPair.Value != nullptr) {
HitBoxPair.Value->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
HitBoxPair.Value->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block);
}
}
World->LineTraceSingleByChannel(
ConfirmHitResult,
TraceStart,
TraceEnd,
ECC_HitBox
);
if (ConfirmHitResult.bBlockingHit) {
//有命中其中一个碰撞盒子,还原所有hitbox的位置到CurrentFrame,开启当前角色碰撞
ResetHitBoxes(HitCharacter, CurrentFrame);
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics);
return FServerSideRewindResult{ true, false };
}
}
}
//没有命中该角色旧时间任何碰撞盒,还原所有hitbox的位置到CurrentFrame,开启当前角色碰撞
ResetHitBoxes(HitCharacter, CurrentFrame);
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics);
return FServerSideRewindResult{ false, false };
}

CacheBoxPositions等细节函数较为简单

三、联机中的GameMode,GameState,PlayerState

(99+ 封私信 / 80 条消息) UE多人联机入门笔记(三) 联机中的GameMode,GameState,PlayerState - 知乎

GameMode

GameMode身为一场游戏的唯一逻辑操纵者身兼重任,在功能实现上有许多的接口,但主要可以分为以下几大块……省略 4.多人游戏的步调同步,在多人游戏的时候,我们常常需要等所有加入的玩家连上之后,载入地图完毕后才能一起开始逻辑 -《InsideUE4》GamePlay架构(七)GameMode和GameState

本工程是多人射击游戏,采用AGameMode,这里需要特地强调:Game Mode 不会复制到加入多人游戏的远程客户端;它只存在于服务器上,因此本地客户端可看到之前使用过的留存 Game Mode 类(或蓝图);但无法访问实际的实例并检查其变量,确定游戏进程中已发生哪些变化。这也是我们为什么有Game State, Game State的数据会被复制到所有远程客户端。

GameState

Game State 负责启用客户端监控游戏状态。从概念上而言,Game State 应该管理所有已连接客户端已知的信息(特定于 Game Mode 但不特定于任何个体玩家)。它能够追踪游戏层面的属性,如已连接玩家的列表、夺旗游戏中的团队得分、开放世界游戏中已完成的任务,等等。

虚幻引擎中的 Game Mode 和 Game State | 虚幻引擎 5.5 文档 | Epic Developer Community

Game State 并非追踪玩家特有内容(如夺旗比赛中特定玩家为团队获得的分数)的最佳之处,因为它们由 Player State 更清晰地处理。

可以看出工程里的GameState只承担了计分排行榜的职责,实际上我们可以将更新每个玩家显示倒计时的职责也给它,统一为countDowntime,Gamemode 只需要在 OnMatchStateSet() 里更新GameState的countDowntime 并让 GameState去处理更新每个controller。

所以这里的逻辑设置时灵活的,引用大钊老师的话:

关于使用,开发者可以自定义GameState子类来存储本GameMode的运行过程中产生的数据(那些想要replicated的!),如果是GameMode游戏运行的一些数据,又不想要所有的客户端都可以看到,则也可以写在GameMode的成员变量中。

PlayerState

游戏里一个全局的玩家逻辑实体,而PlayerController代表的就是玩家的意志,PlayerState代表的是玩家的状态。 APlayerState用来保存玩家的游戏数据,GameState把当前Server的PlayerState都收集了过来,方便访问使用

如果套用MVC模型的话,那么这里PlayerState应该代表的M,玩家手中的金币,子弹数量等数据。而GameState的 PlayerArray 存的就是所有Player的 PlayerState。

PlayerState使用可以参考 UE4游戏框架中PlayerState基本使用