【UE5】多人联机TPS游戏开发(二) —— 基础框架
终于要正式开始游戏的制作了。在这一部分中,将进行射击游戏的基本框架的搭建。
这部分内容的目标是完成射击游戏的基础网络框架搭建。虽然这些步骤看起来有些“公式化”,但它们实际上奠定了整个多人游戏的网络底层环境。
这部分有两个首次接触的多人游戏中的重点:复制和RPC。这两项是多人游戏最核心的同步机制。我们需要在各种需要同步的地方使用这两个功能,在实现同步功能的同时尽可能地节省网络带宽资源。
除了上面这两个知识点在遇到的时候会有比较详细的解释,其它部分基本上就一笔带过了,毕竟这个项目还有很多别的要讲呢。
1.安装联机插件
上一部分中,我花了大量的精力制作了一个用于多人联机的UE5插件,是时候让它发挥作用了。
创建一个新的空项目,将打包好的插件文件复制到正确的位置:

进行一些常规配置:
NetDriverDefinitions
:指定 UE 在多人模式下使用 SteamNetDriver
作为底层网络驱动。它利用Steam的中继服务器(Steam Relay)实现P2P连接,解决NAT穿透问题;DriverClassNameFallback
则是在无法使用 Steam 时回退到本地 IP 驱动。
DefaultPlatformService=Steam
:选择 Steam 作为当前网络平台子系统。如果不设置,将默认使用 NULL 子系统(仅支持本地多人)。
SteamDevAppId=480
:Steam
官方提供的测试AppID
,任何开发者都可以用它进行多人联机调试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| //Config/DefaultEngine.ini
[/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver") [OnlineSubsystem] DefaultPlatformService=Steam [OnlineSubsystemSteam] bEnabled=true SteamDevAppId=480 [/Script/OnlineSubsystemSteam.SteamNetDriver] NetConnectionClassName="OnlineSubsystemSteam.SteamNetConnection"
|
在 .Build.cs
添加模块依赖(OnlineSubsystem
, OnlineSubsystemSteam
)。
创建大厅地图,将项目打包后发送到另一台电脑,就可以轻松实现联机了。因为大厅地图只是一个空地图,使用的是默认游戏模式,玩家控制的是UE默认的Pawn:一个球。

2.项目基础搭建
在完成网络环境验证后,就可以开始搭建射击游戏的最基础框架了,包括角色、动画和游戏模式等。
2.1 角色
这套流程已经进行过不知道多少次了,这里直接一笔带过。
1 2 3 4 5 6 7 8 9 10 11
| CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom")); CameraBoom->SetupAttachment(GetMesh()); CameraBoom->TargetArmLength = 600.f; CameraBoom->bUsePawnControlRotation = true;
FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera")); FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName); FollowCamera->bUsePawnControlRotation = false;
bUseControllerRotationYaw = false; GetCharacterMovement()->bOrientRotationToMovement = true;
|
网格体和增强输入子系统的配置都在蓝图中完成。
2.2 大厅游戏模式
游戏模式是以关卡为单位,规定关卡中的一些规则的一个类。而大厅作为一个关卡,也可以通过游戏模式去进行一些管理。
这里创建一个简单的游戏模式LobbyGameNide
,重写了PostLogin
函数,规定当大厅人数达到要求后就使用ServerTravel
函数将所有玩家传送到游戏地图。PostLogin
在服务器端调用,表示有新的客户端成功连接到该关卡。这里在测试时规定的是写死的2个玩家,之后会实现通过菜单进行配置。
1 2 3 4 5 6 7 8 9 10 11 12 13
| void ALobbyGameMode::PostLogin(APlayerController* NewPlayer) { Super::PostLogin(NewPlayer);
int32 NumberOfPlayer = GameState.Get()->PlayerArray.Num(); if (NumberOfPlayer == 2) { UWorld* World = GetWorld(); if (World) { bUseSeamlessTravel = true; World->ServerTravel(FString("/Games/_MultiplayerTPS/Maps/MultiplayerTPSMap?listen")); } } }
|
在这段代码中有一句:bUseSeamlessTravel = true
,意思是启用无缝传送,使玩家在切换地图时保持角色状态和连接,不需要重新登录。启用后,UE 会在切图前先加载一个“中转地图”,避免切换过程中客户端掉线。而如果想启用无缝传送,我们需要创建一个空地图,并在项目设置中将其设为转移地图。

补充知识:Net Role
Net Role 就是角色(Actor)在网络中“扮演的身份”。每个 Actor(Pawn、角色、物体等)在多人游戏中都会有两个“网络身份”:本机上的角色和远处机器上的角色。
Net Role有以下取值:
通过区分不同的Net Role可以辅助实现一些联机中的功能,如同步变量、RPC等。
3 射击游戏基础
在网络环境和大厅逻辑就绪后,就可以开始构建射击游戏的基础组件了。
3.1 武器类
武器类继承自AActor
类,具有以下要素:
枚举EWeaponState
表示武器当前的装备状态,如初始、丢弃、装备中等。
武器网格体:武器的外观。
球体碰撞体积:检测玩家是否接近武器,用于触发拾取逻辑。
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
| UENUM(BlueprintType) enum class EWeaponState :uint8 { EWS_Initial UMETA(DisplayName = "Initial State"), EWS_Equipped UMETA(DisplayName = "Equipped"), EWS_Dropped UMETA(DisplayName = "Dropped"), EWS_MAX UMETA(DisplayName = "DefaultMAX") };
UCLASS() class MULTIPLAYERTPS_API AWeapon : public AActor { GENERATED_BODY() public: AWeapon(); virtual void Tick(float DeltaTime) override;
protected: virtual void BeginPlay() override;
private: UPROPERTY(VisibleAnywhere, Category = "Weapon Properties") USkeletalMeshComponent* WeaponMesh;
UPROPERTY(VisibleAnywhere, Category = "Weapon Properties") class USphereComponent* AreaSphere;
UPROPERTY(VisibleAnywhere) EWeaponState WeaponState;
public: };
|
在构造函数中:对网格体设置默认的碰撞响应,阻挡除 Pawn
外的所有通道;初始状态关闭球体的碰撞,避免无关检测;将 bReplicates = 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
| AWeapon::AWeapon() { PrimaryActorTick.bCanEverTick = false; bReplicates = true;
WeaponMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponMesh")); SetRootComponent(WeaponMesh);
WeaponMesh->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Block); WeaponMesh->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Ignore); WeaponMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
AreaSphere = CreateDefaultSubobject<USphereComponent>(TEXT("AreaSphere")); AreaSphere->SetupAttachment(RootComponent); AreaSphere->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore); AreaSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision); }
void AWeapon::BeginPlay() { Super::BeginPlay(); if (HasAuthority()) { AreaSphere->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); AreaSphere->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Overlap); } }
|
3.2 拾取UI组件(复制)
当玩家和武器的球体碰撞体积重叠后,需要有一个UI提示玩家拾取武器。这个拾取UI组件本质上是一个文字块,是Weapon
类的一个变量。它被标记为UPROPERTY
,在编辑器中设定。
1 2
| PickupWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("PickupWidget")); PickupWidget->SetupAttachment(RootComponent);
|
在武器类中写了一个球体重叠的函数,当球体碰撞体积检测到玩家时会被调用,将拾取UI组件设为可见:
1 2 3 4 5 6 7
| void AWeapon::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) { AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(OtherActor); if (MultiplayerTPSCharacter && PickupWidget) { PickupWidget->SetVisibility(true); } }
|
在 BeginPlay
中,如果当前运行在服务器(HasAuthority()
为真),则开启球体的碰撞检测并绑定重叠回调:
1 2 3 4 5 6 7 8 9 10 11 12
| void AWeapon::BeginPlay() { Super::BeginPlay(); if (HasAuthority()) { AreaSphere->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); AreaSphere->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Overlap); AreaSphere->OnComponentBeginOverlap.AddDynamic(this, &ThisClass::OnSphereOverlap); } if (PickupWidget) { PickupWidget->SetVisibility(false); } }
|
这样一来,就能在服务器中看到拾取UI组件了。在客户端看不到的原因是,只有在服务器上才会开启武器的球体碰撞体积,也只有在服务器上会进行球体碰撞体积开始重叠的回调函数绑定。这是多人游戏中为了防止作弊的常规做法。那么要怎么在客户端上看到拾取UI组件呢?答案是复制(Replication)。
在虚幻引擎中复制Actor属性 | 虚幻引擎 5.5 文档 | Epic Developer Community
复制是指权威服务器将状态数据发送到连接的客户端的过程。简单来说,对于标注了复制的变量,当它被改变时(通常在权威服务器上),这个改变会被同步到所有客户端上。复制只对Actor及其相关属性生效。
如果想要复制Actor的变量,这个Actor自身必须是可复制的。
角色类本身就是可复制的,所以不用显式开启。
对于要复制的变量,必须要用UPROPERTY
宏将其标记。如果只是需要将这个变量标记为复制,可以用Replicated
。
1 2
| UPROPERTY(Replicated) class AWeapon* OverlappingWeapon;
|
这个变量用来表示当前重叠的武器。还需要一个Set
函数来设置这个变量。SetOverlappingWeapon
用于在网络同步时更新角色的重叠武器状态,并控制拾取 UI 的可见性。只有在本地控制的角色上才会显示拾取 UI。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void AMultiplayerTPSCharacter::SetOverlappingWeapon(AWeapon* Weapon) { if (OverlappingWeapon) { OverlappingWeapon->ShowPickWidget(false); } OverlappingWeapon = Weapon; if (IsLocallyControlled()) { if (OverlappingWeapon) { OverlappingWeapon->ShowPickWidget(true); } } }
|
1 2 3 4 5 6
| void AWeapon::ShowPickWidget(bool bShowWidget) { if (PickupWidget) { PickupWidget->SetVisibility(bShowWidget); } }
|
修改之前武器球体开始重叠的回调函数:
1 2 3 4 5 6 7
| void AWeapon::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) { AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(OtherActor); if (MultiplayerTPSCharacter) { MultiplayerTPSCharacter->SetOverlappingWeapon(this); } }
|
如果有需要复制的变量,我们就需要下面的这个函数去注册这个变量:
1 2 3 4 5 6 7 8 9 10
| virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
void AMultiplayerTPSCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMultiplayerTPSCharacter, OverlappingWeapon); }
|
如果想要有条件地复制Actor
属性,如仅复制到Actor的所有者,在进行注册时需要用DOREPLIFETIME_CONDITION
宏而不是DOREPLIFETIME
宏,同时用COND_Custom
控制复制的条件。具体用法在官方文档中有详细讲解。
这样一来,当这个变量在服务器上被改变后,它就会自动复制到客户端。在这里,当服务器上角色和地上的武器重叠后,OverlappingWeapon
被设为重叠的武器,发生了改变,这时这个变量就会同步到所有客户端。
现在当服务器上角色和武器重叠时,所有客户端的对应角色的OverlappingWeapon
变量都会被设为正确的值,但是要怎样让这个变量显示拾取组件呢?
如果想要当复制发生时调用某个函数,可以用下面这个UPROPERTY
标记:ReplicatedUsing = FunctionName
。(回调函数必须要有UFUNCTION()宏)
回调函数通常需要加上OnRep_
的前缀:
1 2 3 4 5 6 7 8 9
| void AMultiplayerTPSCharacter::OnRep_OverlappingWeapon(AWeapon* LastWeapon) { if (OverlappingWeapon) { OverlappingWeapon->ShowPickWidget(true); } if (LastWeapon) { LastWeapon->ShowPickWidget(false); } }
|
回调函数通常是无参的,但是也可以带一个要复制的变量类的指针的参数,其值为变量在复制前的值。可以通过这个参数对复制之前的值进行一些操作。
这样一来,当OverlappingWeapon
被复制的时候,所有通过的复制同步了这个变量的客户端都会执行OnRep_OverlappingWeapon
函数,设置武器拾取控制的可见性。当然,只有拾取武器的那个角色能看到效果。
在结束重叠的回调函数中将重叠的武器设为空。同样,这会触发复制,并且将之前重叠的武器的拾取UI组件隐藏。
1 2 3 4 5 6 7
| void AWeapon::OnSphereEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex) { AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(OtherActor); if (MultiplayerTPSCharacter) { MultiplayerTPSCharacter->SetOverlappingWeapon(nullptr); } }
|
3.3 装备武器(RPC)
为了实现模块化,可以把所有处理和战斗相关的逻辑写在一个角色组件CombatComponent
中。在其中声明两个变量,分别为角色和装备的武器。
1 2
| class AMultiplayerTPSCharacter* Character; AWeapon* EquippedWeapon;
|
为了方便角色访问组件,这里将角色类设为了组件类的友元类。这种行为一般来说是不推荐的,但是因为组件本质上是角色的一部分,所以这里这样做没问题。
1
| friend class AMultiplayerTPSCharacter;
|
装备武器的逻辑实现如下,将武器附着到角色的手部插槽,并更新武器状态为已装备。
1 2 3 4 5 6 7 8 9 10 11
| void UCombatComponent::EquipWeapon(AWeapon* WeaponToEquip) { if (Character == nullptr || WeaponToEquip == nullptr) return; EquippedWeapon = WeaponToEquip; EquippedWeapon->SetWeaponState(EWeaponState::EWS_Equipped); const USkeletalMeshSocket* HandSocket = Character->GetMesh()->GetSocketByName(FName("RightHandSocket")); if (HandSocket) { HandSocket->AttachActor(EquippedWeapon, Character->GetMesh()); } EquippedWeapon->SetOwner(Character); }
|
角色类是复制的,但是战斗组件不是,所以角色持有战斗组件后要将其设为复制的。
1 2 3 4 5 6 7
| UPROPERTY(VisibleAnywhere) class UCombatComponent* Combat;
Combat = CreateDefaultSubobject<UCombatComponent>(TEXT("CombatComponent")); Combat->SetIsReplicated(true);
|
1 2 3 4 5 6 7
| void AMultiplayerTPSCharacter::PostInitializeComponents() { Super::PostInitializeComponents(); if (Combat) { Combat->Character = this; } }
|
拾取武器的操作只能在服务器上进行。
1 2 3 4 5 6
| void AMultiplayerTPSCharacter::EquipButtonPressed() { if (Combat && HasAuthority()) { Combat->EquipWeapon(OverlappingWeapon); } }
|
目前服务器的装备武器操作还没有同步到客户端上。对于变量,我们使用复制的方式进行同步,那对于“操作”呢。
远程程序调用(RPC) 是在一台或多台连接的机器上远程执行本地调用的函数。RPC可帮助客户端和服务器通过网络连接相互调用函数。RPC是一种重要机制,它补充了使用 Replicated
或 ReplicatedUsing
说明符的复制属性。要调用RPC,必须从Actor或Actor组件调用RPC,并设置要复制的Actor或相关Actor组件。
简单来说,RPC可以分为调用和执行,当调用RPC函数时会根据规则在本机或者别的机器上执行实现函数。
虚幻引擎中的远程程序调用 | 虚幻引擎 5.5 文档 | Epic Developer Community
虚幻引擎中的Actor所有者和所属连接 | 虚幻引擎 5.5 文档 | Epic Developer Community
我画了几张图来表示几种RPC
的工作原理。
假设一共有三个玩家在一起进行游戏,其中有两个客户端,他们都连接到Listened Server
。对于使用Listened Server
的模型,Listened Server
本身即是服务器,同时也是特殊的客户端。在每一个客户端上都存在三个角色(对于Actor也是同理),对于自己的角色,可以称之为称之为本地控制的(Locally Controlled);而另外两个角色都是经由服务器发送的数据进行模拟的,可以称之为本地模拟的(Simulated);另外,服务器上的角色都是权威的(Authority),通常那些重要数据只能在权威的Actor上才能进行修改。
注意,这是我自己根据实际开发的过程中的理解进行划分的,叫法和之前的Net Role
略有不同,但是意思基本一样,因为在开发中基本上就是通过HasAuthority()
和IsLocallyControlled()
来判断Actor
所属的。同时,对于Listened Server
,Actor
可能既是权威的,又是本地控制的。

Server RPC
:如果是本地控制的Actor
调用,将会由服务器上对应的Actor
来执行。

Client RPC
:如果是服务器上的Actor
调用,将会由Actor
的所属客户端上的对应来执行。

NetMultacast RPC
:我通常直接称之为Multicast RPC
或者多播RPC(因为官方文档中其函数前缀就是Multacast
),被设计于在服务器上调用,会由所有客户端上对应的Actor
来执行。

这里使用Server RPC
来实现装备武器的同步,以及初步了解RPC的用法。
首先要声明一个RPC函数:
1 2 3 4
|
UFUNCTION(Server, Reliable) void ServerEquipButtonPressed();
|
在定义函数时要在函数后面加上后缀_Implementation
,代表RPC函数的实现。编译时不会报错。
1 2 3 4 5 6
| void AMultiplayerTPSCharacter::ServerEquipButtonPressed_Implementation() { if (Combat) { Combat->EquipWeapon(OverlappingWeapon); } }
|
按下装备键的回调函数改为:
1 2 3 4 5 6 7 8 9 10 11 12 13
| void AMultiplayerTPSCharacter::EquipButtonPressed() { if (Combat) { if (HasAuthority()) { Combat->EquipWeapon(OverlappingWeapon); } else { ServerEquipButtonPressed(); } } }
|
说实话,我觉得这样写真的很不优雅。经过查阅资料和自己验证,我确定这里不需要判断是否是权威的,直接调用Server RPC
即可。对于Listened Server
,其同样算是特殊的客户端,所以同样会执行Server RPC
,并且是直接在本地执行的,不会经过网络。
1 2 3 4 5 6
| void AMultiplayerTPSCharacter::EquipButtonPressed() { if (Combat) { ServerEquipButtonPressed(); } }
|
这样就保证了任何机器试图装备武器时,其操作都是在服务器上执行的。
再回过头来看装备武器的逻辑。将要装备的武器的状态改为EWS_Equipped
,将其附着到角色的手部插槽上,然后将其Owner设为角色,将其拾取UI组件隐藏。Weapon自身是可复制的,所以这里面大部分操作都会自动同步到所有机器,除了“将其拾取UI组件隐藏”这一步,因为这是我们自己的逻辑和变量。那么要如何实现拾取UI组件逻辑的同步?
可以注意到,装备武器后会改变WeaponState
这个变量。那么我们将其设为复制,然后在回调函数中隐藏拾取UI组件不就行了吗?
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
| virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
UFUNCTION() void OnRep_WeaponState();
UPROPERTY(ReplicatedUsing = OnRep_WeaponState, VisibleAnywhere, Category = "Weapon Properties") EWeaponState WeaponState;
void AWeapon::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AWeapon, WeaponState); }
void AWeapon::OnRep_WeaponState() { switch (WeaponState) { case EWeaponState::EWS_Equipped: ShowPickWidget(false); AreaSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision); break; } }
|
补一个设置武器状态的函数:
1 2 3 4
| void AWeapon::SetWeaponState(EWeaponState State) { WeaponState = State; }
|
装备武器后需要更新角色的动画。实现的逻辑和简单,在角色类中实现一个接口返回当前是否装备武器,动画实例类中实时通过这个接口获取当前是否装备武器,并据此在动画蓝图中切换动画状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| bool AMultiplayerTPSCharacter::IsWeaponEquipped() { return (Combat && Combat->EquippedWeapon); }
UPROPERTY(BlueprintReadOnly, Category = "Movement", meta = (AllowPrivateAccess = "true")) bool bWeaponEquipped;
void UMultiplayerTPSAnimInstance::NativeUpdateAnimation(float DeltaTime) { Super::NativeUpdateAnimation(DeltaTime); ... bWeaponEquipped = MultiplayerTPSCharacter->IsWeaponEquipped(); ... }
|
唯一的问题是装备的武器是在服务器上更新的,需要进行复制。
1 2 3 4 5 6 7 8 9 10 11
| UPROPERTY(Replicated) AWeapon* EquippedWeapon;
void UCombatComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UCombatComponent, EquippedWeapon); }
|
3.4 蹲伏
蹲伏功能很简单,可以直接调用角色类实现的函数。记得要启用蹲伏功能。
1 2 3 4 5 6 7 8 9 10 11 12 13
| GetCharacterMovement()->NavAgentProps.bCanCrouch = true;
void AMultiplayerTPSCharacter::CrouchButtonPressed() { if (bIsCrouched) { UnCrouch(); } else { Crouch(); } }
|
可以直接在动画蓝图中获取当前是否在蹲伏来切换动画状态。
1
| bIsCrouched = MultiplayerTPSCharacter->bIsCrouched;
|
作为角色类的内置功能,Crouch()
和UnCrouch()
已经处理好了网络同步的相关功能,我们不需要考虑相关问题。
3.5 瞄准
瞄准主要依赖于战斗组件中的一个变量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| bool bAiming;
void AMultiplayerTPSCharacter::AimButtonPressed() { if (Combat) { Combat->bAiming = true; } }
void AMultiplayerTPSCharacter::AimButtonReleased() { if (Combat) { Combat->bAiming = false;; } }
bool AMultiplayerTPSCharacter::IsAiming() { return (Combat && Combat->bAiming); }
|
动画实例类中实时获取当前是否在瞄准,来切换动画蓝图中的状态。
1 2
| UPROPERTY(BlueprintReadOnly, Category = "Movement", meta = (AllowPrivateAccess = "true")) bool bAiming;
|
这样就完成了瞄准的本地实现,现在需要将瞄准同步到所有客户端。要用什么方法呢?
瞄准是在本地按键后执行的,而复制只能在当服务器上的值改变时将其同步到所有连接,如果将bAiming
设为复制属性,那么客户端瞄准时也无法将这个状态同步给服务器和其它客户端。所以除了复制以外,还要使用Server RPC
。
1 2 3 4 5 6 7 8
| UPROPERTY(Replicated) bool bAiming;
void SetAiming(bool bIsAiming);
UFUNCTION(Server, Reliable) void ServerSetAiming(bool bIsAiming);
|
角色瞄准时从直接设置瞄准变为执行SetAiming
函数。在SetAiming
函数中会执行ServerSetAiming
函数,在服务器上设置bAiming
。而当服务器上bAiming
改变后,就会通过复制改变所有连接的对应属性。
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
| void AMultiplayerTPSCharacter::AimButtonPressed() { if (Combat) { Combat->SetAiming(true); } }
void AMultiplayerTPSCharacter::AimButtonReleased() { if (Combat) { Combat->SetAiming(false); } }
void UCombatComponent::SetAiming(bool bIsAiming) { bAiming = bIsAiming; ServerSetAiming(bIsAiming); }
void UCombatComponent::ServerSetAiming_Implementation(bool bIsAiming) { bAiming = bIsAiming; }
|
当装备武器时会使用控制器控制角色Yaw方向的旋转,此时需要使用混合空间来控制角色在不同方向移动时的动作。混合空间需要Yaw和Lean两个输入,我们在动画实例类中实时计算这两个值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| UPROPERTY(BlueprintReadOnly, Category = "Movement", meta = (AllowPrivateAccess = "true")) float YawOffset;
UPROPERTY(BlueprintReadOnly, Category = "Movement", meta = (AllowPrivateAccess = "true")) float Lean;
FRotator AimRotation = MultiplayerTPSCharacter->GetBaseAimRotation(); FRotator MovementRotation = UKismetMathLibrary::MakeRotFromX(MultiplayerTPSCharacter->GetVelocity());
FRotator DeltaRot = UKismetMathLibrary::NormalizedDeltaRotator(MovementRotation, AimRotation); DeltaRotation = FMath::RInterpTo(DeltaRotation, DeltaRot, DeltaTime, 6.f); YawOffset = DeltaRotation.Yaw;
CharacterRotationLastFrame = CharacterRotation; CharacterRotation = MultiplayerTPSCharacter->GetActorRotation(); const FRotator Delta = UKismetMathLibrary::NormalizedDeltaRotator(CharacterRotation, CharacterRotationLastFrame); const float Target = Delta.Yaw / DeltaTime; const float Interp = FMath::FInterpTo(Lean, Target, DeltaTime, 6.f); Lean = FMath::Clamp(Interp, -90.f, 90.f);
|
在装备武器时设置bOrientRotationToMovement = false
和bUseControllerRotationYaw = true
,但是这两个值是不会自动复制的。可以使用之前已经设为EquippedWeapon
属性,当其改变时检测其是否为空,如果不为空,说明装备了武器,此时可以用ReplicatedUsing =
来调用回调函数设置这两个值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| UFUNCTION() void OnRep_EquipWeapon();
UPROPERTY(ReplicatedUsing = OnRep_EquipWeapon) AWeapon* EquippedWeapon;
void UCombatComponent::OnRep_EquipWeapon() { if (EquippedWeapon && Character) { Character->GetCharacterMovement()->bOrientRotationToMovement = false; Character->bUseControllerRotationYaw = true; } }
|
瞄准时需要降低移动速度,逻辑很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| UPROPERTY(EditAnywhere) float BaseWalkSpeed;
UPROPERTY(EditAnywhere) float AimWalkSpeed;
void UCombatComponent::SetAiming(bool bIsAiming) { bAiming = bIsAiming; ServerSetAiming(bIsAiming); if (Character) { Character->GetCharacterMovement()->MaxWalkSpeed = bIsAiming ? AimWalkSpeed : BaseWalkSpeed; } }
|
移动速度作为角色类的内置属性应该是会自动复制的。然而移动速度这个值是由服务器权威控制的,在客户端本地修改这个值不但同步无法生效,甚至在客户端本地的修改也不会生效,而是会被服务器的值不断覆盖。因此我们只能在服务器修改这个值。之前我们实现了瞄准时的Server RPC
,在那里进行修改即可:
1 2 3 4 5 6 7
| void UCombatComponent::ServerSetAiming_Implementation(bool bIsAiming) { bAiming = bIsAiming; if (Character) { Character->GetCharacterMovement()->MaxWalkSpeed = bIsAiming ? AimWalkSpeed : BaseWalkSpeed; } }
|
瞄准时还要放大FOV
。通过插值完成。
1 2 3 4 5 6 7 8 9
| UPROPERTY(EditAnywhere) float ZoomedFOV = 30.f;
UPROPERTY(EditAnywhere) float ZoomInterpSpeed = 20.f;
FORCEINLINE float GetZoomedFOV()const { return ZoomedFOV; } FORCEINLINE float GetZoomInterpSpeed()const { return ZoomInterpSpeed; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
|
float DefaultFOV;
UPROPERTY(EditAnywhere, Category = "Combat") float ZoomedFOV = 30.f;
float CurrentFOV;
UPROPERTY(EditAnywhere, Category = "Combat") float ZoomInterpSpeed = 20.f;
void InterpFOV(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 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| void UCombatComponent::BeginPlay() { Super::BeginPlay();
if (Character) { ... if (Character->GetFollowCamera()) { DefaultFOV = Character->GetFollowCamera()->FieldOfView; CurrentFOV = DefaultFOV; } } }
void UCombatComponent::InterpFOV(float DeltaTime) { if (!EquippedWeapon) { return; }
if (bAiming) { CurrentFOV = FMath::FInterpTo(CurrentFOV, EquippedWeapon->GetZoomedFOV(), DeltaTime, EquippedWeapon->GetZoomInterpSpeed()); } else { CurrentFOV = FMath::FInterpTo(CurrentFOV, DefaultFOV, DeltaTime, ZoomInterpSpeed); } if (Character && Character->GetFollowCamera()) { Character->GetFollowCamera()->SetFieldOfView(CurrentFOV); } }
void UCombatComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); if (Character && Character->IsLocallyControlled()) { ... InterpFOV(DeltaTime); } }
|
3.6 瞄准偏移
在很多第三人称游戏中,当我们移动视角时,角色并不会一直随着摄像机进行旋转。当视角旋转幅度不大时角色会转动头部或上半身,直到视角旋转到了角色身后才会转身。这种转动上半身的效果可以通过瞄准偏移实现。
瞄准偏移属于动画的一种,根据输入的Yaw
和Pitch
计算角色上半身的旋转和俯仰。
在角色类中声明这两个值,并在Tick中进行计算:
1 2 3 4 5 6 7 8
| private: float AO_Yaw; float AO_Pitch; public: FORCEINLINE float GetAO_Yaw() { return AO_Yaw; } FORCEINLINE float GetAO_Pitch() { return AO_Pitch; }
|
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 AMultiplayerTPSCharacter::AimOffset(float DeltaTime) { if (Combat && !Combat->EquippedWeapon) { return; } FVector Velocity = GetVelocity(); Velocity.Z = 0.f; float Speed = Velocity.Size(); bool bIsInAir = GetCharacterMovement()->IsFalling();
if (Speed == 0.f && !bIsInAir) { FRotator CurrentAimRotation = FRotator(0.f, GetBaseAimRotation().Yaw, 0.f); FRotator DeltaAimRotation = UKismetMathLibrary::NormalizedDeltaRotator(CurrentAimRotation, StartingAimRotation); AO_Yaw = DeltaAimRotation.Yaw; if (TurningInPlace == ETurningInPlace::ETIP_NotTurning) { InterpAO_Yaw = AO_Yaw; } bUseControllerRotationYaw = false; } if (Speed > 0.f || bIsInAir) { StartingAimRotation = FRotator(0.f, GetBaseAimRotation().Yaw, 0.f); bUseControllerRotationYaw = true; AO_Yaw = 0.f; } AO_Pitch = GetBaseAimRotation().Pitch; }
|
在动画蓝图中就可以使用这两个值实现瞄准偏移了。
还有一个问题。角色类中的AO_Pitch会进行自动复制,但是在传输过程中引擎会将这个值进行压缩来实现更高的传输效率,但是在解压时会解压成一个在[0,360]之间的值。当角色低头时,Pitch在[-90,0]之间,压缩再解压后就变成了[270,360]之间的值,导致在传输后的低头逻辑错误。解决方法很简单,将解压后的值进行映射即可。
1 2 3 4 5 6
| if (!IsLocallyControlled() && AO_Pitch > 90.f) { FVector2D InRange(270.f, 360.f); FVector2D OutRange(-90.f, 0.f); AO_Pitch = FMath::GetMappedRangeValueClamped(InRange, OutRange, AO_Pitch); }
|
随着上半身的旋转,会出现角色的左手和武器无法匹配的情况,可以使用FABRIK
来解决,将左手放在武器对应的插槽上。这里主要讲一下如何获取左手要放置的位置。
1 2 3
| UPROPERTY(BlueprintReadOnly, Category = "Movement", meta = (AllowPrivateAccess = "true")) FTransform LeftHandTransform;
|
1 2 3 4 5 6 7 8 9
| if (bWeaponEquipped && EquippedWeapon && EquippedWeapon->GetWeaponMesh() && MultiplayerTPSCharacter->GetMesh()) { LeftHandTransform = EquippedWeapon->GetWeaponMesh()->GetSocketTransform(FName("LeftHandSocket"), ERelativeTransformSpace::RTS_World); FVector OutPosition; FRotator OutRotation; MultiplayerTPSCharacter->GetMesh()->TransformToBoneSpace(FName("hand_r"), LeftHandTransform.GetLocation(), FRotator::ZeroRotator, OutPosition, OutRotation); LeftHandTransform.SetLocation(OutPosition); LeftHandTransform.SetRotation(FQuat(OutRotation)); }
|
和瞄准偏移相对应的是当偏移到一定程度后角色要进行转身。
定义一个枚举表示角色当前的转身状态。当AO_Yaw超过指定范围后就切换转身状态。
1 2 3 4 5 6 7 8 9 10 11 12
| UENUM(BlueprintType) enum class ETurningInPlace : uint8 { ETIP_Left UMETA(DisplayName = "Turning Left"), ETIP_Right UMETA(DisplayName = "Turning Right"), ETIP_NotTurning UMETA(DisplayName = "Not Turning"),
ETIP_MAX UMETA(DisplayName = "DefaultMAX") };
ETurningInPlace TurningInPlace; void TurnInPlace(float DeltaTime);
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void AMultiplayerTPSCharacter::TurnInPlace(float DeltaTime) { if (AO_Yaw > 90.f) { TurningInPlace = ETurningInPlace::ETIP_Right; } else if (AO_Yaw < -90.f) { TurningInPlace = ETurningInPlace::ETIP_Left; } if (TurningInPlace != ETurningInPlace::ETIP_NotTurning) { InterpAO_Yaw = FMath::FInterpTo(InterpAO_Yaw, 0.f, DeltaTime, 4.f); AO_Yaw = InterpAO_Yaw; if (FMath::Abs(AO_Yaw) < 15.f) { TurningInPlace = ETurningInPlace::ETIP_NotTurning; StartingAimRotation = FRotator(0.f, GetBaseAimRotation().Yaw, 0.f); } } }
|
这个变量在初始时设为NotInTurning
,并在AimOffset
函数中计算枚举的值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| if (Speed == 0.f && !bIsInAir) { FRotator CurrentAimRotation = FRotator(0.f, GetBaseAimRotation().Yaw, 0.f); FRotator DeltaAimRotation = UKismetMathLibrary::NormalizedDeltaRotator(CurrentAimRotation, StartingAimRotation); AO_Yaw = DeltaAimRotation.Yaw; bUseControllerRotationYaw = false; TurnInPlace(DeltaTime); }
if (Speed > 0.f || bIsInAir) { StartingAimRotation = FRotator(0.f, GetBaseAimRotation().Yaw, 0.f); bUseControllerRotationYaw = true; AO_Yaw = 0.f; TurningInPlace = ETurningInPlace::ETIP_NotTurning; }
FORCEINLINE ETurningInPlace GetTurningInPlace() const { return TurningInPlace; }
|
在动画实例类中实时获取这个值,并根据这个值在动画蓝图中使用RotateRootBone
节点实现转身即可。
3.7 基础射击
本项目的射击方式分为两种:延迟弹道和即时命中。首先实现延迟弹道。
延迟弹道是指开火时会发射子弹,子弹命中后造成伤害。所以我们首先需要创建一个投射物类。
1 2 3 4 5 6 7 8 9 10 11
| AProjectile::AProjectile() { PrimaryActorTick.bCanEverTick = true; CollisionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("CollisionBox")); SetRootComponent(CollisionBox); CollisionBox->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic); CollisionBox->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); CollisionBox->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore); CollisionBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block); CollisionBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_WorldStatic, ECollisionResponse::ECR_Block); }
|
按下开火键后为角色和武器播放开火动画。
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 AMultiplayerTPSCharacter::FireButtonPressed() { if (Combat) { Combat->FireButtonPressed(true); } }
void AMultiplayerTPSCharacter::FireButtonReleased() { if (Combat) { Combat->FireButtonPressed(false); } }
void AMultiplayerTPSCharacter::PlayFireMontage(bool bAiming) { if (!Combat || !Combat->EquippedWeapon) { return; }
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance(); if (AnimInstance && FireWeaponMontage) { AnimInstance->Montage_Play(FireWeaponMontage); FName SectionName; SectionName = bAiming ? FName("RifleAim") : FName("RifleHip"); AnimInstance->Montage_JumpToSection(SectionName); } }
|
1 2 3 4 5 6 7 8 9 10
| void UCombatComponent::FireButtonPressed(bool bPressed) { bFireButtonPressed = bPressed; if (!EquippedWeapon) return; if (Character && bFireButtonPressed) { Character->PlayerFireMontage(bAiming); EquippedWeapon->Fire(); } }
|
1 2 3 4 5
| UPROPERTY(EditAnywhere, Category = "Weapon Properties") class UAnimationAsset* FireAnimation;
void Fire();
|
1 2 3 4 5 6 7
| void AWeapon::Fire() { if (FireAnimation) { WeaponMesh->PlayAnimation(FireAnimation, false); } }
|
这样就实现了本地的射击。
要实现开火的同步,解决方法当然是RPC。我们可以使用一个Server RPC
和一个Multicast RPC
,当开火时调用Server RPC
,服务器在执行Server RPC
时再调用Multicast RPC
,而真正的开火逻辑则放在Multicast RPC
的实现中,调用Multicast RPC
后会在每一台电脑上执行。
1 2 3 4 5 6
| UFUNCTION(Server, Reliable) void ServerFire();
UFUNCTION(NetMulticast, Reliable) void MulticastFire();
|
之前战斗组件中开火键设置的逻辑改为调用Server RPC
。开火同步完成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| void UCombatComponent::FireButtonPressed(bool bPressed) { bFireButtonPressed = bPressed; if (bFireButtonPressed) { ServerFire(); } }
void UCombatComponent::ServerFire_Implementation() { MulticastFire(); }
void UCombatComponent::MulticastFire_Implementation() { if (!EquippedWeapon) return; if (Character) { Character->PlayerFireMontage(bAiming); EquippedWeapon->Fire(); } }
|
接下来是射击的对象。使用射线检测,以准星为起点向前发射,在Tick中计算准星瞄准的对象,并保存到成员变量:
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
| void UCombatComponent::TraceUnderCrosshairs(FHitResult& TraceHitResult) { FVector2D ViewportSize; if (GEngine && GEngine->GameViewport) { GEngine->GameViewport->GetViewportSize(ViewportSize); }
FVector2D CrosshairLocation(ViewportSize.X / 2.f, ViewportSize.Y / 2.f); FVector CrosshairWorldPosition; FVector CrosshairWorldDirection; bool bScreenToWorld = UGameplayStatics::DeprojectScreenToWorld( UGameplayStatics::GetPlayerController(this, 0), CrosshairLocation, CrosshairWorldPosition, CrosshairWorldDirection ); if (bScreenToWorld) { FVector Start = CrosshairWorldPosition; if (Character) { float DistanceToCharacter = (Character->GetActorLocation() - Start).Size(); Start += CrosshairWorldDirection * (DistanceToCharacter + 100.f); } FVector End = Start + CrosshairWorldDirection * TRACE_LENGTH; GetWorld()->LineTraceSingleByChannel( TraceHitResult, Start, End, ECollisionChannel::ECC_Visibility ); if (!TraceHitResult.bBlockingHit) { TraceHitResult.ImpactPoint = End; } else { DrawDebugSphere( GetWorld(), TraceHitResult.ImpactPoint, 12.f, 12, FColor::Red ); } if (!TraceHitResult.bBlockingHit) { TraceHitResult.ImpactPoint = End; HitTarget = End; } else { HitTarget = TraceHitResult.ImpactPoint; DrawDebugSphere( GetWorld(), TraceHitResult.ImpactPoint, 12.f, 12, FColor::Red ); } } }
|
3.8 弹道武器
弹道武器的要点就是在开火时生成一颗子弹,并发射出去。
生成子弹的过程很简单,在弹道武器类中重写开火函数,执行父类的开火函数后获取枪口插槽的位置以及目标点到插槽的方向,根据这些信息生成子弹即可。
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
| void AProjectileWeapon::Fire(const FVector& HitTarget) { Super::Fire(HitTarget); APawn* InstigatorPawn = Cast<APawn>(GetOwner()); const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName(FName("MuzzleFlash")); if (MuzzleFlashSocket && InstigatorPawn) { FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh()); FVector ToTarget = HitTarget - SocketTransform.GetLocation(); FRotator TargetRotatrion = ToTarget.Rotation(); if (ProjectileClass) { FActorSpawnParameters SpawnParams; SpawnParams.Owner = GetOwner(); SpawnParams.Instigator = InstigatorPawn; UWorld* World = GetWorld(); if (World) { World->SpawnActor<AProjectile>( ProjectileClass, SocketTransform.GetLocation(), TargetRotatrion, SpawnParams ); } } } }
|
接下来就是发射子弹了。幸运的是,UE提供了现成的发射器组件,只需要为子弹附带上这个组件,就可以轻松发射子弹。
1 2 3
| UPROPERTY(VisibleAnywhere) class UProjectileMovementComponent* ProjectileMovementComponent;
|
1 2 3 4 5 6 7
| AProjectile::AProjectile() { ... ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComponent")); ProjectileMovementComponent->bRotationFollowsVelocity = true; }
|
发射子弹后我们基本上是看不到的,所以需要为子弹添加轨迹来增强视觉效果。
1 2 3 4
| UPROPERTY(EditAnywhere) class UParticleSystem* Tracer; class UParticleSystemComponent* TracerComponent;
|
1 2 3 4 5 6 7 8 9 10 11
| if (Tracer) { TracerComponent = UGameplayStatics::SpawnEmitterAttached( Tracer, CollisionBox, FName(), GetActorLocation(), GetActorRotation(), EAttachLocation::KeepWorldPosition ); }
|
子弹也是需要同步的,除了复制子弹之外,还要将准星瞄准的目标也设为复制,当开火时将其传入准星瞄准的参数,才能正确实现子弹的飞行。
子弹在碰撞后要进行销毁。绑定OnHit
事件,在回调函数中处理销毁逻辑:
1 2 3 4 5 6
| UPROPERTY(EditAnywhere) UParticleSystem* ImpactParticles;
UPROPERTY(EditAnywhere) class USoundCue* ImpactSound;
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| BeginPlay(){ ... if (HasAuthority()) { CollisionBox->OnComponentHit.AddDynamic(this, &ThisClass::OnHit); } }
void AProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit) { Destroy(); }
void AProjectile::Destroyed() { Super::Destroyed(); if (ImpactParticles) { UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactParticles, GetActorTransform()); } if (ImpactSound) { UGameplayStatics::PlaySoundAtLocation(this, ImpactSound, GetActorLocation()); } }
|
射击后需要抛出弹壳,创建一个Casing
类:
1 2
| UPROPERTY(VisibleAnywhere) UStaticMeshComponent* CasingMesh;
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| ACasing::ACasing() { PrimaryActorTick.bCanEverTick = false;
CasingMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("CasingMesh")); SetRootComponent(CasingMesh); CasingMesh->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); CasingMesh->SetSimulatePhysics(true); CasingMesh->SetEnableGravity(true); CasingMesh->SetNotifyRigidBodyCollision(true); ShellEjectionImpulse = 5.f; }
|
1 2 3
| UPROPERTY(EditAnywhere) TSubclassOf<class ACasing> CasingClass;
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| void AWeapon::Fire(const FVector& HitTarget) { if (FireAnimation) { WeaponMesh->PlayAnimation(FireAnimation, false); } if (CasingClass) { const USkeletalMeshSocket* AmmoEjectSocket = WeaponMesh->GetSocketByName(FName("AmmoEject")); if (AmmoEjectSocket) { FTransform SocketTransform = AmmoEjectSocket->GetSocketTransform(WeaponMesh); UWorld* World = GetWorld(); if (World) { World->SpawnActor<ACasing>( CasingClass, SocketTransform.GetLocation(), SocketTransform.GetRotation().Rotator() ); } } } }
|
落地后要进行销毁:
1 2 3 4
| if (ShellSound) { UGameplayStatics::PlaySoundAtLocation(this, ShellSound, GetActorLocation()); } Destroy();
|
3.9 即时命中武器
没什么好说的,开火时进行射线检测,如果命中角色,对其造成伤害,然后在命中点生成音效和命中特效,并且生成子弹的轨迹。
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
| void AHitScanWeapon::Fire(const FVector& HitTarget) { Super::Fire(HitTarget);
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(); FVector End = Start + (HitTarget - Start) * 1.25f;
FHitResult FireHit; UWorld* World = GetWorld(); if (World) { World->LineTraceSingleByChannel( FireHit, Start, End, ECollisionChannel::ECC_Visibility ); FVector BeamEnd = End; if (FireHit.bBlockingHit) { BeamEnd = FireHit.ImpactPoint; AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(FireHit.GetActor()); if (HasAuthority() && MultiplayerTPSCharacter && InstigatorController) { UGameplayStatics::ApplyDamage( MultiplayerTPSCharacter, Damage, InstigatorController, this, UDamageType::StaticClass() ); } if (ImpactParticles) { UGameplayStatics::SpawnEmitterAtLocation( World, ImpactParticles, FireHit.ImpactPoint, FireHit.ImpactNormal.Rotation() ); } } if (BeamParticles) { UParticleSystemComponent* Beam = UGameplayStatics::SpawnEmitterAtLocation( World, BeamParticles, SocketTransform ); if (Beam) { Beam->SetVectorParameter(FName("Target"), BeamEnd); } } } } }
|
3.10 准星
准星可以在HUD类中进行绘制,而这个过程一般是由玩家控制器来控制的。因此这里需要创建自己的PlayerController
类和HUD
类。
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
|
USTRUCT(BlueprintType) struct FHUDPackage { GENERATED_BODY() public: class UTexture2D* CrosshairCenter; UTexture2D* CrosshairLeft; UTexture2D* CrosshairRight; UTexture2D* CrosshairTop; UTexture2D* CrosshairBottom; float CrosshairSpread; FLinearColor CrosshairColor; };
public: virtual void DrawHUD() override;
private: FHUDPackage HUDPackage;
public: FORCEINLINE void SetHUDPackage(const FHUDPackage& Package) { HUDPackage = Package; }
|
在武器类中同样声明五个2D材质变量,用来存储每个武器自己的准星。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| UPROPERTY(EditAnywhere) class UTexture2D* CrosshairCenter;
UPROPERTY(EditAnywhere) UTexture2D* CrosshairLeft;
UPROPERTY(EditAnywhere) UTexture2D* CrosshairRight;
UPROPERTY(EditAnywhere) UTexture2D* CrosshairTop;
UPROPERTY(EditAnywhere) UTexture2D* CrosshairBottom;
|
在战斗组件中每帧进行准星设置:
1 2 3
| class AMPTPSPlayerController* Controller; class AMultiplayerTPSHUD* HUD;
|
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 UCombatComponent::SetHUDCrosshairs(float DeltaTime) { if (!Character || !Character->Controller) return; Controller = Controller == nullptr ? Cast<AMPTPSPlayerController>(Character->Controller) : Controller; if (Controller) { HUD = HUD == nullptr ? Cast<AMultiplayerTPSHUD>(Controller->GetHUD()) : HUD; if (HUD) { FHUDPackage HUDPackage; if (EquippedWeapon) { HUDPackage.CrosshairCenter = EquippedWeapon->CrosshairCenter; HUDPackage.CrosshairLeft = EquippedWeapon->CrosshairLeft; HUDPackage.CrosshairRight = EquippedWeapon->CrosshairRight; HUDPackage.CrosshairTop = EquippedWeapon->CrosshairTop; HUDPackage.CrosshairBottom = EquippedWeapon->CrosshairBottom; } else { HUDPackage.CrosshairCenter = nullptr; HUDPackage.CrosshairLeft = nullptr; HUDPackage.CrosshairRight = nullptr; HUDPackage.CrosshairTop = nullptr; HUDPackage.CrosshairBottom = nullptr; } HUD->SetHUDPackage(HUDPackage); } } }
|
在HUD类中实现准星绘制:
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
| void AMultiplayerTPSHUD::DrawHUD() { Super::DrawHUD();
FVector2D ViewportSize; if (GEngine) { GEngine->GameViewport->GetViewportSize(ViewportSize); const FVector2D ViewportCenter(ViewportSize.X / 2.f, ViewportSize.Y / 2.f);
if (HUDPackage.CrosshairCenter) { DrawCrosshair(HUDPackage.CrosshairCenter, ViewportCenter); } if (HUDPackage.CrosshairLeft) { DrawCrosshair(HUDPackage.CrosshairLeft, ViewportCenter); } if (HUDPackage.CrosshairRight) { DrawCrosshair(HUDPackage.CrosshairRight, ViewportCenter); } if (HUDPackage.CrosshairTop) { DrawCrosshair(HUDPackage.CrosshairTop, ViewportCenter); } if (HUDPackage.CrosshairBottom) { DrawCrosshair(HUDPackage.CrosshairBottom, ViewportCenter); } } }
void AMultiplayerTPSHUD::DrawCrosshair(UTexture2D* Texture, FVector2D ViewportCenter) { const float TextureWidth = Texture->GetSizeX(); const float TextureHeight = Texture->GetSizeY(); const FVector2D TextureDrawPoint( ViewportCenter.X - (TextureWidth / 2.f), ViewportCenter.Y - (TextureHeight / 2.f) );
DrawTexture( Texture, TextureDrawPoint.X, TextureDrawPoint.Y, TextureWidth, TextureHeight, 0.f, 0.f, 1.f, 1.f, FLinearColor::White );
}
|
在射击游戏中,通常准星都会有各种扩散(当然一般来说没什么人会开,但是开不开是玩家的事,有没有是开发者的事),这个扩散值由用各种“因子”来决定,包括速度,瞄准,跳跃,射击等。这里直接给出添加了扩散后的准星绘制代码。
1 2 3
| UPROPERTY(EditAnywhere, Category = "Player Stats") TSubclassOf<class UUserWidget> CharacterOverlayClass;
|
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
| void UCombatComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { ... if (Character && Character->IsLocallyControlled()) { SetHUDCrosshairs(DeltaTime); } ... }
void UCombatComponent::SetHUDCrosshairs(float DeltaTime) { if (!Character || !Character->Controller) return; Controller = Controller == nullptr ? Cast<AMPTPSPlayerController>(Character->Controller) : Controller; if (Controller) { HUD = HUD == nullptr ? Cast<AMultiplayerTPSHUD>(Controller->GetHUD()) : HUD; if (HUD) { if (EquippedWeapon) { HUDPackage.CrosshairCenter = EquippedWeapon->CrosshairCenter; HUDPackage.CrosshairLeft = EquippedWeapon->CrosshairLeft; HUDPackage.CrosshairRight = EquippedWeapon->CrosshairRight; HUDPackage.CrosshairTop = EquippedWeapon->CrosshairTop; HUDPackage.CrosshairBottom = EquippedWeapon->CrosshairBottom; } else { HUDPackage.CrosshairCenter = nullptr; HUDPackage.CrosshairLeft = nullptr; HUDPackage.CrosshairRight = nullptr; HUDPackage.CrosshairTop = nullptr; HUDPackage.CrosshairBottom = nullptr; } FVector2D WalkSpeedRange(0.f, Character->GetCharacterMovement()->MaxWalkSpeed); FVector2d VelocityMultiplierRange(0.f, 1.f); FVector Velocity = Character->GetVelocity(); Velocity.Z = 0.f; CrosshairVelocityFactor = FMath::GetMappedRangeValueClamped(WalkSpeedRange, VelocityMultiplierRange, Velocity.Size()); if (Character->GetCharacterMovement()->IsFalling()) { CrosshairInAirFactor = FMath::FInterpTo(CrosshairInAirFactor, 2.25f, DeltaTime, 2.25f); } else { CrosshairInAirFactor= FMath::FInterpTo(CrosshairInAirFactor, 0.f, DeltaTime, 30.f); }
if (bAiming) { CrosshairAimFactor = FMath::FInterpTo(CrosshairAimFactor, 0.58f, DeltaTime, 30.f); } else { CrosshairAimFactor = FMath::FInterpTo(CrosshairAimFactor, 0.f, DeltaTime, 30.f); } CrosshairShootingFactor = FMath::FInterpTo(CrosshairShootingFactor, 0.f, DeltaTime, 40.f);
HUDPackage.CrosshairSpread = 0.5f + CrosshairVelocityFactor + CrosshairInAirFactor - CrosshairAimFactor + CrosshairShootingFactor;
HUD->SetHUDPackage(HUDPackage); } } }
|
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
| void AMultiplayerTPSHUD::DrawHUD() { Super::DrawHUD();
float SpreadScaled = CrosshairSpreadMax * HUDPackage.CrosshairSpread;
FVector2D ViewportSize; if (GEngine) { GEngine->GameViewport->GetViewportSize(ViewportSize); const FVector2D ViewportCenter(ViewportSize.X / 2.f, ViewportSize.Y / 2.f);
if (HUDPackage.CrosshairCenter) { FVector2D Spread(0.f, 0.f); DrawCrosshair(HUDPackage.CrosshairCenter, ViewportCenter, Spread, HUDPackage.CrosshairColor); } if (HUDPackage.CrosshairLeft) { FVector2D Spread(-SpreadScaled, 0.f); DrawCrosshair(HUDPackage.CrosshairLeft, ViewportCenter, Spread, HUDPackage.CrosshairColor); } if (HUDPackage.CrosshairRight) { FVector2D Spread(SpreadScaled, 0.f); DrawCrosshair(HUDPackage.CrosshairRight, ViewportCenter, Spread, HUDPackage.CrosshairColor); } if (HUDPackage.CrosshairTop) { FVector2D Spread(0.f, -SpreadScaled); DrawCrosshair(HUDPackage.CrosshairTop, ViewportCenter, Spread, HUDPackage.CrosshairColor); } if (HUDPackage.CrosshairBottom) { FVector2D Spread(0.f, SpreadScaled); DrawCrosshair(HUDPackage.CrosshairBottom, ViewportCenter, Spread, HUDPackage.CrosshairColor); } } }
|
当准星瞄准特定的对象时应该改变颜色。这里的特定对象不仅包含敌人,也可能包含场景中的可破坏道具等,所以不能简单地将瞄准的对象进行转换来实现。这里的实现方式是接口。
面向对象编程的一个核心思想是数据抽象,而接口的思想与这有些相似,我称之为功能抽象:它允许一些彼此可能类似也可能完全不同的对象去执行名称相同、概念相似的操作。例如门、开关和冰箱,它们是不同的对象,但都可以执行“开”这个动作,尽管动作的内部逻辑并不相同。
创建接口类后,如果想要使用接口,需要保证使用接口的类继承这个接口类:
1
| class MULTIPLAYERTPS_API AMultiplayerTPSCharacter : public ACharacter, public IInteractWithCrosshairsInterface
|
这里使用的只是接口的一个小功能。我们可以通过Implements<UInteractWithCrosshairsInterface>()
函数来判断一个类有没有实现特定的接口,如果瞄准的对象实现了这个接口,就设置准星的颜色为红色,否则为白色:
1 2 3 4 5 6
| if (TraceHitResult.GetActor() && TraceHitResult.GetActor()->Implements<UInteractWithCrosshairsInterface>()) { HUDPackage.CrosshairColor = FLinearColor::Red; } else { HUDPackage.CrosshairColor = FLinearColor::White; }
|
有一个小细节。在 UE 中,接口由两部分组成:
U
开头的类(如 UInteractWithCrosshairsInterface
):继承自 UInterface
,用于反射系统识别接口类型,支持蓝图调用与类型检查。
I
开头的类(如 IInteractWithCrosshairsInterface
):纯 C++ 接口,包含你要实现的函数声明。
在继承接口类时,继承的是IInteractWithCrosshairsInterface
,而在检测对象是否实现了接口时使用的是UInteractWithCrosshairsInterface
。**
3.11 受击
和开火时一样,受击动画也要通过多播RPC进行同步。不同的时,受击只会在服务器上发生,所以不需要在客户端调用Server RPC
。
1 2 3 4
| void PlayHitReactMontage(); UFUNCTION(NetMulticast, Unreliable) void MulticastHit();
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| void AMultiplayerTPSCharacter::PlayHitReactMontage() { if (!Combat || !Combat->EquippedWeapon) { return; }
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance(); if (AnimInstance && HitReactMontage) { AnimInstance->Montage_Play(HitReactMontage); FName SectionName("FromFront"); AnimInstance->Montage_JumpToSection(SectionName); } } void AMultiplayerTPSCharacter::MulticastHit_Implementation() { PlayHitReactMontage(); }
|
当子弹碰到角色时调用多播RPC:
1 2 3 4 5 6 7 8 9 10
| void AProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit) { AMultiplayerTPSCharacter* MultiplayerTPSCharacter = Cast<AMultiplayerTPSCharacter>(OtherActor); if (MultiplayerTPSCharacter) { MultiplayerTPSCharacter->MulticastHit(); } Destroy(); }
|
为了使子弹能精确击中敌人的网格体而不是胶囊,这里添加了一个检测碰撞通道,并将Projectile对这个通道的碰撞设为Block
。为了使用能够理解的名字而不是“第一个通道”,添加了一个宏。
1 2 3
| #define ECC_SkeletalMesh ECollisionChannel::ECC_GameTraceChannel1
CollisionBox->SetCollisionResponseToChannel(ECC_SkeletalMesh, ECollisionResponse::ECR_Block);
|
将角色的骨骼网格体的碰撞通道设为自定义的通道即可。
3.12 自动开火
对于可以自动开火的武器,当一次射击完成,到达开火间隔后如果仍然按着开火键,应该自动进行下一次开火。可以通过计时器完成这个操作。
在战斗组件中声明计时器。
1 2 3 4 5 6
| FTimerHandle FireTimer; bool bCanFire = true;
void StartFireTimer(); void FireTimerFinished();
|
当开火时启动计时器,同时将bCanFire
设为false,计时结束后重写将bCanFire
设为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 40 41 42 43 44
| void UCombatComponent::FireButtonPressed(bool bPressed) { bFireButtonPressed = bPressed; if (bFireButtonPressed) { if (EquippedWeapon) { Fire(); } } }
void UCombatComponent::Fire() { if (bCanFire) { UE_LOG(LogTemp, Warning, TEXT("Fire!")); bCanFire = false; ServerFire(HitTarget); if (EquippedWeapon) { CrosshairShootingFactor = 0.8f; } StartFireTimer(); } }
计时结束后重写将bCanFire设为true,同时如果是自动武器并且仍然按着开火键,就继续射击。 void UCombatComponent::StartFireTimer() { if (!EquippedWeapon || !Character) return; Character->GetWorldTimerManager().SetTimer( FireTimer, this, &UCombatComponent::FireTimerFinished, EquippedWeapon->FireDelay ); }
void UCombatComponent::FireTimerFinished() { if (!EquippedWeapon) return; bCanFire = true; if (bFireButtonPressed && EquippedWeapon->bAutomatic) { Fire(); } }
|
3.13 优化
当瞄准的对象从远处的对象突然到近处的对象时,枪口会有瞬移的效果,所以在枪口移动时需要做一个插值:
1 2 3 4 5 6
| if (MultiplayerTPSCharacter->IsLocallyControlled()) { bLocallyControlled = true; FTransform RightHnadTransform = EquippedWeapon->GetWeaponMesh()->GetSocketTransform(FName("hand_r"), ERelativeTransformSpace::RTS_World); FRotator LookAtRotation = UKismetMathLibrary::FindLookAtRotation(RightHnadTransform.GetLocation(), RightHnadTransform.GetLocation() + (RightHnadTransform.GetLocation() - MultiplayerTPSCharacter->GetHitTarget())); RightHandRotation = FMath::RInterpTo(RightHandRotation, LookAtRotation, DeltaTime, 30.f); }
|
当摄像机被墙挡住时会拉近和角色之间的距离,此时准星可能会对准角色身后的角色甚至自己,导致武器指向身后并且准星对准自己的时候会变红。解决方法是在射线检测时将起点移到角色前面:
1 2 3 4 5
| FVector Start = CrosshairWorldPosition; if (Character) { float DistanceToCharacter = (Character->GetActorLocation() - Start).Size(); Start += CrosshairWorldDirection * (DistanceToCharacter + 100.f); }
|
当摄像机撞墙时可能视野会被角色完全遮挡,此时需要隐藏角色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| UPROPERTY(EditAnywhere) float CameraThreshold = 200.f;
void AMultiplayerTPSCharacter::HideCameraIfCharacterClose() { if (!IsLocallyControlled()) { return; } if ((FollowCamera->GetComponentLocation() - GetActorLocation()).Size() < CameraThreshold) { GetMesh()->SetVisibility(false); if (Combat && Combat->EquippedWeapon && Combat->EquippedWeapon->GetWeaponMesh()) { Combat->EquippedWeapon->GetWeaponMesh()->bOwnerNoSee = true; } } else { GetMesh()->SetVisibility(true); if (Combat && Combat->EquippedWeapon && Combat->EquippedWeapon->GetWeaponMesh()) { Combat->EquippedWeapon->GetWeaponMesh()->bOwnerNoSee = false; } } }
|