前言 UE 中网络系统是重要一个部分,其中包括网络预测更为重要。DS就需要基于此去处理部分信息。
零、开篇 省流 第一篇使用 关键字回忆
帧同步和状态同步,UE5的CS架构(如DS)是状态同步
Player角色枚举ENetRole
RPC :[Reliable]、HasAuthority()检测宇宙是否权威、xxx_Implementation执行、xxx_Validate 校验
属性复制 : [Replicated]、[ReplicatedUsing = OnRep_xxx ] 、注册属性复制 GetLifetimeReplicatedProps()–[DOREPLIFETIME]
组件属性复制:CombatComponent->SetIsReplicated(true); 构造函数中SetIsReplicatedByDefault(true);
第二篇预测
客户端提前预测:Ammo的属性复制和AmmoNetSequence(预测变更子弹数)
服务器权威 对表 HitBox 回滚判定
写入对表:计算时间 DTime = STime - CTime = (RspSTime + Trip1Time ) - CTime = (RspSTime + Trip2Time * 0.5 ) - CTime = (RspSTime + (NowCTime - ReqCTime )* 0.5) - CTime
对表在一定时间需要更新,可能会有DTime的误差
HitBox(双端链表)存储:在一定时间中的FramePackage;
用射击时间HitTime去查找Hitbox中TarPos,一般会在俩个HitBox中间(使用插值处理or选择更近的hitbox);
回溯与判定:SetPos人物到TarPos->判定->SetPos放回人物
一、同步 帧同步和状态同步 【网络同步】浅析帧同步和状态同步 - 知乎
需要说明的是,由于状态同步的安全性 比帧同步高很多,且对网络延迟有较大容忍,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_None UMETA (DisplayName = "None" ) , ROLE_SimulatedProxy UMETA (DisplayName = "Simulated Proxy" ) , ROLE_AutonomousProxy UMETA (DisplayName = "Autonomous Proxy" ) , 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 .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); }
RPC底层
UE4属性同步(四)rpc实现 - 知乎
Reliable RPC通信默认是不可靠的,即如果数据包被丢弃,则不执行该RPC。
而如果某些函数是重要的,比如ServerFire告知服务器这个玩家开火,我们需要添加Reliable来标记可靠“将重新发送该RPC,直到它被接收方确认。在确认该RPC之前,将暂停所有后续RPC执行”。
当然,如果学过TCP和UDP, 就知道可靠传输代价会更高一些。
WithValidation RPC还提供了验证功能,需要我们在cpp中添加_Validate实现:
1 2 3 4 5 6 7 8 9 10 11 bool UCombatComponent::ServerFire_Validate (const FVector_NetQuantize& TraceHitTarget, float FireDelay) {if (EquippedWeapon) { bool bNearlyEqual = FMath::IsNearlyEqual (EquippedWeapon->FireDelay, FireDelay, 0.01f ); return bNearlyEqual; } return true ; }
上面是一个简单的验证的实现,上线项目需要检测更多的属性。
注意,我们需要确保 当前AActor派生的类被设置为在派生的Actor的构造函数内复制:
bReplicates = true;
属性复制
Replicated属性为属性复制提供了指定特定条件的选项,将属性复制限制在特定连接上。你也可以设置自定义复制条件,为属性复制定义自己的逻辑。
ReplicatedUsing属性需要你提供RepNotify函数,当相关属性被复制时,客户端就会调用该函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 .h UPROPERTY (Replicated) bool bDisableGameplay = false ; UPROPERTY (ReplicatedUsing = OnRep_Health) float Health = 100.f ; UFUNCTION () void OnRep_Health (float LastHealth) ; virtual void GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& OutLifetimeProps) const override ; 注意,绑定的方法**必须**有UFUNCTION (),我们还必须要 override GetLifetimeReplicatedProps 并添加宏调用,以在派生的Actor实例的生命周期内复制需要的属性 void ABlasterCharacter::GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& 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下的组件,自建的组件默认是不开启网络复制的,该组件需要网络复制,则需要:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Combat->SetIsReplicated (true ); 结合文档,我们看到如果是静态Actor组件,即在Actor结构函数中创建的Actor组件,例如: AMyActor::AMyActor (){ bReplicates = true ; MyActorComponent = CreateDefaultSubobject <UMyActorComponent>(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计算单程时间,大部分情况下来往时间也确实是相同的。
而这种延迟在客户端和服务端造成了很大的困惑,我们先来看客户端:
客户端预测 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 33 34 .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
同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 .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被红迪说不照顾非洲兄弟)
这段代码实现了一个典型的客户端-服务器架构下的延迟补偿系统 ,主要作用包括:
帧数据记录 :在服务器端定期保存角色及其碰撞框的状态快照
历史数据管理 :维护一个有时间限制的帧历史记录(由MaxRecordTime控制)
数据清理 :自动移除过期的历史帧数据以控制内存使用
注意事项
该组件仅在拥有权限(服务器端)运行 ,通过HasAuthority()检查保证
使用双向链表(TDoubleLinkedList)存储帧历史,便于从两端快速操作
碰撞框信息包括位置、旋转和尺寸,为后续的命中检测提供完整数据
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 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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 void AHitScanWeapon::Fire (const FVector& HitTarget) { Super::Fire (HTarget); 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 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 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 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基本使用 ,
参考 UE多人联机入门笔记(一) - 知乎
Unreal Engine 网络同步:属性同步、RPC、断线重连与高级技巧 - 知乎
硬核向:延迟补偿具体实现算法_哔哩哔哩_bilibili