【UE5】多人联机TPS游戏开发(四) —— 延迟补偿
不知道为什么,最近脑子里经常响起《月光》的旋律,每当这时,因为压力而烦躁的内心都会慢慢平静下来。大概是因为被《恶灵附身》吓得失去生命体征了吧。
作为涉猎广泛的FPS老玩家,受到垃圾的网络环境的影响应该算是必经之路了。至今仍然记得初中顶着一百多毫秒延迟打OW在地图里乱飘的烦躁,还有躲到墙后仍然被打死的无语。之前只是知道有这些现象,却完全不知道是什么原理,而现在却要自己动手尝试解决这些问题了,想到这里,不由得感到一阵恍惚,时间如流水啊。
可能是马上要面临的压力让我变得有些多愁善感了吧。总之,接下来将要着手解决(减轻)高延迟带来的游戏体验问题。当然,对于目前已经比较成熟的FPS竞技游戏品类,即使已经有数不清的方案被用来解决延迟问题,尚且不能完美达成目的,本项目所用到的只是最基础的几个而已,肯定还是有很大的缺陷。然而,千里之行始于足下,只要像这样持续去学习,又有什么是学不会的呢?反正这段时间我是学爽了。
1.延迟补偿
自多人游戏诞生以来,网络延迟就是一个无法绕过的难题。对于不同的游戏品类,其对网络条件的要求是不同的,而FPS
竞技游戏(虽然本项目是TPS
就是了)则是其中对网络要求最高的品类之一。而网络问题又包括延迟、丢包、抖动等,本项目着重讨论的是延迟问题。
说实话,想要完全消除延迟是几乎不可能的事,除非哪天真的发明出了低成本的零延迟通讯技术,否则就只能想方设法在软件层面上找补。本项目使用了两个技术:客户端预测+服务器修正,以及服务器倒带,尽量降低延迟对玩家的影响。
为了模拟高延迟的情况,在Config/DefaultEngine.ini
中进行相应配置:
1 2
| [PacketSimulationSettings] PktLag = 200
|
在本文的最后一部分将会讨论Valve
使用的延迟补偿技术,看看真正的游戏的做法是什么样的。
2.延迟检测
UE
引擎内置了获取延迟的功能,那为什么不直接用呢?
HUD
部分依然放在CharacterOverlay
中处理。包含一个图像以及一个动画,动画控制图像的闪烁。
1 2 3 4 5 6
| UPROPERTY(meta = (BindWidget)) class UImage* HighPingImage;
UPROPERTY(meta = (BindWidgetAnim), Transient) UWidgetAnimation* HighPingAnimation;
|
在Tick
中运行检查函数,当检查间隔到达一定时间后检查Ping
,通过GetCompressedPing()*4
获得客户端的Ping
值,如果Ping
高于门槛,就触发提示,播放动画。
1 2 3 4 5 6 7 8 9 10 11 12 13
| float HighPingRunningTime = 0.f;
UPROPERTY(EditAnywhere) float HighPingDuration = 5.f;
float PingAnimationRunningTime = 0.f;
UPROPERTY(EditAnywhere) float CheckPingFrequency = 5.f;
UPROPERTY(EditAnywhere) float HighPingThreshold = 50.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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| void AMPTPSPlayerController::CheckPing(float DeltaTime) { HighPingRunningTime += DeltaTime; if (HighPingRunningTime > CheckPingFrequency) { PlayerState = PlayerState == nullptr ? GetPlayerState<AMultiplayerTPSPlayerState>() : PlayerState; if (PlayerState) { if (PlayerState->GetCompressedPing() * 4 > HighPingThreshold) { HighPingWarning(); PingAnimationRunningTime = 0.f; } } HighPingRunningTime = 0.f; } if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation && MultiplayerTPSHUD->CharacterOverlay->IsAnimationPlaying(MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation)) { PingAnimationRunningTime += DeltaTime; if (PingAnimationRunningTime > HighPingDuration) { StopHighPingWarning(); } } }
void AMPTPSPlayerController::HighPingWarning() { MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD; if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->HighPingImage && MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation) { MultiplayerTPSHUD->CharacterOverlay->HighPingImage->SetOpacity(1.f); MultiplayerTPSHUD->CharacterOverlay->PlayAnimation(MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation, 0.f, 5); } }
void AMPTPSPlayerController::StopHighPingWarning() { MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD; if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->HighPingImage && MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation) { MultiplayerTPSHUD->CharacterOverlay->HighPingImage->SetOpacity(0.f); if (MultiplayerTPSHUD->CharacterOverlay->IsAnimationPlaying(MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation)) { MultiplayerTPSHUD->CharacterOverlay->StopAnimation(MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation); } } }
|
3.客户端预测+服务器修正
在理想的情况下,客户端的一切操作都应该提交到服务器上,由服务器进行处理后将更新后的状态发送回客户端,这样能完全保证服务器权威。然而,以目前的网络发展水平,这样做是不可接受的,因为玩家的一切操作都要经过网络延迟后才能产生效果,对玩家的游戏体验来说具有极大的破坏性,尤其是那些高延迟的玩家。
客户端预测+服务器修正就是用来解决这个问题的。它的思想是,对于那些相对不想要那么高权威性的操作,客户端在将操作发往服务器的同时直接在本地执行这些操作,这一步为预测;而服务器收到客户端的操作并进行处理后将更新后的状态发送给客户端,此时如果该状态和客户端自己执行后的状态不同,就用服务器回复给客户端的状态将客户端自己的状态覆盖掉,这一步为修正。以移动为例,假如客户端的玩家向右移动了10米,但是因为某些原因,服务器收到的操作是玩家向右移动了5米,那么在客户端看来,玩家在收到操作指令后立即向右移动了10米,但随后服务器的计算结果来了,玩家就会被强行移动的左边5米的位置。
这个方法面临着一个问题:要对每一次操作都进行修正吗?同样以移动为例,客户端的玩家向右移动了10米,当这个操作发送到服务器进行处理后,服务器会将处理后的状态发送给客户端,然而如果客户端的玩家在此期间又向右移动了10米,那么当服务器的回复到达客户端时,客户端的玩家角色会被恢复到向右10米的位置,而当下一次服务器的操作处理结果到达时,客户端的玩家角色又被恢复到向右20米的位置。这显然是不合常理的。
对于这个问题,解决方案是:客户端每次将操作发送出去时,将这些发送记录保存下来,当收到服务器的回复时,首先检查保存的记录中有没有符合的操作,如果有,说明这次回复对应的是之前的操作,不需要进行修正;如果没有,则进行修正。
对于本项目,就有一个非常适合应用这项技术的地方:弹药消耗。
在之前的实现中,当进行开火时,会通过RPC
在所有客户端上进行开火操作,而只有在服务器上才能进行消耗子弹的操作,当子弹数量改变后会通过复制同步到客户端,并在回调函数中进行HUD
更新。在这种做法下,当延迟很高的时候,就会发现开火与子弹数量改变之间有很明显的时间差。
首先要去除Ammo
变量的复制。弹药是一个整型变量,因此可以简单地用一个变量Sequence
来记录对Ammo
的操作记录。在SpendRound
函数中,不再限制只有服务器能修改Ammo
,取而代之的是,如果在客户端,将Sequence
加1表示客户端消耗了一枚弹药。
1 2 3 4 5 6 7 8 9 10 11
| void AWeapon::SpendRound() { Ammo = FMath::Clamp(Ammo - 1, 0, MagCapacity); SetHUDAmmo(); if (HasAuthority()) { ClientUpdateAmmo(Ammo); } else { ++Sequence; } }
|
而在服务器,会调用Client RPC
进行客户端Ammo
变量的检查和修正。更新弹药值后将Sequence
减1,表示这次更新对应了客户端的一次操作,然后根据Sequence
进行客户端Ammo
的修正。最后在客户端更新HUD
。
1 2 3 4 5 6 7 8 9 10
| void AWeapon::ClientUpdateAmmo_Implementation(int32 ServerAmmo) { if (HasAuthority()) { return; } Ammo = ServerAmmo; --Sequence; Ammo -= Sequence; SetHUDAmmo(); }
|
这就是客户端预测+服务器修正的基础运用。换弹也可以用同样的方式进行优化,因为这篇文章聚焦于技术原理,就不具体展示了。
4.服务器倒带
4.1 基本概念
在多人游戏中,我们很少注意到的一件事情是:玩家看到的一切都存在于过去(监听服务器除外)。因为只有服务器上的游戏状态是权威的,而无论是客户端向服务器发送自己的操作,还是服务器向所有客户端同步权威的游戏状态,都需要一定的时间,所以客户端看到的永远都是一段时间之前的游戏状态。对于FPS
竞技游戏,这件事的后果就是:玩家看到了一个敌人,进行瞄准并射击,这个操作会被发送到服务器进行判定。然而对于服务器,当其收到客户端的瞄准操作时,客户端瞄准的那个玩家可能已经不在那个位置了,这时客户端得到的结果自然是未命中。延迟越高,这种情况就越明显,而这显然是不可接受的。

服务器倒带就是用来解决这个问题的。服务器倒带的思想时,当客户端请求进行射击的判定时,服务器不会直接使用目标当前的状态,而是会使用目标在客户端射击时的状态进行判定。服务器倒带的核心是:记录所有玩家一定时间内的必要信息,用于在客户端射击时进行回溯,判断射击是否命中。这正是FPS
竞技游戏中出现“明明躲到了掩体后面却被击杀”这种情况的原因。而为了避免这种情况出现得过于频繁和离谱,游戏需要对服务器倒带的范围进行一些限制,不然就太偏向于高延迟玩家,而破坏了低延迟玩家的体验了。
4.2 角色历史信息保存
上面说到,服务器倒带需要记录所有玩家一定时间内的必要信息。那么问题来了:所谓的“必要信息”是什么?位置?但是目标可能会蹲下,可能会跳跃,可能在做各种动作,只记录位置的话无法做到精确的判定。整个网格体?这样确实很精确,但是开销实在是太大了,尤其是在可能要存几百个记录的情况下。
目前的主流做法是,将玩家用一些命中盒(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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
|
UPROPERTY(EditAnywhere) class UBoxComponent* head;
UPROPERTY(EditAnywhere) UBoxComponent* pelvis;
UPROPERTY(EditAnywhere) UBoxComponent* spine_02;
UPROPERTY(EditAnywhere) UBoxComponent* spine_03;
UPROPERTY(EditAnywhere) UBoxComponent* upperarm_l;
UPROPERTY(EditAnywhere) UBoxComponent* upperarm_r;
UPROPERTY(EditAnywhere) UBoxComponent* lowerarm_l;
UPROPERTY(EditAnywhere) UBoxComponent* lowerarm_r;
UPROPERTY(EditAnywhere) UBoxComponent* hand_l;
UPROPERTY(EditAnywhere) UBoxComponent* hand_r;
UPROPERTY(EditAnywhere) UBoxComponent* backpack;
UPROPERTY(EditAnywhere) UBoxComponent* blanket;
UPROPERTY(EditAnywhere) UBoxComponent* thigh_l;
UPROPERTY(EditAnywhere) UBoxComponent* thigh_r;
UPROPERTY(EditAnywhere) UBoxComponent* calf_l;
UPROPERTY(EditAnywhere) UBoxComponent* calf_r;
UPROPERTY(EditAnywhere) UBoxComponent* foot_l;
UPROPERTY(EditAnywhere) UBoxComponent* foot_r;
|
初始将这些命中盒和角色的骨骼进行绑定,并设置为无碰撞。
1 2 3 4 5 6 7 8 9
| head = CreateDefaultSubobject<UBoxComponent>(TEXT("head")); head->SetupAttachment(GetMesh(), FName("head")); head->SetCollisionEnabled(ECollisionEnabled::NoCollision);
pelvis = CreateDefaultSubobject<UBoxComponent>(TEXT("pelvis")); pelvis->SetupAttachment(GetMesh(), FName("pelvis")); pelvis->SetCollisionEnabled(ECollisionEnabled::NoCollision);
...
|

对于服务器倒带的相关逻辑,我想放在一起统一处理,所以我创建了一个Actor
组件LagCompensationComponent
。在其中声明两个结构体,分别用来保存命中盒自身的信息和角色的所有命中盒以及时间的信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| USTRUCT(BlueprintType) struct FBoxInformation { GENERATED_BODY()
FVector Location;
FVector Rotation;
FVector BoxExtent; };
USTRUCT(BlueprintType) struct FFramePackage { GENERATED_BODY()
float Time;
TMap<FName, FBoxInformation> HitBoxInfo; AMultiplayerTPSCharacter* Character; };
|
在角色类中进行初始化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| LagCompensation = CreateDefaultSubobject<ULagCompensationComponent>(TEXT("LagCompensation"));
void AMultiplayerTPSCharacter::PostInitializeComponents() { Super::PostInitializeComponents(); ... ... if (LagCompensation) { LagCompensation->Character = this; if (Controller) { LagCompensation->Controller = Cast<AMPTPSPlayerController>(Controller); } } }
|
创建一个TMap
,用来保存所有的命中盒信息。
1 2 3 4
| UPROPERTY() TMap<FName, UBoxComponent*> HitCollisionBoxes;
HitCollisionBoxes.Add(FName("head"), head);
|
在LagCompensationComponent
中定义用来保存当前帧的所有角色命中盒信息的函数以及绘制命中盒的函数。在BeginPlay
中保存并绘制,就能看到命中盒在游戏中的样子了(我承认头确实有点大了)。
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 ULagCompensationComponent::SaveFramePackage(FFramePackage& Package) { if (Character) { Package.Time = GetWorld()->GetTimeSeconds(); for (auto& BoxPair : Character->HitCollisionBoxes) { FBoxInformation BoxInformation; BoxInformation.Location = BoxPair.Value->GetComponentLocation(); BoxInformation.Rotation = BoxPair.Value->GetComponentRotation(); BoxInformation.BoxExtent = BoxPair.Value->GetScaledBoxExtent(); Package.HitBoxInfo.Add(BoxPair.Key, BoxInformation); } } }
void ULagCompensationComponent::ShowFramePackage(const FFramePackage& Package, FColor Color) { for (auto& BoxInfo:Package.HitBoxInfo) { DrawDebugBox( GetWorld(), BoxInfo.Value.Location, BoxInfo.Value.BoxExtent, FQuat(BoxInfo.Value.Rotation), Color, true ); } }
|

接下来的问题是,要如何保存一定时间范围内的命中盒信息?
想象一下,随着时间的流逝,我们需要不断保存新的命中盒信息,同时丢弃那些超出服务器倒带范围的命中盒信息,这代表着要在数据结构的两端进行频繁增删;同时因为要按时间进行回溯,所以这些命中盒信息必须是有序的。而满足这些要求的数据结构正是双向链表。
在组件中声明一个保存命中盒信息的双向链表,同时声明服务器倒带的最长回溯时间。
1 2 3 4
| TDoubleLinkedList<FFramePackage> FrameHistory;
UPROPERTY(EditAnywhere) float MaxRecordTime = 4.f;
|
在Tick
中保存每一帧的命中盒信息,同时如果双向链表尾部的节点已经超出了回溯范围,将其丢弃。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| void ULagCompensationComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (FrameHistory.Num() <= 1) { FrameHistory.AddHead(FFramePackage()); SaveFramePackage(FrameHistory.GetHead()->GetValue()); } 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; } FrameHistory.AddHead(FFramePackage()); SaveFramePackage(FrameHistory.GetHead()->GetValue()); } ShowFramePackage(FrameHistory.GetHead()->GetValue(), FColor::Red); }
|
将这些命中盒绘制出来就可以看到效果了。在实际体验中,可以感觉到这种保存方式对游戏流畅性的影响基本没有。

为了方便进行检测命中盒,新建了一个碰撞通道HitBox
,并定义了一个宏:
1
| #define ECC_HitBox ECollisionChannel::ECC_GameTraceChannel2
|
在构造角色时遍历角色的TMap,设置每一个HitBox的碰撞通道。
1 2 3 4 5 6
| for (auto& Box : HitCollisionBoxes) { Box.Value->SetCollisionObjectType(ECC_HitBox); Box.Value->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore); Box.Value->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block); Box.Value->SetCollisionEnabled(ECollisionEnabled::NoCollision); }
|
4.3 倒带
然后就是服务器倒带最重要的部分:倒带的实现了。
声明一个结构体,保存服务器倒带的检查结果。
1 2 3 4 5 6 7 8
| USTRUCT(BlueprintType) struct FServerSideRewindResult { GENERATED_BODY()
bool bHitConfirmed; bool bHeadShot; };
|
首先从HitScanWeapon
开始,讲解一遍服务器倒带的流程。
上一篇中我自己挖的坑在这里被我自己踩了。为了杜绝作弊,我的做法是由服务器计算散布并进行检测,结果到了这里,客户端就无法提供命中的角色,也就无法通过这种方式实现服务器倒带了。
但是我想到了一个方法:只要将所有角色的命中盒进行倒带,不就能直接检测并且不需要提供命中角色了吗?而对于这种小体量并且玩家数不多的游戏,这种代价应该是可以接受的。
不过这里还是得到了教训:做之前先多了解目前已经成熟的技术方案,不要脑子一热觉得自己想到了什么盲点(哪怕是问了AI)。
下面介绍的是正常的服务器倒带实现方法。
当客户端角色开火后,不再将判定都交由服务器进行处理,而是直接在本地进行射线检测。如果检测到命中了敌方角色,则将命中的角色作为参数传给服务器进行服务器倒带。其它参数分别是射击起始位置(枪口Socket
),命中位置,命中时间,以及发起角色。
1 2 3 4 5 6 7
| MultiplayerTPSOwnerCharacter->GetLagCompensation()->ServerScoreRequest( MultiplayerTPSCharacter, Start, HitTarget, MultiplayerTPSOwnerController->GetServerTime()-MultiplayerTPSOwnerController->SingleTripTime, this );
|
服务器会根据这些参数判断是否成功命中。首先判断命中时间是否超出了最大倒带范围,如果命中时间在双向链表尾部节点的时间之前,直接返回空结果。接下来寻找两个目标节点,使得命中时间在这两个目标节点的时间时间,然后通过插值的方式获取命中时间时对应的所有命中盒信息。
获得了要检查的命中盒后,接下来的过程就简单了。先将角色当前的命中盒信息保存下来,然后将角色的命中盒更改为要检测的命中盒,并关闭角色网格体的碰撞。这时候就可以进行射线检测了。首先开启头部命中盒的碰撞,检测后如果命中,说明命中了头部,直接返回结果,否则关闭头部命中盒的同时开启其它命中盒的碰撞进行检测,如果命中则返回结果。最终返回未命中的结果。在任何返回前记得将角色的命中盒、网格体碰撞以及碰撞盒的碰撞改为原样。
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
| void ULagCompensationComponent::ServerScoreRequest_Implementation(AMultiplayerTPSCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime, AWeapon* DamageCasuer) { FServerSideRewindResult Confirm = ServerSideRewind(HitCharacter, TraceStart, HitLocation, HitTime); if (Character && HitCharacter && Confirm.bHitConfirmed && DamageCasuer) { UGameplayStatics::ApplyDamage( HitCharacter, DamageCasuer->GetDamage(), Character->Controller, DamageCasuer, UDamageType::StaticClass() ); } }
FServerSideRewindResult ULagCompensationComponent::ServerSideRewind(AMultiplayerTPSCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime) { FFramePackage FrameToCheck = GetFrameToCheck(HitCharacter, HitTime);
return ConfirmHit(FrameToCheck, HitCharacter, TraceStart, HitLocation); }
FFramePackage ULagCompensationComponent::GetFrameToCheck(AMultiplayerTPSCharacter* HitCharacter, float HitTime) { bool bReturn = !HitCharacter || !HitCharacter->LagCompensation || !HitCharacter->LagCompensation->FrameHistory.GetHead() || !HitCharacter->LagCompensation->FrameHistory.GetTail(); if (bReturn) { return FFramePackage(); }
FFramePackage FrameToCheck; FrameToCheck.Character = HitCharacter; const TDoubleLinkedList<FFramePackage>& History = HitCharacter->LagCompensation->FrameHistory; const float OldestHistoryTime = History.GetTail()->GetValue().Time; const float NewestHistoryTime = History.GetHead()->GetValue().Time; if (OldestHistoryTime > HitTime) { return FFramePackage(); } if (NewestHistoryTime <= HitTime) { FrameToCheck = History.GetHead()->GetValue(); }
auto Younger = History.GetHead(); auto Older = Younger; while (Older->GetValue().Time > HitTime) { if (!Older->GetNextNode()) { break; } Older = Older->GetNextNode(); if (Older->GetValue().Time > HitTime) { Younger = Older; } } FrameToCheck = InterpBetweenFrames(Older->GetValue(), Younger->GetValue(), HitTime);
return FrameToCheck; }
FFramePackage ULagCompensationComponent::InterpBetweenFrames(const FFramePackage& OlderFrame, const FFramePackage& YoungerFrame, float HitTime) { const float Distance = YoungerFrame.Time - OlderFrame.Time; const float InterpFraction = FMath::Clamp((HitTime - OlderFrame.Time) / Distance, 0.f, 1.f); FFramePackage InterpFramePackage; InterpFramePackage.Character = OlderFrame.Character; InterpFramePackage.Time = HitTime; for (const auto& YoungerPair : YoungerFrame.HitBoxInfo) { const FName& BoxInfoName = YoungerPair.Key; const FBoxInformation& OlderBox = OlderFrame.HitBoxInfo[BoxInfoName]; const FBoxInformation& YoungerBox = YoungerPair.Value;
FBoxInformation InterpBoxInfo; InterpBoxInfo.Location = FMath::VInterpTo(OlderBox.Location, YoungerBox.Location, 1.f, InterpFraction); InterpBoxInfo.Rotation = FMath::RInterpTo(OlderBox.Rotation, YoungerBox.Rotation, 1.f, InterpFraction); InterpBoxInfo.BoxExtent = YoungerBox.BoxExtent;
InterpFramePackage.HitBoxInfo.Add(BoxInfoName, InterpBoxInfo); } return InterpFramePackage; }
FServerSideRewindResult ULagCompensationComponent::ConfirmHit(const FFramePackage& Package, AMultiplayerTPSCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation) { if (!HitCharacter) { 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) { ResetBoxes(HitCharacter, CurrentFrame); EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics); return FServerSideRewindResult{ true,true }; } else { for (auto& HitBoxPair : HitCharacter->HitCollisionBoxes) { if (HitBoxPair.Value) { HitBoxPair.Value->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); HitBoxPair.Value->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block); } } World->LineTraceSingleByChannel( ConfirmHitResult, TraceStart, TraceEnd, ECC_HitBox ); if (ConfirmHitResult.bBlockingHit) { ResetBoxes(HitCharacter, CurrentFrame); EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics); return FServerSideRewindResult{ true,false }; } } }
ResetBoxes(HitCharacter, CurrentFrame); EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics); return FServerSideRewindResult{ false,false }; }
|
一些辅助函数。
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
| void ULagCompensationComponent::CacheBoxPositions(AMultiplayerTPSCharacter* HitCharacter, FFramePackage& OutFramePackage) { if (!HitCharacter) { return; } for (auto& HitBoxPair : Character->HitCollisionBoxes) { if (HitBoxPair.Value) { FBoxInformation BoxInfo; BoxInfo.Location = HitBoxPair.Value->GetComponentLocation(); BoxInfo.Rotation = HitBoxPair.Value->GetComponentRotation(); BoxInfo.BoxExtent = HitBoxPair.Value->GetScaledBoxExtent(); OutFramePackage.HitBoxInfo.Add(HitBoxPair.Key, BoxInfo); } } }
void ULagCompensationComponent::MoveBoxes(AMultiplayerTPSCharacter* HitCharacter, const FFramePackage& Package) { if (!HitCharacter) { return; } for (auto& HitBoxPair : Character->HitCollisionBoxes) { if (HitBoxPair.Value) { HitBoxPair.Value->SetWorldLocation(Package.HitBoxInfo[HitBoxPair.Key].Location); HitBoxPair.Value->SetWorldRotation(Package.HitBoxInfo[HitBoxPair.Key].Rotation); } } }
void ULagCompensationComponent::ResetBoxes(AMultiplayerTPSCharacter* HitCharacter, const FFramePackage& Package) { if (!HitCharacter) { return; } for (auto& HitBoxPair : Character->HitCollisionBoxes) { if (HitBoxPair.Value) { HitBoxPair.Value->SetWorldLocation(Package.HitBoxInfo[HitBoxPair.Key].Location); HitBoxPair.Value->SetWorldRotation(Package.HitBoxInfo[HitBoxPair.Key].Rotation); HitBoxPair.Value->SetCollisionEnabled(ECollisionEnabled::NoCollision); } } }
void ULagCompensationComponent::EnableCharacterMeshCollision(AMultiplayerTPSCharacter* HitCharacter, ECollisionEnabled::Type CollisionEnable) { if (HitCharacter && HitCharacter->GetMesh()) { HitCharacter->GetMesh()->SetCollisionEnabled(CollisionEnable); } }
|
对于霰弹枪的倒带,思想和即时命中武器是差不多的,但是因为可能会命中多个角色,需要做一些调整。
声明一个新的结构体,用来保存霰弹枪的命中结果。
1 2 3 4 5 6 7
| struct FShotgunServerSideRewindResult { GENERATED_BODY()
TMap<AMultiplayerTPSCharacter*, uint32> HeadShots; TMap<AMultiplayerTPSCharacter*, uint32> BodyShots; };
|
在进行检测的时候对所有的命中目标进行倒带并检测,返回检测结果。
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
| FShotgunServerSideRewindResult ULagCompensationComponent::ShotgunServerSideRewind(const TArray<AMultiplayerTPSCharacter*>& HitCharacters, const FVector_NetQuantize& TraceStart, const TArray<FVector_NetQuantize>& HitLocations, float HitTime) { TArray<FFramePackage> FramesToCheck; for (AMultiplayerTPSCharacter* HitCharacter : HitCharacters) { FramesToCheck.Add(GetFrameToCheck(HitCharacter, HitTime)); }
return ShotgunConfirmHit(FramesToCheck, TraceStart, HitLocations); }
FShotgunServerSideRewindResult ULagCompensationComponent::ShotgunConfirmHit(const TArray<FFramePackage>& FramePackages, const FVector_NetQuantize& TraceStart, const TArray<FVector_NetQuantize>& HitLocations) { for (auto& Frame : FramePackages) { if (!Frame.Character) { return FShotgunServerSideRewindResult(); } } FShotgunServerSideRewindResult ShotgunResult; TArray<FFramePackage> CurrentFrames; for (auto& Frame : FramePackages) { FFramePackage CurrentFrame; CurrentFrame.Character = Frame.Character; CacheBoxPositions(Frame.Character, CurrentFrame); MoveBoxes(Frame.Character, Frame); EnableCharacterMeshCollision(Frame.Character, ECollisionEnabled::NoCollision); CurrentFrames.Add(CurrentFrame); }
for (auto& Frame : FramePackages) { UBoxComponent* HeadBox = Frame.Character->HitCollisionBoxes[FName("head")]; HeadBox->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); HeadBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block); }
UWorld* World = GetWorld(); for (auto& HitLocation : HitLocations) { FHitResult ConfirmHitResult; const FVector TraceEnd = TraceStart + (HitLocation - TraceStart) * 1.25f; if (World) { World->LineTraceSingleByChannel( ConfirmHitResult, TraceStart, TraceEnd, ECollisionChannel::ECC_Visibility ); AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(ConfirmHitResult.GetActor()); if (MultiplayerTPSCharacter) { if (ShotgunResult.HeadShots.Contains(MultiplayerTPSCharacter)) { ++ShotgunResult.HeadShots[MultiplayerTPSCharacter]; } else { ShotgunResult.HeadShots.Emplace(MultiplayerTPSCharacter, 1); } } } }
for (auto& Frame : FramePackages) { for (auto& HitBoxPair : Frame.Character->HitCollisionBoxes) { if (HitBoxPair.Value) { HitBoxPair.Value->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); HitBoxPair.Value->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block); } } UBoxComponent* HeadBox = Frame.Character->HitCollisionBoxes[FName("head")]; HeadBox->SetCollisionEnabled(ECollisionEnabled::NoCollision); } for (auto& HitLocation : HitLocations) { FHitResult ConfirmHitResult; const FVector TraceEnd = TraceStart + (HitLocation - TraceStart) * 1.25f;
if (World) { World->LineTraceSingleByChannel( ConfirmHitResult, TraceStart, TraceEnd, ECollisionChannel::ECC_Visibility ); AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(ConfirmHitResult.GetActor()); if (MultiplayerTPSCharacter) { if (ShotgunResult.BodyShots.Contains(MultiplayerTPSCharacter)) { ++ShotgunResult.BodyShots[MultiplayerTPSCharacter]; } else { ShotgunResult.BodyShots.Emplace(MultiplayerTPSCharacter, 1); } } } }
for (auto& Frame : CurrentFrames) { ResetBoxes(Frame.Character, Frame); EnableCharacterMeshCollision(Frame.Character, ECollisionEnabled::QueryAndPhysics); } return ShotgunResult; }
|
在射击时调用Server 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 33 34 35
| TMap<AMultiplayerTPSCharacter*, uint32> HitMap; for (uint32 i = 0; i < NumberOfPellets; ++i) { FHitResult FireHit; WeaponTraceHit(Start, HitTarget, FireHit); HitResults.Add(FireHit); HitEnds.Add(HitEnd);
AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(FireHit.GetActor()); if (MultiplayerTPSCharacter && InstigatorController) { if (HitMap.Contains(MultiplayerTPSCharacter)) { ++HitMap[MultiplayerTPSCharacter]; } else { HitMap.Emplace(MultiplayerTPSCharacter, 1); } } } TArray<AMultiplayerTPSCharacter*> HitCharacters; for (auto& HitPair : HitMap) { if (HitPair.Key && InstigatorController) { HitCharacters.Add(HitPair.Key); } }
MultiplayerTPSOwnerCharacter = MultiplayerTPSOwnerCharacter ? MultiplayerTPSOwnerCharacter : Cast<AMultiplayerTPSCharacter>(OwnerPawn); MultiplayerTPSOwnerController = MultiplayerTPSOwnerController ? MultiplayerTPSOwnerController : Cast<AMPTPSPlayerController>(InstigatorController); if (MultiplayerTPSOwnerCharacter && MultiplayerTPSOwnerController && MultiplayerTPSOwnerCharacter->GetLagCompensation() && MultiplayerTPSOwnerCharacter->IsLocallyControlled()) { MultiplayerTPSOwnerCharacter->GetLagCompensation()->ServerShotgunServerScoreRequest( HitCharacters, Start, HitEnds, MultiplayerTPSOwnerController->GetServerTime() - MultiplayerTPSOwnerController->SingleTripTime ); }
|
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 ULagCompensationComponent::ServerShotgunServerScoreRequest_Implementation(const TArray<AMultiplayerTPSCharacter*>& HitCharacters, const FVector_NetQuantize& TraceStart, const TArray<FVector_NetQuantize>& HitLocations, float HitTime) { FShotgunServerSideRewindResult Confirm = ShotgunServerSideRewind(HitCharacters, TraceStart, HitLocations, HitTime);
for (auto& HitCharacter : HitCharacters) { if (!HitCharacter || !Character || !Character->GetEquippedWeapon()) { continue; } float TotalDamage = 0.f; if (Confirm.HeadShots.Contains(HitCharacter)) { float HeadShotDamage = Confirm.HeadShots[HitCharacter] * Character->GetEquippedWeapon()->GetDamage(); TotalDamage += HeadShotDamage; } if (Confirm.BodyShots.Contains(HitCharacter)) { float BodyDamage = Confirm.BodyShots[HitCharacter] * Character->GetEquippedWeapon()->GetDamage(); TotalDamage += BodyDamage; } UGameplayStatics::ApplyDamage( HitCharacter, TotalDamage, Character->Controller, Character->GetEquippedWeapon(), UDamageType::StaticClass() ); } }
|
最后是弹道武器的服务器倒带。UE
引擎自带了投射物路径预测的功能,这使得弹道武器的服务器倒带实现起来容易了很多。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| FPredictProjectilePathParams PathParams; PathParams.bTraceWithChannel = true; PathParams.bTraceWithCollision = true; PathParams.DrawDebugTime = 5.f; PathParams.DrawDebugType = EDrawDebugTrace::ForDuration; PathParams.LaunchVelocity = GetActorForwardVector() * InitialSpeed; PathParams.MaxSimTime = 4.f; PathParams.ProjectileRadius = 5.f; PathParams.SimFrequency = 30.f;PathParams.StartLocation = GetActorLocation(); PathParams.TraceChannel = ECollisionChannel::ECC_Visibility; PathParams.ActorsToIgnore.Add(this);
FPredictProjectilePathResult PathResult; UGameplayStatics::PredictProjectilePath(this, PathParams, PathResult);
|

在投射物基类中声明用于服务器倒带的变量:
1 2 3 4 5 6
| bool bUseServerSideRewind = false; FVector_NetQuantize TraceStart; FVector_NetQuantize100 InitialVelocity;
UPROPERTY(EditAnywhere) float InitialSpeed = 15000.f;
|
在构造函数中将投射物运动组件的初速度设为我们自己的变量:
1 2
| ProjectileMovementComponent->InitialSpeed = InitialSpeed; ProjectileMovementComponent->MaxSpeed = InitialSpeed;
|
这里有一个问题。我需要初速度和最大速度这两个变量来进行服务器倒带,但是投射物运动组件中使用的并不是这两个变量,而是直接在蓝图中由我们设置的。如果我们手动更改InitialSpeed
,蓝图中的初速度和最大速度也是不会自动变化的。为了使其自动跟随InitialSpeed
自动变化,这里使用了一个黑科技。
重写下面这个函数,并使用#if
进行标记。
1 2 3
| #if WITH_EDITOR virtual void PostEditChangeProperty(struct FPropertyChangedEvent& Event) override; #endif
|
在函数定义中同样使用#if
进行定义,这样在蓝图中更改InitialSpeed
的值,初速度和最大速度就会跟着改变了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #if WITH_EDITOR void AProjectileBullet::PostEditChangeProperty(FPropertyChangedEvent& Event) { Super::PostEditChangeProperty(Event);
FName PropertyName = Event.Property ? Event.Property->GetFName() : NAME_None; if (PropertyName == GET_MEMBER_NAME_CHECKED(AProjectileBullet, InitialSpeed)) { if (ProjectileMovementComponent) { ProjectileMovementComponent->InitialSpeed = InitialSpeed; ProjectileMovementComponent->MaxSpeed = InitialSpeed; } } } #endif
|
来到投射物的生成部分。在弹道武器中再加一个用于服务器倒带的投射物类,它的唯一区别是不启用复制。
1 2 3 4 5
| UPROPERTY(EditAnywhere) TSubclassOf<class AProjectile> ProjectileClass;
UPROPERTY(EditAnywhere) TSubclassOf<AProjectile> ServerSideRewindProjectileClass;
|
重写弹道武器的开火逻辑,根据是否是服务器,是否启用服务器倒带以及是否是本地控制来决定生成投射物的逻辑。
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
| void AProjectileWeapon::Fire(const FVector& HitTarget) { Super::Fire(HitTarget);
APawn* InstigatorPawn = Cast<APawn>(GetOwner()); const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName(FName("MuzzleFlash")); UWorld* World = GetWorld(); if (MuzzleFlashSocket && InstigatorPawn && World) { FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh()); FVector ToTarget = HitTarget - SocketTransform.GetLocation(); FRotator TargetRotatrion = ToTarget.Rotation();
FActorSpawnParameters SpawnParams; SpawnParams.Owner = GetOwner(); SpawnParams.Instigator = InstigatorPawn;
AProjectile* SpawnedProjectile = nullptr; if (bUseServerSideRewind) { if (InstigatorPawn->HasAuthority()) { if (InstigatorPawn->IsLocallyControlled()) { SpawnedProjectile = World->SpawnActor<AProjectile>(ProjectileClass,SocketTransform.GetLocation(),TargetRotatrion,SpawnParams); SpawnedProjectile->bUseServerSideRewind = false; SpawnedProjectile->Damage = Damage; } else { SpawnedProjectile = World->SpawnActor<AProjectile>(ServerSideRewindProjectileClass, SocketTransform.GetLocation(), TargetRotatrion, SpawnParams); SpawnedProjectile->bUseServerSideRewind = false; } } else { if (InstigatorPawn->IsLocallyControlled()) { SpawnedProjectile = World->SpawnActor<AProjectile>(ProjectileClass, SocketTransform.GetLocation(), TargetRotatrion, SpawnParams); SpawnedProjectile->bUseServerSideRewind = true; SpawnedProjectile->TraceStart = SocketTransform.GetLocation(); SpawnedProjectile->InitialVelocity = SpawnedProjectile->GetActorForwardVector() * SpawnedProjectile->InitialSpeed; SpawnedProjectile->Damage = Damage; } else { SpawnedProjectile = World->SpawnActor<AProjectile>(ServerSideRewindProjectileClass, SocketTransform.GetLocation(), TargetRotatrion, SpawnParams); SpawnedProjectile->bUseServerSideRewind = false; } } } else { if (InstigatorPawn->HasAuthority()) { SpawnedProjectile = World->SpawnActor<AProjectile>(ProjectileClass, SocketTransform.GetLocation(), TargetRotatrion, SpawnParams); SpawnedProjectile->bUseServerSideRewind = false; SpawnedProjectile->Damage = Damage; } } } }
|
最后就是倒带的具体实现了。当子弹命中时,如果是在服务器并且没有使用服务器倒带或者是服务器自己发射的子弹,直接造成伤害,否则如果是本地发射的子弹并且启用的服务器倒带,就调用Server 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
| void AProjectileBullet::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit) { AMultiplayerTPSCharacter* OwnerCharacter = Cast<AMultiplayerTPSCharacter>(GetOwner()); if (OwnerCharacter) { AMPTPSPlayerController* OwnerController = Cast<AMPTPSPlayerController>(OwnerCharacter->Controller); if (OwnerController) { if (OwnerCharacter->HasAuthority() && (!bUseServerSideRewind || OwnerCharacter->IsLocallyControlled())) { UGameplayStatics::ApplyDamage(OtherActor, Damage, OwnerController, this, UDamageType::StaticClass()); Super::OnHit(HitComp, OtherActor, OtherComp, NormalImpulse, Hit); return; } AMultiplayerTPSCharacter* HitCharacter = Cast<AMultiplayerTPSCharacter>(OtherActor); if (bUseServerSideRewind && OwnerCharacter->GetLagCompensation() && OwnerCharacter->IsLocallyControlled() && HitCharacter) { ServerProjectileScoreRequest( HitCharacter, TraceStart, InitialVelocity, OwnerController->GetServerTime() - OwnerController->SingleTripTime ); } } } Super::OnHit(HitComp, OtherActor, OtherComp, NormalImpulse, Hit); }
|
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
| void ULagCompensationComponent::ServerProjectileScoreRequest_Implementation(AMultiplayerTPSCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize100& InitialVelocity, float HitTime) { FServerSideRewindResult Confirm = ProjectileServerSideRewind(HitCharacter, TraceStart, InitialVelocity, HitTime); if (Character && HitCharacter && Confirm.bHitConfirmed) { UGameplayStatics::ApplyDamage( HitCharacter, Character->GetEquippedWeapon()->GetDamage(), Character->Controller, Character->GetEquippedWeapon(), UDamageType::StaticClass() ); } }
FServerSideRewindResult ULagCompensationComponent::ProjectileServerSideRewind(AMultiplayerTPSCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& InitialVelocity, float HitTime) { FFramePackage FrameToCheck = GetFrameToCheck(HitCharacter, HitTime); return ProjectileConfirmHit(FrameToCheck, HitCharacter, TraceStart, InitialVelocity, HitTime); }
FServerSideRewindResult ULagCompensationComponent::ProjectileConfirmHit(const FFramePackage& Package, AMultiplayerTPSCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& InitialVelocity, float HitTime) { if (!HitCharacter) { 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);
FPredictProjectilePathParams PathParams; PathParams.bTraceWithCollision = true; PathParams.DrawDebugTime = MaxRecordTime; PathParams.LaunchVelocity = InitialVelocity; PathParams.StartLocation = TraceStart; PathParams.ProjectileRadius = 5.f; PathParams.SimFrequency = 15.f; PathParams.TraceChannel = ECC_HitBox; PathParams.ActorsToIgnore.Add(GetOwner()); PathParams.DrawDebugTime = 5.f; PathParams.DrawDebugType = EDrawDebugTrace::ForDuration;
FPredictProjectilePathResult PathResult; UGameplayStatics::PredictProjectilePath(this, PathParams, PathResult);
if (PathResult.HitResult.bBlockingHit) { ResetBoxes(HitCharacter, CurrentFrame); EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics); return FServerSideRewindResult{ true,true }; } else { for (auto& HitBoxPair : HitCharacter->HitCollisionBoxes) { if (HitBoxPair.Value) { HitBoxPair.Value->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); HitBoxPair.Value->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block); } }
UGameplayStatics::PredictProjectilePath(this, PathParams, PathResult); if (PathResult.HitResult.bBlockingHit) { ResetBoxes(HitCharacter, CurrentFrame); EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics); return FServerSideRewindResult{ true,false }; } }
ResetBoxes(HitCharacter, CurrentFrame); EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics); return FServerSideRewindResult{ false,false }; }
|
4.4 禁用服务器倒带
之前说过,当玩家的延迟高到一定程度时,使用服务器倒带会为其它低延迟玩家带来较差的游戏体验,因此当玩家延迟高到一定程度的时候应该禁用服务器倒带。
首先这个变量应该是有条件复制的,因为只要控制角色的客户端才需要这个变量。
1
| DOREPLIFETIME_CONDITION(AWeapon, bUseServerSideRewind, COND_OwnerOnly);
|
之前在控制器中实现了定期检查Ping,现在检查Ping后调用Server RPC
,根据ping的高低决定是否启用服务器倒带。
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
| void AMPTPSPlayerController::CheckPing(float DeltaTime) { HighPingRunningTime += DeltaTime; if (HighPingRunningTime > CheckPingFrequency) { PlayerState = PlayerState == nullptr ? GetPlayerState<AMultiplayerTPSPlayerState>() : PlayerState; if (PlayerState) { if (PlayerState->GetCompressedPing() * 4 > HighPingThreshold) { HighPingWarning(); PingAnimationRunningTime = 0.f; ServerReportPingStatus(true); } else { ServerReportPingStatus(false); } } HighPingRunningTime = 0.f; } if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation && MultiplayerTPSHUD->CharacterOverlay->IsAnimationPlaying(MultiplayerTPSHUD->CharacterOverlay->HighPingAnimation)) { PingAnimationRunningTime += DeltaTime; if (PingAnimationRunningTime > HighPingDuration) { StopHighPingWarning(); } } }
void AMPTPSPlayerController::ServerReportPingStatus(bool bHighPing) { AMultiplayerTPSCharacter* Character = Cast<AMultiplayerTPSCharacter>(Owner); if (Character && Character->GetEquippedWeapon()) { Character->GetEquippedWeapon()->bUseServerSideRewind = !bHighPing; }
|
5.讨论和总结
完成了上面的部分后,出于好奇,我找到了两篇Valve
的文章,介绍了起源引擎中使用的多人游戏网络技术。
Source Multiplayer Networking - Valve Developer Community
服务器游戏内协议设计与优化 - Valve 开发者社区
事实上,我发现文章中的重点正是本文中介绍的两种技术,除此之外,还有在两个快照之间进行插值等使游戏体验更加丝滑的手段。(另外,我看到文中介绍的服务器倒带在实现中确实是将所有玩家进行回溯再进行检测的,所以我的想法算是歪打正着了?)然而,这已经是二十多年前的文章了,也就是说,我目前所了解了知识仍然只是一个基础而已,而在这二十多年里肯定也有相当多的相关技术手段诞生。前路漫长啊。
在完成延迟补偿部分后,可以说本项目的重点部分已经结束了。目前项目中的玩法部分还没有实现,但是秋招已经开始一段时间了,我不能等到所有工作都完全准备好再开始行动,毕竟哪里会有真正的准备好呢?总之,玩法部分的开发会缓慢进行,至于现在,先祝我自己好运吧。
朝焼けた色 空を舞って
朝晖之下 在空中起舞
何を願うかなんて愚問だ
竟然可笑到问我愿望为何
大人になって忘れていた
成为大人后就已经忘了
君を映す目が邪魔だ
映照出你的这双眼睛 竟是累赘
ずっと下で花が鳴った
千里之下鲜花低唱
大きな火の花が鳴った
巨大的火焰响彻夜空
音だけでも泣いてしまう、だなんて憶う
「只听声音就已泫然欲泣」 记得曾有这番情景
そんな夏を聞いた
听到了如此声响的夏天
——ヨルシカ《靴の花火》