前言 (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 { 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 void UCombatComponent::Fire () { if (EquippedWeapon && FatherCharacter) { CrosshairHitTarget = EquippedWeapon->bUseScatter ? EquippedWeapon->TraceEndWithScatter (CrosshairHitTarget) : CrosshairHitTarget; if (!FatherCharacter->HasAuthority ()) LocalFire (CrosshairHitTarget); ServerFire (CrosshairHitTarget, EquippedWeapon->FireDelay); } void UCombatComponent::ServerFire_Implementation (const FVector_NetQuantize& TraceHitTarget, float FireDelay) { MulticastFire (TraceHitTarget); } void UCombatComponent::MulticastFire_Implementation (const FVector_NetQuantize& TraceHitTarget) { if (FatherCharacter && FatherCharacter->IsLocallyControlled () && !FatherCharacter->HasAuthority ()) { return ; } 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 () ;
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 ; UFUNCTION (Client, Reliable)void ClientUpdateAmmo (int32 ServerAmmo) ; void SpendRound () ; int32 AmmoNetSequence = 0 ; .cpp void AWeapon::SpendRound () { Ammo = FMath::Clamp (Ammo - 1 , 0 , MagCapacity); SetHUDAmmo (); if (HasAuthority ()) { ClientUpdateAmmo (Ammo); } else { ++AmmoNetSequence; } } void AWeapon::ClientUpdateAmmo_Implementation (int32 ServerAmmo) { if (HasAuthority ()) return ; Ammo = ServerAmmo; --AmmoNetSequence; Ammo -= AmmoNetSequence; 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 ; UFUNCTION (Server, Reliable) void ServerRequestServerTime (float TimeOfClientRequest) ; UFUNCTION (Client, Reliable) void ClientReportServerTime (float TimeOfClientRequest, float TimeServerReceivedClientRequest) ; float ClientServerDelta = 0.f ;
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 (); 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 ; 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; if (IsLocalController () && TimeSyncRunningTime >= TimeSyncFrequency) { ServerRequestServerTime (GetWorld ()->GetTimeSeconds ()); TimeSyncRunningTime = 0.f ; } } float ABlasterPlayerController::GetServerTime () { if (HasAuthority ()) { return GetWorld ()->GetTimeSeconds (); } return GetWorld ()->GetTimeSeconds () + ClientServerDelta; }
HitBox HixBox选择 存储每一个人碰撞箱的位置信息
对于碰撞箱的判定,若对于判定要求不高或者服务器的配置不高,可以只用一个胶囊体去代表历史位置
但是对于严格判定,并且服务器配置好的情况下,要将hitbox列入游戏平衡。我们就要在骨骼上绑定碰撞箱。
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); 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 USTRUCT (BlueprintType)struct FBoxInformation { GENERATED_BODY () UPROPERTY () FVector Location; UPROPERTY () FRotator Rotation; UPROPERTY () FVector BoxExtent; }; USTRUCT (BlueprintType)struct FFramePackage { GENERATED_BODY () UPROPERTY () float Time; UPROPERTY () TMap<FName, FBoxInformation> HitBoxInfo; UPROPERTY () ABlasterCharacter* Character; }; 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 () { PrimaryComponentTick.bCanEverTick = true ; } void ULagCompensationComponent::TickComponent (float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent (DeltaTime, TickType, ThisTickFunction); if (FatherCharacter == nullptr || !FatherCharacter->HasAuthority ()) return ; SaveFramePackagePerTick (); } 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) { FrameHistory.RemoveNode (FrameHistory.GetTail ()); HistoryLength = FrameHistory.GetHead ()->GetValue ().Time - FrameHistory.GetTail ()->GetValue ().Time; } FFramePackage ThisFrame; SaveFramePackage (ThisFrame); FrameHistory.AddHead (ThisFrame); } } void ULagCompensationComponent::SaveFramePackage (FFramePackage& Package) { FatherCharacter = FatherCharacter ? FatherCharacter : Cast <ABlasterCharacter>(GetOwner ()); if (FatherCharacter) { Package.Time = GetWorld ()->GetTimeSeconds (); Package.Character = FatherCharacter; 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 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; WeaponTraceHit (Start, HitTarget, FireHit); ABlasterCharacter* HitCharacter = Cast <ABlasterCharacter>(FireHit.GetActor ()); if (HitCharacter && InstigatorController) { bool bCauseAuthDamage = !bUseServerSideRewind || OwnerPawn->IsLocallyControlled (); if (HasAuthority () && bCauseAuthDamage) { } if (!HasAuthority () && bUseServerSideRewind) { 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) { 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 ; const TDoubleLinkedList<FFramePackage>& History = HitCharacter->GetLagCompensation ()->FrameHistory; const float OldestHistoryTime = History.GetTail ()->GetValue ().Time; const float NewestHistoryTime = History.GetHead ()->GetValue ().Time; if (OldestHistoryTime > HitTime) { return FFramePackage (); } if (OldestHistoryTime == HitTime) { FrameToCheck = History.GetTail ()->GetValue (); bShouldInterpolate = false ; } if (NewestHistoryTime <= HitTime) { FrameToCheck = History.GetHead ()->GetValue (); bShouldInterpolate = false ; } TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* Younger = History.GetHead (); TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* Older = Younger; while (Older->GetValue ().Time > HitTime) { 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; } FServerSideRewindResult ULagCompensationComponent::ConfirmHit (const FFramePackage& Package, ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation) { if (HitCharacter == nullptr ) return FServerSideRewindResult (); FFramePackage CurrentFrame; CacheBoxPositions (HitCharacter, CurrentFrame); 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) { 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) { ResetHitBoxes (HitCharacter, CurrentFrame); EnableCharacterMeshCollision (HitCharacter, ECollisionEnabled::QueryAndPhysics); return FServerSideRewindResult{ true , false }; } } } 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基本使用 ,