【UE5】多人联机TPS游戏开发(三) —— 射击游戏核心

这一部分开始接近一个射击游戏的核心功能,包括HUD、开火、弹药,以及各种武器的实现等。

同时,在这一部分中也会开始通过Gameplay框架来实现游戏玩法的管理,以及使愈发复杂的游戏功能变得更加有条理。之前只是通过官方文档死记硬背,完全无法理解Gameplay框架到底是干什么的,而在实际上手之后才发现,Gameplay框架只是一些引擎提供的类,只要先了解这些类大概的作用,在有实际需求的时候自然就知道要用哪些类了。这个时候再去学习就比较轻松了。

1.HUD

在本项目中,主要通过HUD来管理各种UI,而HUD类又由玩家控制器来进行控制。

创建一个控件类CharacterOverlay用来显示玩家的主UI。从最基础的血量显示开始。

包含两个和控件蓝图绑定的元素。

1
2
3
4
5
UPROPERTY(meta = (BindWidget))
class UProgressBar* HealthBar;

UPROPERTY(meta = (BindWidget))
class UTextBlock* HealthText;

在HUD类中将其添加到视口:

1
2
3
4
5
//MultiplayerTPSHUD.h
UPROPERTY(EditAnywhere, Category = "Player Stats")
TSubclassOf<class UUserWidget> CharacterOverlayClass;

class UCharacterOverlay* CharacterOverlay;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//MultiplayerTPSHUD.cpp
void AMultiplayerTPSHUD::BeginPlay()
{
Super::BeginPlay();
AddCharacterOverlay();
}

void AMultiplayerTPSHUD::AddCharacterOverlay()
{
APlayerController* PlayerController = GetOwningPlayerController();
if (PlayerController && CharacterOverlayClass) {
CharacterOverlay = CreateWidget<UCharacterOverlay>(PlayerController, CharacterOverlayClass);
CharacterOverlay->AddToViewport();
}
}

创建自己的控制器类MPTPSPlayerController(我发誓以后再也不取这么长的项目名字了,类名还能超过字数显示的),在其中初始化HUD,并定义更新血量UI的函数:

1
2
//MPTPSPlayerController.h
class AMultiplayerTPSHUD* MultiplayerTPSHUD;
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
//MPTPSPlayerController.cpp
void AMPTPSPlayerController::BeginPlay()
{
Super::BeginPlay();
MultiplayerTPSHUD = Cast<AMultiplayerTPSHUD>(GetHUD());
}

void AMPTPSPlayerController::SetHUDHealth(float Health, float MaxHealth)
{
MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD;
if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->HealthBar && MultiplayerTPSHUD->CharacterOverlay->HealthText) {
const float HealthPercent = Health / MaxHealth;
MultiplayerTPSHUD->CharacterOverlay->HealthBar->SetPercent(HealthPercent);
FString HealthText = FString::Printf(TEXT("%d/%d"), FMath::CeilToInt(Health), FMath::CeilToInt(MaxHealth));
MultiplayerTPSHUD->CharacterOverlay->HealthText->SetText(FText::FromString(HealthText));
}
}

void AMPTPSPlayerController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);

AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(InPawn);
if (MultiplayerTPSCharacter) {
SetHUDHealth(MultiplayerTPSCharacter->GetHealth(), MultiplayerTPSCharacter->GetMaxHealth());
}
}

在角色类中定义血量相关的变量和处理函数。我之前想过血量作为角色的属性,为什么不放在“玩家状态”中?答案似乎是血量作为角色的核心属性,需要进行快速的同步,游戏状态的同步速度较慢无法达到要求,而角色的属性复制则可以满足同步需求。

1
2
3
4
5
6
7
8
9
10
11
//MultiplayerTPSCharacter.h
UPROPERTY(EditAnywhere)
float MaxHealth = 100.f;

UPROPERTY(ReplicatedUsing = OnRep_Health, VisibleAnywhere, Category = "Player Stats")
float Health = 100.f;

UFUNCTION()
void OnRep_Health();

class AMPTPSPlayerController* PlayerController;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//MultiplayerTPSCharacter.cpp
void AMultiplayerTPSCharacter::BeginPlay()
{
Super::BeginPlay();

PlayerController = Cast<AMPTPSPlayerController>(Controller);
if (PlayerController) {
PlayerController->SetHUDHealth(Health, MaxHealth);
}
...
}

void AMultiplayerTPSCharacter::UpdateHUDHealth()
{
PlayerController = PlayerController == nullptr ? Cast<AMPTPSPlayerController>(Controller) : PlayerController;
if (PlayerController) {
PlayerController->SetHUDHealth(Health, MaxHealth);
}
}

这样就可以直接在角色类中通过角色=》玩家控制器=》HUD=》CharacterOverlay的链条来控制血条的更新。

HUD的更新逻辑都差不多,主要还是要细心,在值可能更新的地方都要更新HUD,之后的HUD部分就不一一列举了。

2.命中伤害

Projectile类派生一个ProjectileBullet类,重写命中函数,对命中的角色应用伤害并更新UI。

1
2
3
4
5
6
7
8
9
10
11
12
void AProjectileBullet::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (OwnerCharacter) {
AController* OwnerController = OwnerCharacter->Controller;
if (OwnerController) {
UGameplayStatics::ApplyDamage(OtherActor, Damage, OwnerController, this, UDamageType::StaticClass());
}
}

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
//MultiplayerTPSCharacter.cpp
void AMultiplayerTPSCharacter::BeginPlay()
{
Super::BeginPlay();

UpdateHUDHealth();
if (HasAuthority()) {
OnTakeAnyDamage.AddDynamic(this, &AMultiplayerTPSCharacter::ReceiveDamage);
}
if (AttachedGrenade) {
AttachedGrenade->SetVisibility(false);
}
}

void AMultiplayerTPSCharacter::ReceiveDamage(AActor* DamagedActor, float Damage, const UDamageType* DamageType, AController* InstigatorController, AActor* DamageCauser)
{
//UE_LOG(LogTemp, Warning, TEXT("Receive damage."));
Health = FMath::Clamp(Health - Damage, 0.f, MaxHealth);
UpdateHUDHealth();
PlayHitReactMontage();
}

之前通过Multicast RPC实现了受击时的动画,但是RPC的和开销网络速度不如变量复制,所以现在可以通过血量的复制来实现受击动画。

1
2
3
4
5
void AMultiplayerTPSCharacter::OnRep_Health()
{
UpdateHUDHealth();
PlayHitReactMontage();
}

3.淘汰

淘汰是对游戏中任意玩家进行的操作,很适合放在游戏模式中。

创建自己的游戏模式类,在其中定义淘汰玩家函数和重生玩家函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//MultiplayerTPSGameMode.h
void AMultiplayerTPSGameMode::PlayerEliminated(AMultiplayerTPSCharacter* ElimmedCharacter, AMPTPSPlayerController* VictimController, AMPTPSPlayerController* AttackerController)
{
if (ElimmedCharacter)
{
ElimmedCharacter->Elim();
}
}

void AMultiplayerTPSGameMode::RequestRespawn(ACharacter* ElimmedCharacter, AController* ElimmedController)
{
if (ElimmedCharacter) {
ElimmedCharacter->Reset();
ElimmedCharacter->Destroy();
}
if (ElimmedController) {
TArray<AActor*> PlayerStarts;
UGameplayStatics::GetAllActorsOfClass(this, APlayerStart::StaticClass(), PlayerStarts);
int32 Selection = FMath::RandRange(0, PlayerStarts.Num() - 1);
RestartPlayerAtPlayerStart(ElimmedController, PlayerStarts[Selection]);
}
}

在角色类中,当受到伤害时,如果生命值变为0就通过游戏模式将其淘汰,并通过多播RPC播放淘汰动画。当淘汰时启用一个计时器,其回调函数会调用游戏模式的重生玩家函数来复活玩家。

1
2
3
4
5
6
7
//MultiplayerTPSCharacter.h
void Elim();

UFUNCTION(NetMulticast, Reliable)
void MulticastElim();

bool bElimmed = 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
//MultiplayerTPSCharacter.cpp
void AMultiplayerTPSCharacter::ReceiveDamage(AActor* DamagedActor, float Damage, const UDamageType* DamageType, AController* InstigatorController, AActor* DamageCauser)
{
Health = FMath::Clamp(Health - Damage, 0.f, MaxHealth);
OnRep_Health();
//UpdateHUDHealth();
//PlayHitReactMontage();
if (Health == 0.f) {
AMultiplayerTPSGameMode* MultiplayerTPSGameMode = GetWorld()->GetAuthGameMode<AMultiplayerTPSGameMode>();
if (MultiplayerTPSGameMode) {
PlayerController = PlayerController ? PlayerController : Cast<AMPTPSPlayerController>(Controller);
AMPTPSPlayerController* AttackerController = Cast<AMPTPSPlayerController>(InstigatorController);
MultiplayerTPSGameMode->PlayerEliminated(this, PlayerController, AttackerController);
}
}

}

void AMultiplayerTPSCharacter::MulticastElim_Implementation()
{
bElimmed = true;
PlayElimMontage();
}

void AMultiplayerTPSGameMode::PlayerEliminated(AMultiplayerTPSCharacter* ElimmedCharacter, AMPTPSPlayerController* VictimController, AMPTPSPlayerController* AttackerController)
{
if (ElimmedCharacter)
{
ElimmedCharacter->Elim();
}
}

void AMultiplayerTPSCharacter::Elim()
{
MulticastElim();
GetWorldTimerManager().SetTimer(
ElimTimer,
this,
&AMultiplayerTPSCharacter::ElimTimerFinished,
ElimDelay
);
}

void AMultiplayerTPSCharacter::ElimTimerFinished()
{
AMultiplayerTPSGameMode* MultiplayerTPSGameMode = GetWorld()->GetAuthGameMode<AMultiplayerTPSGameMode>();
if (MultiplayerTPSGameMode) {
MultiplayerTPSGameMode->RequestRespawn(this, Controller);
}
}

在角色淘汰时为角色添加一个无人机将角色溶解的效果,可以增加游戏性。

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
//Elim Bot
UPROPERTY(EditAnywhere)
UParticleSystem* ElimBotEffect;

UPROPERTY(VisibleAnywhere)
UParticleSystemComponent* ElimBotComponent;

UPROPERTY(EditAnywhere)
class USoundCue* ElimBotSound;

//Spawn elim bot
if (ElimBotEffect) {
FVector ElimBotSpawnPoint(GetActorLocation().X, GetActorLocation().Y, GetActorLocation().Z + 200.f);
ElimBotComponent = UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
ElimBotEffect,
ElimBotSpawnPoint,
GetActorRotation()
);
}
if (ElimBotSound) {
UGameplayStatics::SpawnSoundAtLocation(
this,
ElimBotSound,
GetActorLocation()
);
}

void AMultiplayerTPSCharacter::Destroyed()
{
Super::Destroy();
if (ElimBotComponent) {
ElimBotComponent->DestroyComponent();
}
}

//溶解效果
UPROPERTY(VisibleAnywhere)
UTimelineComponent* DissolveTimeline;
FOnTimelineFloat DissolveTrack;

UPROPERTY(EditAnywhere)
UCurveFloat* DissolveCurve;

UFUNCTION()
void UpdateDissolveMaterial(float DissolveValue);

void StartDissolve();

//实时改变的动态材质实例
UPROPERTY(VisibleAnywhere, Category = "Elim")
UMaterialInstanceDynamic* DynamicDissolveMaterialInstance;

//蓝图中设置的动态材质实例
UPROPERTY(EditAnywhere, Category = "Elim")
UMaterialInstance* DissolveMaterialInstance;

两个时间轴相关的函数:
void AMultiplayerTPSCharacter::UpdateDissolveMaterial(float DissolveValue)
{
if (DynamicDissolveMaterialInstance) {
DynamicDissolveMaterialInstance->SetScalarParameterValue(TEXT("Dissolve"), DissolveValue);
}
}

void AMultiplayerTPSCharacter::StartDissolve()
{
DissolveTrack.BindDynamic(this, &AMultiplayerTPSCharacter::UpdateDissolveMaterial);
if (DissolveCurve && DissolveTimeline) {
DissolveTimeline->AddInterpFloat(DissolveCurve, DissolveTrack);
DissolveTimeline->Play();
}
}

void AMultiplayerTPSCharacter::MulticastElim_Implementation()
{
bElimmed = true;
PlayElimMontage();

if (DissolveMaterialInstance) {
DynamicDissolveMaterialInstance = UMaterialInstanceDynamic::Create(DissolveMaterialInstance, this);
GetMesh()->SetMaterial(0, DynamicDissolveMaterialInstance);
DynamicDissolveMaterialInstance->SetScalarParameterValue(TEXT("Dissolve"), 0.55f);
DynamicDissolveMaterialInstance->SetScalarParameterValue(TEXT("Glow"), 200.f);
}
StartDissolve();
}

当角色淘汰后要丢弃武器,为武器定义丢弃函数。丢弃武器时武器状态会改变,这时要对武器进行一些属性调整。

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
//Weapon.cpp
void AWeapon::Dropped()
{
SetWeaponState(EWeaponState::EWS_Dropped);
FDetachmentTransformRules DetachRules(EDetachmentRule::KeepWorld, true);
WeaponMesh->DetachFromComponent(DetachRules);
SetOwner(nullptr);
}

void AWeapon::OnRep_WeaponState()
{
switch (WeaponState) {
case EWeaponState::EWS_Equipped:
ShowPickWidget(false);
AreaSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
WeaponMesh->SetSimulatePhysics(false);
WeaponMesh->SetEnableGravity(false);
WeaponMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
break;
case EWeaponState::EWS_Dropped:
WeaponMesh->SetSimulatePhysics(true);
WeaponMesh->SetEnableGravity(true);
WeaponMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
break;
}
}

void AWeapon::SetWeaponState(EWeaponState State)
{
WeaponState = State;
switch (WeaponState) {
case EWeaponState::EWS_Equipped:
ShowPickWidget(false);
AreaSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
WeaponMesh->SetSimulatePhysics(false);
WeaponMesh->SetEnableGravity(false);
WeaponMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
break;

case EWeaponState::EWS_Dropped:
if (HasAuthority()) {
AreaSphere->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}
WeaponMesh->SetSimulatePhysics(true);
WeaponMesh->SetEnableGravity(true);
WeaponMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
break;
}
}

记得拾取武器时也要设一下状态。

4.玩家状态

玩家状态是跟踪和记录玩家信息的一个类,存在于每一个客户端中。它不适合用来保存血量、弹药等需要及时同步的变量,而是通常用来保存得分、死亡数等信息。

创建自己的玩家状态类,通过复制来更新和同步分数及分数的HUD。其中Score是玩家状态自带的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void AMultiplayerTPSPlayerState::AddToScore(float ScoreAmount)
{
SetScore(Score + ScoreAmount);
Character = Character ? Character : Cast<AMultiplayerTPSCharacter>(GetPawn());
if (Character) {
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDScore(GetScore());
}
}
}

void AMultiplayerTPSPlayerState::OnRep_Score()
{
Super::OnRep_Score();
Character = Character ? Character : Cast<AMultiplayerTPSCharacter>(GetPawn());
if (Character) {
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDScore(GetScore());
}
}
}
1
2
3
4
5
6
7
8
9
//MPTPSPlayerController.cpp
void AMPTPSPlayerController::SetHUDScore(float Score)
{
MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD;
if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->ScoreAmount) {
FString ScoreText = FString::Printf(TEXT("%d"), FMath::FloorToInt(Score));
MultiplayerTPSHUD->CharacterOverlay->ScoreAmount->SetText(FText::FromString(ScoreText));
}
}

游戏开始时我们要将Score设为0,但是在角色的BeginPlay运行时,游戏状态可能还未初始化导致我们无法访问并对其进行操作,所以定义了一个轮询函数在Tick中调用来初始化游戏状态:

1
2
3
4
5
6
7
8
9
void AMultiplayerTPSCharacter::PollInit()
{
if (!MultiplayerTPSPlayerState) {
MultiplayerTPSPlayerState = GetPlayerState<AMultiplayerTPSPlayerState>();
if (MultiplayerTPSPlayerState) {
MultiplayerTPSPlayerState->AddToScore(0.f);
}
}
}

接下来是淘汰数。

1
2
3
4
5
6
//MultiplayerTPSPlayerState.h
UPROPERTY(ReplicatedUsing = OnRep_Defeats)
int32 Defeats;

UFUNCTION()
virtual void OnRep_Defeats();
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
void AMultiplayerTPSPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME(AMultiplayerTPSPlayerState, Defeats);
}

void AMultiplayerTPSPlayerState::AddToDefeats(int32 DefeatsAmount)
{
Defeats += DefeatsAmount;
Character = Character ? Character : Cast<AMultiplayerTPSCharacter>(GetPawn());
if (Character) {
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDDefeats(Defeats);
}
}
}

void AMultiplayerTPSPlayerState::OnRep_Defeats()
{
Character = Character ? Character : Cast<AMultiplayerTPSCharacter>(GetPawn());
if (Character) {
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDDefeats(Defeats);
}
}
}

控制器中的HUD更新函数:

1
2
3
4
5
6
7
8
void AMPTPSPlayerController::SetHUDDefeats(int32 Defeats)
{
MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD;
if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->DefeatsAmount) {
FString DefeatsText = FString::Printf(TEXT("%d"), Defeats);
MultiplayerTPSHUD->CharacterOverlay->DefeatsAmount->SetText(FText::FromString(DefeatsText));
}
}

在游戏模式中,当玩家被淘汰时,攻击者的得分+1,受害者的失败数+1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void AMultiplayerTPSGameMode::PlayerEliminated(AMultiplayerTPSCharacter* ElimmedCharacter, AMPTPSPlayerController* VictimController, AMPTPSPlayerController* AttackerController)
{
AMultiplayerTPSPlayerState* AttackerPlayerState = AttackerController ? Cast<AMultiplayerTPSPlayerState>(AttackerController->PlayerState) : nullptr;
AMultiplayerTPSPlayerState* VictimPlayerState = VictimController ? Cast<AMultiplayerTPSPlayerState>(VictimController->PlayerState) : nullptr;

if (AttackerPlayerState && AttackerPlayerState != VictimPlayerState) {
AttackerPlayerState->AddToScore(1.f);
}
if (VictimController) {
VictimPlayerState->AddToDefeats(1);
}

if (ElimmedCharacter)
{
ElimmedCharacter->Elim();

}
}

5.玩家携带弹药

声明一个表示武器类型的枚举(之后会有更多类型):

1
2
3
4
5
6
UENUM(BlueprintType)
enum class EWeaponType :uint8 {
EWT_AssaultRifle UMETA(Displayname="Assult Rifle"),

EWT_MAX UMETA(Displayname = "DefaultMAX")
};

之前我们用一个整型来表示玩家的携带弹药,而现在有多种武器类型,也就需要多种携带弹药。

然而并不需要为每一种武器类型都创建一个变量。我们可以使用一个TMap来保存所有武器类型对应的携带弹药。TMap的底层实现是哈希表,由于在每台机器上的哈希结果不一定相同,因此TMap是不能复制的。不过我们也不需要对其进行复制,因为备弹的相关处理逻辑都是在服务器上进行的,只需要同步好CarriedAmmo就行了。

1
2
3
4
5
6
7
UPROPERTY(ReplicatedUsing = OnRep_CarriedAmmo)
int32 CarriedAmmo;

UFUNCTION()
void OnRep_CarriedAmmo();

TMap<EWeaponType, int32> CarriedAmmoMap;

在战斗组件中为每一种武器初始化备弹,装备武器时将备弹显示设为装备的武器类型的备弹。

1
2
3
4
5
//Weapon.h
UPROPERTY(EditAnywhere)
EWeaponType WeaponType;

FORCEINLINE EWeaponType GetWeaponType()const { return WeaponType; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//CombatComponent.cpp
void UCombatComponent::InitializeCarriedAmmo()
{
CarriedAmmoMap.Emplace(EWeaponType::EWT_AssaultRifle, StartingARAmmo);
//...更多武器类型
}

void UCombatComponent::EquipWeapon(AWeapon* WeaponToEquip)
{
...
if (CarriedAmmoMap.Contains(EquippedWeapon->GetWeaponType())) {
CarriedAmmo = CarriedAmmoMap[EquippedWeapon->GetWeaponType()];
}
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDCarriedAmmo(CarriedAmmo);
}
...
}

6.换弹

声明一个枚举来表示玩家当前的角色状态:

1
2
3
4
5
6
UENUM(BlueprintType)
enum class ECombatState :uint8 {
ECS_Unoccupied UMETA(DisplayName = "Unoccupied"),
ECS_Reloading UMETA(DisplayName = "Reloading"),
ECS_MAX UMETA(DisplayName = "DefaultMAX")
};

按下Reload键后触发回调函数,调用战斗组件中的换弹函数,只有战斗状态为Unoccupied时才能换弹。换弹只能在服务器上进行,所以调用换弹后要调用Server RPC。服务器上实现真正的换弹逻辑。这样就完成了服务器上的换弹逻辑,之后需要进行同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//CombatComponent.cpp
void UCombatComponent::Reload()
{
if (CarriedAmmo > 0 && CombatState == ECombatState::ECS_Unoccupied) {
ServerReload();
}
}

void UCombatComponent::ServerReload_Implementation()
{
if (!Character) {
return;
}
CombatState = ECombatState::ECS_Reloading;
HandleReload();
}

角色根据武器类型跳转到动画蒙太奇的对应片段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//MultiplayerTPSCharacter.cpp
void AMultiplayerTPSCharacter::PlayReloadMontage()
{
if (!Combat || !Combat->EquippedWeapon) {
return;
}

UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
if (AnimInstance && ReloadMontage) {
AnimInstance->Montage_Play(ReloadMontage);
FName SectionName;
switch (Combat->EquippedWeapon->GetWeaponType()) {
case EWeaponType::EWT_AssaultRifle:
SectionName = FName("Rifle");
break;
}
AnimInstance->Montage_JumpToSection(SectionName);
}
}

战斗组件中持有当前状态,设为复制,回调函数中根据战斗状态进行同步操作,如当战斗状态变为Reloading时播放换弹动画:

1
2
3
4
5
6
//CombatComponent.h
UPROPERTY(ReplicatedUsing = OnRep_CombatState)
ECombatState CombatState = ECombatState::ECS_Unoccupied;

UFUNCTION()
void OnRep_CombatState();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//CombatComponent.cpp
void UCombatComponent::OnRep_CombatState()
{
switch (CombatState)
{
case ECombatState::ECS_Unoccupied:
if (bFireButtonPressed) {
Fire();
}
break;
case ECombatState::ECS_Reloading:
HandleReload();
break;
case ECombatState::ECS_MAX:
break;
default:
break;
}
}

在换弹的动画蒙太奇结尾加一个通知,调用结束换弹的函数;当可以开火时如果按着开火键,我们希望玩家能立刻开火,所以在换弹结束和战斗状态变为空闲时,如果开火键被按下就开火:

1
2
3
4
5
6
7
void UCombatComponent::FinishReloading()
{
CombatState = ECombatState::ECS_Unoccupied;
if (bFireButtonPressed) {
Fire();
}
}

当换弹的时候不能开火,在判断开火条件时加上:

1
2
3
4
5
bool UCombatComponent::CanFire()
{
if (!EquippedWeapon) return false;
return !EquippedWeapon->IsEmpty() && bCanFire && CombatState == ECombatState::ECS_Unoccupied;
}

接下来就是真正的换弹逻辑了。在战斗组件中定义计算要上多少子弹的函数,当触发结束换弹动画通知的时候执行弹药的更新逻辑。

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
//CombatComponent.cpp
void UCombatComponent::FinishReloading()
{
if (!Character) return;
if (Character->HasAuthority()) {
CombatState = ECombatState::ECS_Unoccupied;
UpdateAmmoValues();
}
if (bFireButtonPressed) {
Fire();
}
}

int32 UCombatComponent::AmountToReload()
{
if (!EquippedWeapon) return 0;
int32 RoomInMag = EquippedWeapon->GetMagCapacity() - EquippedWeapon->GetAmmo();
if (CarriedAmmoMap.Contains(EquippedWeapon->GetWeaponType())) {
int32 AmountCarried = CarriedAmmoMap[EquippedWeapon->GetWeaponType()];
int32 Least = FMath::Min(RoomInMag, AmountCarried);

return FMath::Clamp(RoomInMag, 0, Least);
}

return 0;
}

void UCombatComponent::UpdateAmmoValues()
{
if (!EquippedWeapon) {
return;
}
int32 ReloadAmount = AmountToReload();
if (CarriedAmmoMap.Contains(EquippedWeapon->GetWeaponType())) {
CarriedAmmoMap[EquippedWeapon->GetWeaponType()] -= ReloadAmount;
CarriedAmmo = CarriedAmmoMap[EquippedWeapon->GetWeaponType()];
}
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDCarriedAmmo(CarriedAmmo);
}
EquippedWeapon->AddAmmo(ReloadAmount);
}
1
2
3
4
5
6
//Weapon.cpp
void AWeapon::AddAmmo(int32 AmmoToAdd)
{
Ammo = FMath::Clamp(Ammo + AmmoToAdd, 0, MagCapacity);
SetHUDAmmo();
}

7.游戏时间与服务器计时

一局游戏是有一个时间的。对于服务器,只要获取游戏时间就行了,但是对于客户端,有可能存在中途加入游戏、加载时间过长等情况,因此不能直接使用本地的游戏时间,而是要获取服务器的游戏时间。然而,和服务器进行通信也需要时间,如果直接从服务器获取游戏时间仍然存在滞后。所有这里要进行一些小计算。

先放一下设置游戏倒计时的函数。

1
2
3
4
5
6
7
8
9
10
void AMPTPSPlayerController::SetHUDMatchCountdown(float CountdownTime)
{
MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD;
if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->MatchCountdownText) {
int32 Minutes = FMath::FloorToInt(CountdownTime / 60.f);
int32 Seconds = CountdownTime - Minutes * 60;
FString CountdowmText = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);
MultiplayerTPSHUD->CharacterOverlay->MatchCountdownText->SetText(FText::FromString(CountdowmText));
}
}

RTT(Round-Trip Time)是我们耳熟能详的概念,表示两台计算机进行网络通信时信息传输所花费的时间。当客户端获取到了服务器的游戏时间,只要再加上RTT,不就是客户端当前的游戏时间吗。

这里使用了一个Server RPC和一个Client RPC。当客户端请求服务器的游戏时间时,调用Server RPC,发送自己发送请求的时间;服务器在执行Server RPC时调用Client RPC,将客户端的请求时间和服务器的游戏时间一起发送给客户端。这样,客户端通过将本地时间减去自己发送请求的时间,就得到了RTT。需要注意,RTT是从自己发送请求到服务器,到服务器的回复送达客户端的时间,而这里得到了服务器的游戏时间,只要加上服务器的回复发送到客户端时间就行了。双向通信的时间不一定是对称的,但是这里近似为对称的。将服务器的游戏时间加上一半的RTT,就得到了比较准确的客户端本地游戏时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void AMPTPSPlayerController::ServerRequestServerTime_Implementation(float TimeOfClientRequest)
{
float ServerTimeOfReceipt = GetWorld()->GetTimeSeconds();
ClientReportServerTime(TimeOfClientRequest, ServerTimeOfReceipt);
}

void AMPTPSPlayerController::ClientReportServerTime_Implementation(float TimeOfClientRequest, float TimeServerReveivedClientRequest)
{
float RoundTripTime = GetWorld()->GetTimeSeconds() - TimeOfClientRequest;
float CurrentServerTime = TimeServerReveivedClientRequest + (0.5f * RoundTripTime);
ClientServerDelta = CurrentServerTime - GetWorld()->GetTimeSeconds();
}

float AMPTPSPlayerController::GetServerTime()
{
if (HasAuthority()) {
return GetWorld()->GetTimeSeconds();
}
else {
return GetWorld()->GetTimeSeconds() + ClientServerDelta;
}
}

每隔一段时间就同步一次游戏时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//请求当前服务器时间,发送客户端发送请求的时间
UFUNCTION(Server, Reliable)
void ServerRequestServerTime(float TimeOfClientRequest);

//回应客户端请求,向客户端报告当前服务器时间
UFUNCTION(Client, Reliable)
void ClientReportServerTime(float TimeOfClientRequest, float TimeServerReveivedClientRequest) ;

float ClientServerDelta = 0.f; //客户端和服务器之间的时间差

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

float TimeSyncRunningTime = 0.f;
void CheckTimeSync(float DeltaTime);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void AMPTPSPlayerController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
SetHUDTime();
CheckTimeSync(DeltaTime);
}

void AMPTPSPlayerController::CheckTimeSync(float DeltaTime)
{
TimeSyncRunningTime += DeltaTime;
if (IsLocalController() && TimeSyncRunningTime > TimeSyncFrequency) {
ServerRequestServerTime(GetWorld()->GetTimeSeconds());
TimeSyncRunningTime = 0.f;
}
}

void AMPTPSPlayerController::SetHUDTime()
{
uint32 SecondLeft = FMath::CeilToInt(MatchTime - GetServerTime());
if (CountdownInt != SecondLeft) {
SetHUDMatchCountdown(MatchTime - GetServerTime());
}
CountdownInt = SecondLeft;
}

8.比赛状态

GameMode类中除了继承了GameModeBase类中的基础功能,还实现了MatchState的相关功能实现。简单来说,我们可以使用GameMode类提供的游戏状态和我们自己定义的比赛状态来控制和管理游戏的不同阶段,以及转换阶段时的操作。

从最简单的开始。当进入游戏后,我不希望直接开始游戏,而是有一个热身阶段,热身结束后正式开始比赛,并进入InProgress的比赛状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
AMultiplayerTPSGameMode::AMultiplayerTPSGameMode()
{
bDelayedStart = true;
}

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

if (MatchState == MatchState::WaitingToStart) {
CountdownTime = WarmupTime - GetWorld()->GetTimeSeconds() + LevelStartingTime;
if (CountdownTime <= 0.f) {
StartMatch();
}
}
}

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

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

当比赛状态改变时会调用OnMatchStateSet函数,可以在里面设置控制器的比赛状态变量,并由此进行相关的操作。例如,当比赛状态变成InProgress时,就开始显示HUD

1
2
3
4
5
6
7
8
9
10
11
void AMultiplayerTPSGameMode::OnMatchStateSet()
{
Super::OnMatchStateSet();

for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator();It;++It) {
AMPTPSPlayerController* MultiplayerTPSPlayer = Cast<AMPTPSPlayerController>(*It);
if (MultiplayerTPSPlayer) {
MultiplayerTPSPlayer->OnMatchStateSet(MatchState);
}
}
}
1
2
3
4
5
6
7
8
9
void AMPTPSPlayerController::OnRep_MatchState()
{
if (MatchState == MatchState::InProgress) {
MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD;
if (MultiplayerTPSHUD) {
MultiplayerTPSHUD->AddCharacterOverlay();
}
}
}

存在一个问题。之前当HUD创建后在BeginPlay中就创建Overlay,此时控制器可以直接在游戏刚开始时设置相关的属性。但是现在有了一个等待时间,Overlay还没有创建出来,导致控制器无法准确初始化数值。解决方法是如果设置Overlay时Overlay还没有创建,就用变量将这些值保存下来,在Tick中轮询Overlay,如果发现Overlay创建好了,就用之前保存的值来初始化Overlay。之后如果有新的变量需要设置,就不再重复了。

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
//MPTPSPlayerCharacter.cpp
void AMPTPSPlayerController::SetHUDHealth(float Health, float MaxHealth)
{
MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD;
if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay && MultiplayerTPSHUD->CharacterOverlay->HealthBar && MultiplayerTPSHUD->CharacterOverlay->HealthText) {
const float HealthPercent = Health / MaxHealth;
MultiplayerTPSHUD->CharacterOverlay->HealthBar->SetPercent(HealthPercent);
FString HealthText = FString::Printf(TEXT("%d/%d"), FMath::CeilToInt(Health), FMath::CeilToInt(MaxHealth));
MultiplayerTPSHUD->CharacterOverlay->HealthText->SetText(FText::FromString(HealthText));
}
else {
bInitializerCharacterOverlay = true;
HUDHealth = Health;
HUDMaxHealth = MaxHealth;
}
}

void AMPTPSPlayerController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
SetHUDTime();
CheckTimeSync(DeltaTime);
PollInit();
}

void AMPTPSPlayerController::PollInit()
{
if (!CharacterOverlay) {
if (MultiplayerTPSHUD && MultiplayerTPSHUD->CharacterOverlay) {
CharacterOverlay = MultiplayerTPSHUD->CharacterOverlay;
if (CharacterOverlay) {
SetHUDHealth(HUDHealth, HUDMaxHealth);
SetHUDScore(HUDScore);
SetHUDDefeats(HUDDefeats);
}
}
}
}

然后是自定义的比赛状态。比赛状态是定义在一个命名空间中的,可以直接在这个命名空间中定义自己的比赛状态。

1
2
3
4
5
6
7
8
namespace MatchState
{
extern MULTIPLAYERTPS_API const FName Cooldown; //比赛持续时间结束,显示胜者并开始冷却计时
}

namespace MatchState {
const FName Cooldown = FName("Cooldown");
}

控制器中新增了对加入Cooldown状态的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void AMPTPSPlayerController::OnRep_MatchState()
{
if (MatchState == MatchState::InProgress) {
HandleMatchHasStarted();
}
else if (MatchState == MatchState::Cooldown) {
HandleCooldown();
}
}

void AMPTPSPlayerController::HandleCooldown()
{
MultiplayerTPSHUD = MultiplayerTPSHUD == nullptr ? Cast<AMultiplayerTPSHUD>(GetHUD()) : MultiplayerTPSHUD;
if (MultiplayerTPSHUD) {
MultiplayerTPSHUD->CharacterOverlay->RemoveFromParent();
if (MultiplayerTPSHUD->Announcement) {
MultiplayerTPSHUD->Announcement->SetVisibility(ESlateVisibility::Visible);
}
}
}

9.各种武器

目前已经完成了射击游戏的大部分核心内容,包括完善的开火、换弹等逻辑,以及武器和弹药基类。要实现多种多样的武器,只需要在之前的基础上添加各种武器特有的功能就行了。这就是精心设计基类的好处啊。

之前已经设计好了武器类,及其两个派生类:ProjectileWeapon(弹道武器)和HitscanWeapon(即时命中武器)。弹道武器的设计主要集中在投射物上,而即时命中武器的设计则集中于武器本身。

注意,对于每一种新武器,都要添加对应的武器类型以及初始弹药。

9.1 火箭发射器(天降正义!)

和上面说的一样,火箭发射器和突击步枪没什么本质区别,区别只是发射的子弹不同罢了。从Projectile类派生ProjectileRocket类。

仔细思考,火箭弹和子弹有什么区别?首先命中时不是直接对命中的角色造成伤害,而是造成范围伤害;其次火箭弹的飞行速度较慢,因此必须要有自己的网格体;最后,火箭弹在飞行时会有逐渐消散的烟雾尾迹,以及飞行音效,不能和子弹一样直接绑定一个尾迹。

至于其它部分,如命中音效、特效等,就和基类没什么区别了,直接在编辑器中指定即可。

知道了火箭弹独有的特性,接下来去实现就可以了。网格体和范围伤害没什么好说的,对于尾迹,这里使用了Niagara系统,而随之而来的一个问题是,Niagara系统组件是绑定在火箭弹上的,如果火箭弹发射碰撞后直接被摧毁,那么Niagara系统组件也会被摧毁,导致尾迹直接全部消失。这里的解决方法是发生碰撞后不直接摧毁火箭弹,而是将其隐藏并关闭碰撞,并启动计时器,当计时器结束时尾迹已经消失了,这时候再摧毁火箭弹就没问题了。相应的,Projectile类中音效、特性和伤害等逻辑无论时在OnHit中触发还是在Destroyed中触发都没问题,而在火箭弹中必须在OnHit中触发。

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
//ProjectileRocket.cpp
AProjectileRocket::AProjectileRocket()
{
RocketMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Rocket Mesh"));
RocketMesh->SetupAttachment(RootComponent);
RocketMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}

void AProjectileRocket::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
APawn* FiringPawn = GetInstigator();
if (FiringPawn && HasAuthority()) {
AController* FiringController = FiringPawn->GetController();
if (FiringController) {
//添加径向伤害
UGameplayStatics::ApplyRadialDamageWithFalloff(
this, //世界上下文对象
Damage, //基础伤害
10.f, //最低伤害
GetActorLocation(), //原点
200.f, //内圈半径
500.f, //外圈半径
1.f, //伤害衰减
UDamageType::StaticClass(), //伤害类
TArray<AActor*>(), //忽略的Actor
this, //造成伤害的Actor
FiringController //发起者控制器
);
);
}
}

GetWorldTimerManager().SetTimer(
DestroyTimer,
this,
&AProjectileRocket::DestroyTimerFinished,
DestroyTime
);

if (ImpactParticles) {
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactParticles, GetActorTransform());
}
if (ImpactSound) {
UGameplayStatics::PlaySoundAtLocation(this, ImpactSound, GetActorLocation());
}
if (RocketMesh) {
RocketMesh->SetVisibility(false);
}
if (CollisionBox) {
CollisionBox->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
if (TrailSystemComponent && TrailSystemComponent->GetSystemInstance()) {
TrailSystemComponent->GetSystemInstance()->Deactivate();
}
if (ProjectileLoopComponent && ProjectileLoopComponent->IsPlaying()) {
ProjectileLoopComponent->Stop();
}
}

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

if (!HasAuthority()) {
CollisionBox->OnComponentHit.AddDynamic(this, &ThisClass::OnHit);
}

if (TrailSystem) {
TrailSystemComponent = UNiagaraFunctionLibrary::SpawnSystemAttached(
TrailSystem,
GetRootComponent(),
FName(),
GetActorLocation(),
GetActorRotation(),
EAttachLocation::KeepWorldPosition,
false
);
}
if (ProjectileLoop && LoopingSoundAttenuation) {
ProjectileLoopComponent = UGameplayStatics::SpawnSoundAttached(
ProjectileLoop,
GetRootComponent(),
FName(),
GetActorLocation(),
EAttachLocation::KeepWorldPosition,
false,
1.f,
1.f,
0.f,
LoopingSoundAttenuation,
(USoundConcurrency*)nullptr,
false
);
}
}

void AProjectileRocket::DestroyTimerFinished()
{
Destroy();
}

另一个小问题时,火箭弹的飞行速度较慢,而其生成位置是火箭发射器的枪口Socket,如果角色一边前进一边发射,可能会直接原地爆炸。当然最简单的方式就是将生成点向前移,但是总觉得这种妥协的做法有点不爽。

如果简单地在碰撞时判断碰撞对象是发射者就直接返回,确实不会触发爆炸,但是火箭弹会在原地停止不动。这是因为投射物运动组件的默认逻辑时发生碰撞后立即停止投射物的运动,但是事实上根本不需要这个逻辑。既然如此,那么重写投射物运动组件的碰撞逻辑就行了。

创建自己的RocketMovementComponent类,派生自ProjectileMovementComponent类。重写两个核心函数,当发生碰撞时返回AdvanceNextSubstep,并且讲碰撞事件置空。这样就可以完全通过火箭弹自己处理碰撞的相关逻辑了。

1
2
3
4
5
6
7
8
9
10
11
//RocketMovementComponent.cpp
URocketMovementComponent::EHandleBlockingHitResult URocketMovementComponent::HandleBlockingHit(const FHitResult& Hit, float TimeTick, const FVector& MoveDelta, float& SubTickTimeRemaining)
{
Super::HandleBlockingHit(Hit, TimeTick, MoveDelta, SubTickTimeRemaining);
return EHandleBlockingHitResult::AdvanceNextSubstep;
}

void URocketMovementComponent::HandleImpact(const FHitResult& Hit, float TimeSlice, const FVector& MoveDelta)
{

}

9.2 冲锋枪(脉冲炸弹!)

在本项目中,武器分为弹道武器和即时命中武器,而只有即时命中武器会应用随机散布(总感觉弹道武器还有随机散布的话有点bt了,通过自己的努力把哈沃克压成一个点才是享受把,要是有随机散布负反馈就太多了)。

随机散布的原理是:之前直接将准星瞄准的地方作为命中目标,而随机散布会在命中目标的方向距离玩家一定距离处生成一个球,在球内随机选择一点作为命中目标的方向。

image-20250811172319785

影响随机散布的参数有两个:球体的半径球心与角色的距离。下面就来实现这个功能吧。

说实话,这个功能表面上挺简单,实际上手却发现有很多小问题。这些问题和功能本身无关,而是在于网络同步方面。

目前的开火逻辑是在所有客户端上都调用武器的Fire函数,其参数是命中目标。随机散布就是根据这个参数来计算的。那么问题来了,随机散布的计算要在哪里进行?最简单的方式是直接在Fire函数中进行计算,这个方法的问题是每一个客户端的计算结果都是随机的,而只有服务器负责伤害的判定。那么就会出现玩家本地看到子弹轨迹命中了敌人,但是服务器上的计算结果却是未命中。这对游戏体验的破坏是致命性的。

接下来的方法就是我学到的,玩家在客户端直接计算命中目标,然后将其作为参数传给其它客户端和服务器用于开火,这样所有客户端的随机散布的计算结果就完全一样了。看起来是个完美的解决方案。

但是正如我上面说的,随机散布由两个参数决定:球体的半径和球心与角色的距离,那么,直接让客户端自行计算随机散布结果真的安全吗?只要在本地将球体半径的数据修改为接近于0,或者将球心与角色的距离修改为无限远,随机散布不就近乎于0了吗?

对于UE引擎的反作弊,一个要点是对于所有涉及到重要数据的操作都由服务器来处理,不使用客户端的数据,这对于上面的方法已经行不通了;另一个方法是在调用RPC时使用WithValidation来验证数据的合理性。问题是,这里传入的参数是计算结果,而不是半径或者距离,那么即使在调用Fire的RPC时使用WithValidation验证这两个值,是否会存在计算前修改这两个值,计算完后调用RPC前将这两个值复原的可能性?

我自己想到的解决方法是,在玩家开火时,只将命中目标通过Server RPC发送到服务器,由服务器进行命中点的计算,然后通过多播RPC将特效、音效、尾迹等同步到所有客户端。

但是这个做法也给之后实现延迟补偿埋下了一个坑,在下一部分会进行解释。除此之外还有一个明面上的问题:玩家开火后不能立刻看到效果,而是要等请求发送到了服务器,服务器处理完成后再将结果发送到客户端,才能看到开火的效果。

(在这部分功能完成后,我使用clumsy手动提高网络延迟,在CSValorantOverwatch中进行了测试,结果是在高延迟的情况下开火都能立刻看到效果,而命中效果则会在延迟后产生。其中Valorant是高度依赖于随机扩散的,并且其同样是由虚幻引擎制作的。所以可能随机扩散确实是在本地计算的,并且不需要担心作弊的问题或者有其它的解决方法。接下来我会想办法搞清楚这个问题。)

总之,下面的随机扩散计算方法可能不算成熟,但是确实是我思考以后得出的最好方法(也有可能因为知识不足导致副作用就是了)。

首先是随机散布的计算,传入一个向量,经过计算后返回随机散布的向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FVector AHitScanWeapon::TraceEndWithScatter(const FVector& HitTarget)
{
const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName("MuzzleFlash");
if (!MuzzleFlashSocket) {
return FVector();
}
FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
FVector TraceStart = SocketTransform.GetLocation();

FVector ToTargetNormalized = (HitTarget - TraceStart).GetSafeNormal();
FVector SphereCenter = TraceStart + ToTargetNormalized * DistanceToSphere;
FVector RandVec = UKismetMathLibrary::RandomUnitVector() * FMath::FRandRange(0.f, SphereRadius);
FVector EndLoc = SphereCenter + RandVec;
FVector ToEndLoc = EndLoc - TraceStart;

//DrawDebugSphere(GetWorld(), SphereCenter, SphereRadius, 12, FColor::Red, true);
//DrawDebugSphere(GetWorld(), EndLoc, 4.f, 12, FColor::Green, true);
//DrawDebugLine(GetWorld(), TraceStart, FVector(TraceStart + ToEndLoc * TRACE_LENGTH / ToEndLoc.Size()), FColor::Cyan, true);

return FVector(TraceStart + ToEndLoc * TRACE_LENGTH / ToEndLoc.Size());
}

然后是射线检测,根据武器是否启用了随机散布来决定命中点,然后进行射线检测,检测结果通过参数中的引用进行传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
void AHitScanWeapon::WeaponTraceHit(const FVector& TraceStart, const FVector& HitTarget, FHitResult& OutHit)
{
UWorld* World = GetWorld();
if (World) {
HitEnd = bUseScatter ? TraceEndWithScatter(HitTarget) : TraceStart + (HitTarget - TraceStart) * 1.25f;
World->LineTraceSingleByChannel(
OutHit,
TraceStart,
HitEnd,
ECollisionChannel::ECC_Visibility
);
}
}

最后,开火时在服务器上进行命中结果的计算,然后通过多播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
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
void AHitScanWeapon::Fire(const FVector& HitTarget)
{
Super::Fire(HitTarget);


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

const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName("MuzzleFlash");
FHitResult FireHit;
if (MuzzleFlashSocket) {
FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
FVector Start = SocketTransform.GetLocation();
WeaponTraceHit(Start, HitTarget, FireHit);
AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(FireHit.GetActor());
if (InstigatorController && MultiplayerTPSCharacter) {
UGameplayStatics::ApplyDamage(
MultiplayerTPSCharacter,
Damage,
InstigatorController,
this,
UDamageType::StaticClass()
);
}
}

MulticastHitScanFire(FireHit);
}
}

void AHitScanWeapon::MulticastHitScanFire_Implementation(const FHitResult& FireHit)
{
DrawDebugSphere(GetWorld(), FireHit.ImpactPoint, 16.f, 12, FColor::Orange, true);
const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName("MuzzleFlash");
if (MuzzleFlashSocket) {
FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
FVector Start = SocketTransform.GetLocation();

if (FireHit.bBlockingHit) {
if (ImpactParticles) {
UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
ImpactParticles,
FireHit.ImpactPoint,
FireHit.ImpactNormal.Rotation()
);
}
if (HitSound) {
UGameplayStatics::PlaySoundAtLocation(
this,
HitSound,
FireHit.ImpactPoint
);
}
}
if (MuzzleFlash) {
UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
MuzzleFlash,
SocketTransform
);
}
if (FireSound) {
UGameplayStatics::PlaySoundAtLocation(
this,
FireSound,
GetActorLocation()
);
}
if (BeamParticles) {
UParticleSystemComponent* Beam = UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
BeamParticles,
Start,
FRotator::ZeroRotator,
true
);
if (Beam) {
FVector BeamEnd = HitEnd;
if (FireHit.bBlockingHit) {
BeamEnd = FireHit.ImpactPoint;
}
Beam->SetVectorParameter(FName("Target"), BeamEnd);
}
}
}
}

9.3 霰弹枪(Spa! Spa!)

对于霰弹枪,唯一的区别就是每次射击时需要执行多次射线检测,因此将所有的检测结果存放在一个Tarray中。于此同时,霰弹枪可能会命中多个目标,因此需要用TMap存储所有命中的角色以及对应的命中数,最后遍历TMap来对所有命中角色造成对应的伤害。

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
void AShotgun::Fire(const FVector& HitTarget)
{
AWeapon::Fire(HitTarget);
if (HasAuthority()) {
APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (!OwnerPawn) return;
AController* InstigatorController = OwnerPawn->GetController();

const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName("MuzzleFlash");
if (MuzzleFlashSocket) {
FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
FVector Start = SocketTransform.GetLocation();

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 (HasAuthority() && MultiplayerTPSCharacter && InstigatorController) {
if (HitMap.Contains(MultiplayerTPSCharacter)) {
++HitMap[MultiplayerTPSCharacter];
}
else {
HitMap.Emplace(MultiplayerTPSCharacter, 1);
}
}
}
for (auto& HitPair : HitMap) {
if (HitPair.Key && HasAuthority() && InstigatorController) {
UGameplayStatics::ApplyDamage(
HitPair.Key,
Damage * HitPair.Value,
InstigatorController,
this,
UDamageType::StaticClass()
);
}
}
}
MulticastShotgunFire(HitResults, HitEnds);
}
}

void AShotgun::MulticastShotgunFire_Implementation(const TArray<FHitResult>& Hits, const TArray<FVector>& Ends)
{
const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName("MuzzleFlash");
if (MuzzleFlashSocket) {
FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
FVector Start = SocketTransform.GetLocation();

for (uint32 i = 0; i < NumberOfPellets; ++i) {
DrawDebugSphere(GetWorld(), Hits[i].ImpactPoint, 16.f, 12, FColor::Orange, true);
if (Hits[i].bBlockingHit && ImpactParticles && HitSound) {
UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
ImpactParticles,
Hits[i].ImpactPoint,
Hits[i].ImpactNormal.Rotation()
);

UGameplayStatics::PlaySoundAtLocation(
this,
HitSound,
Hits[i].ImpactPoint,
.5f,
FMath::FRandRange(-.5f, .5f)
);
}
if (BeamParticles) {
UParticleSystemComponent* Beam = UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
BeamParticles,
Start,
FRotator::ZeroRotator,
true
);
if (Beam) {
FVector BeamEnd = Ends[i];
if (Hits[i].bBlockingHit) {
BeamEnd = Hits[i].ImpactPoint;
}
Beam->SetVectorParameter(FName("Target"), BeamEnd);
}
}
}
HitEnds.Empty();
HitResults.Empty();
}
}

霰弹枪有自己独特的换弹机制,那就是子弹都是一发一发上的,同时可以打断换弹的过程直接进行射击。

为霰弹枪的换弹单独写了一个函数,在动画蒙太奇中,每一次上弹都会有一个动画通知,会调用这个函数函数:

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
void UCombatComponent::UpdateShotgunAmmoValues()
{
if (!EquippedWeapon || !Character) {
return;
}

if (CarriedAmmoMap.Contains(EquippedWeapon->GetWeaponType())) {
CarriedAmmoMap[EquippedWeapon->GetWeaponType()] -= 1;
CarriedAmmo = CarriedAmmoMap[EquippedWeapon->GetWeaponType()];
}
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDCarriedAmmo(CarriedAmmo);
}
EquippedWeapon->AddAmmo(1);
bCanFire = true;
if (EquippedWeapon->IsFull() || CarriedAmmo == 0) {
JumpToShotgunEnd();
}
}


void UCombatComponent::ShotgunShellReload()
{
if (Character && Character->HasAuthority()) {
UpdateShotgunAmmoValues();
}
}

当子弹满了或者没有备弹了,就会跳到结束片段。跳转片段的过程要进行同步,分别在携带弹药复制和武器的弹药复制中进行:

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 UCombatComponent::JumpToShotgunEnd()
{
UAnimInstance* AnimInstance = Character->GetMesh()->GetAnimInstance();
if (AnimInstance && Character->GetReloadMontage()) {
AnimInstance->Montage_JumpToSection(FName("ShotgunEnd"));
}
}

bool UCombatComponent::CanFire()
{
if (!EquippedWeapon) return false;
return !EquippedWeapon->IsEmpty() && bCanFire && (CombatState == ECombatState::ECS_Unoccupied || CombatState == ECombatState::ECS_Reloading && EquippedWeapon->GetWeaponType() == EWeaponType::EWT_Shotgun);
}

void UCombatComponent::OnRep_CarriedAmmo()
{
Controller = Controller ? Controller : Cast<AMPTPSPlayerController>(Character->Controller);
if (Controller) {
Controller->SetHUDCarriedAmmo(CarriedAmmo);
}
bool bJumpToShotgunEnd = CombatState == ECombatState::ECS_Reloading && EquippedWeapon && EquippedWeapon->GetWeaponType() == EWeaponType::EWT_Shotgun && CarriedAmmo == 0;
if (bJumpToShotgunEnd) {
JumpToShotgunEnd();
}
}
1
2
3
4
5
6
7
8
void AWeapon::OnRep_Ammo()
{
MultiplayerTPSOwnerCharacter = MultiplayerTPSOwnerCharacter ? MultiplayerTPSOwnerCharacter : Cast<AMultiplayerTPSCharacter>(GetOwner());
if (MultiplayerTPSOwnerCharacter&&MultiplayerTPSOwnerCharacter->GetCombat()) {
MultiplayerTPSOwnerCharacter->GetCombat()->JumpToShotgunEnd();
}
SetHUDAmmo();
}

开火时记得将战斗状态设为Unoccupied。

9.4 狙击枪(没人可以躲过我的眼睛~)

狙击枪的特点就是瞄准时会出现瞄准镜。通过一个UserWidget就可以轻松搞定。当开镜时播放UserWidget的动画来显示瞄准键,关镜时将动画倒放即可,具体逻辑在蓝图中实现。

1
2
UFUNCTION(BlueprintImplementableEvent)
void ShowSniperScopeWidget(bool bShowScope);
1
2
3
4
5
6
7
8
9
10
11
12
void UCombatComponent::SetAiming(bool bIsAiming)
{
if (!Character || !EquippedWeapon) return;
bAiming = bIsAiming;
ServerSetAiming(bIsAiming);
if (Character) {
Character->GetCharacterMovement()->MaxWalkSpeed = bIsAiming ? AimWalkSpeed : BaseWalkSpeed;
}
if (Character->IsLocallyControlled() && EquippedWeapon->GetWeaponType() == EWeaponType::EWT_SniperRifle) {
Character->ShowSniperScopeWidget(bIsAiming);
}
}

当角色被淘汰时,如果正在用狙击枪瞄准,要为其关闭瞄准键:

1
2
3
if (IsLocallyControlled() && Combat && Combat->EquippedWeapon&& Combat->bAiming && Combat->EquippedWeapon->GetWeaponType() == EWeaponType::EWT_SniperRifle) {
ShowSniperScopeWidget(false);
}

这里还遇到了一个无语的Bug,在客户端,每次上弹动作都会上两次弹。找了一下午原因,在各种地方打Log,问AI,硬是每想起来直接搜一下,最后一搜就搜到了。这是UE自己的BUG,如果使用了上半身体与下半身体分开运行动画,客户端远程调用在服务器播放动画蒙太奇就会出现通知被触发两次的情况,解决方法是使用do once节点,当换弹后延迟一点时间将do once重置。

9.5 榴弹发射器(炸弹轮胎滚起来了!)

榴弹的特点和火箭弹类似,就是爆炸。区别是榴弹启用了重力,同时会进行弹跳。

其实实现很简单,只要在投射物运动组件中将bShouldBounce设为true就行了(投射物运动组件万岁!),同时绑定弹跳事件的回调函数,播放弹跳的音效。

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
AProjectileGrenade::AProjectileGrenade()
{
ProjectileMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Grenade Mesh"));
ProjectileMesh->SetupAttachment(RootComponent);
ProjectileMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);

ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComponent"));
ProjectileMovementComponent->bRotationFollowsVelocity = true;
ProjectileMovementComponent->SetIsReplicated(true);
ProjectileMovementComponent->bShouldBounce = true;
}

void AProjectileGrenade::BeginPlay()
{
AActor::BeginPlay();

StartDestroyTimer();
SpawnTrailSystem();

ProjectileMovementComponent->OnProjectileBounce.AddDynamic(this, &AProjectileGrenade::OnBounce);
}

void AProjectileGrenade::OnBounce(const FHitResult& ImpactResult, const FVector& ImpactVelocity)
{
if (BounceSound) {
UGameplayStatics::PlaySoundAtLocation(
this,
BounceSound,
GetActorLocation()
);
}
}

void AProjectileGrenade::Destroyed()
{
ExplodeDamage();

Super::Destroyed();
}

10.总结

说实话,做这个项目比我想的要有趣,尤其是在遇到具体问题和需求,思考解决方案的时候,有时候想着想着就把整个逻辑理通顺了。一直想不出来的时候是真红温,终于想出来了的时候也是真爽。

下一部分,延迟补偿篇,敬请期待。