本文为B站系列教学视频 《UE5_C++多人TPS完整教程》 —— 《P39 装备武器(Equipping Weapons)》 的学习笔记,该系列教学视频为计算机工程师、程序员、游戏开发者、作家(Engineer, Programmer, Game Developer, Author) Stephen Ulibarri 发布在 Udemy 上的课程 《Unreal Engine 5 C++ Multiplayer Shooter》 的中文字幕翻译版,UP主(也是译者)为 游戏引擎能吃么。
P39 装备武器(Equipping Weapons)
本节课我们开始进入游戏的枪战环节(The realm of combat),在这个环节中我们需要实现很多枪战游戏的特性。为了保持我们的代码井井有条(Keep our code structured in an organized manner),可以添加一个单独的组件(Separate component)处理所有与枪战相关的功能(Related functionality),然后我们将创建一个用于装备武器的函数,这将涉及到人物角色的武器附着(Attaching the weapon)。
39.1 创建枪战功能组件 C++ 类
在虚幻引擎内容浏览器 “C++ 类”(C++ Classes)目录下新建一个 “Actor 组件”(Actor Component) C++ 类,命名为 “
CombatComponent
”,路径为“.../Blaster/BlasterComponents
”。
在 “
BlasterCharacter.h
” 中声明枪战功能组件类 “Combat
”,接着在 “BlasterCharacter.cpp
” 的构造函数 “ABlasterCharacter()
” 中基于枪战功能组件类创建对象,并指定该对象为复制组件,我们的枪战功能组件将会处理武器的装备。/*** BlasterCharacter.h ***/ ... UCLASS() class BLASTER_API ABlasterCharacter : public ACharacter { GENERATED_BODY() ... public: ... // UPROPERTY(Replicated) // class AWeapon* OverlappingWeapon; UPROPERTY(ReplicatedUsing = OnRep_OverlappingWeapon) // B站弹幕:ReplicatedUsing 可以避免冗余操作,服务端作为权威端,直接修改属性即可,无需通过复制机制“通知自己” // 指定 OverlappingWeapon 的 Repnotify 函数为 OnRep_OverlappingWeapon() class AWeapon* OverlappingWeapon; UFUNCTION() void OnRep_OverlappingWeapon(AWeapon* LastWeapon); // OverlappingWeapon 的 Repnotify 函数 /* P39 装备武器(Equipping Weapons)*/ UPROPERTY(VisibleAnyWhere) class UCombatComponent* Combat; // 添加枪战功能组件类 /* P39 装备武器(Equipping Weapons)*/ ... };
/*** BlasterCharacter.cpp ***/ ... // Fill out your copyright notice in the Description page of Project Settings. #include "BlasterCharacter.h" #include "GameFramework/SpringArmComponent.h" #include "Camera/CameraComponent.h" #include "GameFramework/CharacterMovementComponent.h" #include "Components/WidgetComponent.h" #include "Net/UnrealNetwork.h" #include "Blaster/Weapon/Weapon.h" /* P39 装备武器(Equipping Weapons)*/ #include "Blaster/BlasterComponents/CombatComponent.h" /* P39 装备武器(Equipping Weapons)*/ // Sets default values ABlasterCharacter::ABlasterCharacter() { ... OverheadWidget = CreateDefaultSubobject<UWidgetComponent> (TEXT("OverheadWidget")); // 基于头部组件类创建对象 OverheadWidget->SetupAttachment(RootComponent); // 将头部组件附加到人物根组件 RootComponent 上 /* P39 装备武器(Equipping Weapons)*/ Combat = CreateDefaultSubobject<UCombatComponent>(TEXT("CombatComponent")); // 基于枪战功能组件类创建对象 Combat->SetIsReplicated(true); // 指定为复制组件,这里我们并不需要像上节课一样注册枪战功能组件并重写 GetLifetimeReplicatedProps() 函数 /* P39 装备武器(Equipping Weapons)*/ } ...
打开 “项目设置”(Project Settings),在 “引擎”(Engine)下找到 “输入”(Input),添加 “动作映射”(Action Mappings)“
Equip
”,当我们按下键盘 “E
” 键时能够装备武器。
在 “
BlasterCharacter.h
” 中声明动作映射 “Equip
” 的回调函数 “EquipButtonPressed()
”,接着在 “BlasterCharacter.cpp
” 的 “SetupPlayerInputComponent()
” 函数中绑定回调函数 “EquipButtonPressed()
”。/*** BlasterCharacter.h ***/ ... UCLASS() class BLASTER_API ABlasterCharacter : public ACharacter { GENERATED_BODY() ... protected: // Called when the game starts or when spawned virtual void BeginPlay() override; // 与轴映射相对应的回调函数 void MoveForward(float Value); // 人物角色前进或后退 void MoveRight(float Value); // 人物角色左移或右移 void Turn(float Value); // 人物角色视角左转或右转 void LookUp(float Value); // 人物角色俯视或仰视 // 与动作映射相对应的回调函数 /* P39 装备武器(Equipping Weapons)*/ void EquipButtonPressed(); // 人物角色装备武器 /* P39 装备武器(Equipping Weapons)*/ ... }
/*** BlasterCharacter.cpp ***/ ... /* P39 装备武器(Equipping Weapons)*/ void ABlasterCharacter::EquipButtonPressed() { } /* P39 装备武器(Equipping Weapons)*/ ... // Called to bind functionality to input void ABlasterCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) { Super::SetupPlayerInputComponent(PlayerInputComponent); // 绑定动作映射 PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump); /* P39 装备武器(Equipping Weapons)*/ PlayerInputComponent->BindAction("Equip", IE_Pressed, this, &ABlasterCharacter::EquipButtonPressed); /* P39 装备武器(Equipping Weapons)*/ // 绑定轴映射 PlayerInputComponent->BindAxis("MoveForward", this, &ABlasterCharacter::MoveForward); PlayerInputComponent->BindAxis("MoveRight", this, &ABlasterCharacter::MoveRight); PlayerInputComponent->BindAxis("Turn", this, &ABlasterCharacter::Turn); PlayerInputComponent->BindAxis("LookUp", this, &ABlasterCharacter::LookUp); } ...
我们想正确封装变量与函数(Keep variables and functions encapsulated),这里枪战功能组件类和人物角色类将紧密结合在一起(Be pretty tightly coupled),这意味着他们之间相互依赖(They’re sort of dependent on each other),需要相互访问对方各自的变量与函数,因此我们可以将人物角色类设置为枪战功能组件类的友元类,这样人物角色类就可以完全访问枪战功能组件类的所有变量与函数,包括保护(Protected)和私有(Private)属性。
值得注意的是,教学视频中作者在声明友元类时,将人物角色类 “ABlasterCharacter
” 写成了 “UBlasterCharacter
”,此时并没有出现警告和报错(但是在之后会报错),这说明声明友元类有点像 “向前声明”(Forward declaration),编译器并不真正关心这个类是否存在,而是声明后如果有这个类存在将拥有完全访问权限。/*** CombatComponent.h ***/ // Fill out your copyright notice in the Description page of Project Settings. #pragma once #include "CoreMinimal.h" #include "Components/ActorComponent.h" #include "CombatComponent.generated.h" UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) ) class BLASTER_API UCombatComponent : public UActorComponent { GENERATED_BODY() public: // Sets default values for this component's properties UCombatComponent(); /* P39 装备武器(Equipping Weapons)*/ friend class ABlasterCharacter; // 将人物角色类 ABlasterCharcter 设置为枪战功能组件类 UCombatComponent 的友元类 /* P39 装备武器(Equipping Weapons)*/ protected: // Called when the game starts virtual void BeginPlay() override; public: // Called every frame virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; private: /* P39 装备武器(Equipping Weapons)*/ class ABlasterCharacter* Character; // 声明人物角色类,避免反复 casting 到 ABlasterCharacter class AWeapon* EquippedWeapon; // 保存当前装备的武器 /* P39 装备武器(Equipping Weapons)*/ };
C++ 前向声明(forward declaration) 是在编译器还未看到完整定义之前,提前告诉它某个标识符(比如类、结构体或函数)的存在。它只声明“某个名字存在”,不提供细节。前向声明可以引用某个类型,而不需要知道它的完整定义。这样可以减少头文件的依赖,提高编译效率,避免循环依赖问题。
我们想尽早设置 “
class ABlasterCharacter* Charater
” 中 “Charater
” 的值。由于人物角色类中的继承函数 “PostInitializeComponents()
” 是 Actor 最初处于完整状态的地方,它在 Actor 组件初始化后被调用,因此我们最早可以在这个函数中访问我们的枪战功能组件并对 “Charater
” 的值进行设置。在 “BlasterCharacter.h
” 中声明重写 “PostInitializeComponents()
” 函数,然后在 “BlasterCharacter.cpp
” 中完成函数的定义。/*** BlasterCharacter.h ***/ ... UCLASS() class BLASTER_API ABlasterCharacter : public ACharacter { GENERATED_BODY() public: // Sets default values for this character's properties ABlasterCharacter(); // Called every frame virtual void Tick(float DeltaTime) override; // Called to bind functionality to input virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override; // 重写复制属性函数 virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; /* P39 装备武器(Equipping Weapons)*/ virtual void PostInitializeComponents() override; /* P39 装备武器(Equipping Weapons)*/ ... }
/*** BlasterCharacter.cpp ***/ ... /* P39 装备武器(Equipping Weapons)*/ void ABlasterCharacter::PostInitializeComponents() { Super::PostInitializeComponents(); // 此时 Combat 是存在的,它已被初始化,但这并不意味着我们不再坚持良好的 C++ 代码习惯(Adhere to good C++ practice) if (Combat) { Combat->Character = this; } } /* P39 装备武器(Equipping Weapons)*/
从磁盘加载
已位于关卡中的 Actor 使用此路径,如LoadMap
发生时、或AddToWorld
(从流关卡或子关卡)被调用时。- 包/关卡中的 Actor 从磁盘中进行加载。
PostLoad
- 在序列化 Actor 从磁盘加载完成后被调用。在此处可执行自定义版本化和修复操作。PostLoad
与PostActorCreated
互斥。InitializeActorsForPlay
- 为未初始化的 Actor 执行
RouteActorInitialize
(包含无缝行程携带) 。PreInitializeComponents
- 在 Actor 的组件上调用InitializeComponent
之前进行调用。AActor::PreInitializeComponents
。InitializeComponent
- Actor 上定义的每个组件的创建辅助函数。PostInitializeComponents
- Actor 的组件初始化后调用 。
BeginPlay
- 关卡开始后调用。
—— 虚幻引擎官方文档《Actor 生命周期》
39.2 附加武器至骨骼插槽
在虚幻引擎中打开骨骼网格体 “
SK_EpicCharacter
” 编辑窗口,在左侧 “骨骼树”(Skeleton Tree)侧栏下找到右手骨骼节点 “hand_r
”,为该节点 “添加插槽”(Add Socket) “RightHandSocket
”,接着为该插槽添加预览资产(Add Preview Asset) “Assault_Rifle_A
”,然后旋转插槽 “RightHandSocket
” 对突击步枪 “Assault_Rifle_A
” 进行初步调整。
打开动画资源 “
Idle_Rifle_Ironsights
” 编辑窗口,然后 “SK_EpicCharacter
” 编辑窗口中平移插槽 “RightHandSocket
” 对突击步枪 “Assault_Rifle_A
” 进行调整。可以看到,我们在“SK_EpicCharacter
” 编辑窗口中平移插槽时,动画资源 “Idle_Rifle_Ironsights
” 编辑窗口的突击步枪 “Assault_Rifle_A
” 的位置也在同步地发生变化,我们调整至不出现突击步枪和人物角色脸部不穿模、突击步枪枪口指向正确的方向即可,先不需要关心人物角色的左手与突击步枪的关系。
在 “
Weapon.h
” 中使用关键字 “FORCEINLINE
” 定义函数 “SetWeaponState()
”,用于设置武器的状态(我们在前面的课程中定义了四种武器状态的枚举类型)。/*** Weapon.h ***/ ... // UENUM():参阅《元数据说明符》https://dev.epicgames.com/documentation/zh-cn/unreal-engine/metadata-specifiers-in-unreal-engine?application_version=5.0 // BlueprintType:将此类公开为可用于蓝图中的变量的类型,参阅《类说明符》https://dev.epicgames.com/documentation/zh-cn/unreal-engine/class-specifiers?application_version=5.0 UENUM(BlueprintType) enum class EWeaponState : uint8 // 武器状态枚举类型,枚举常量为无符号8位整型 { // UMETA():参阅《元数据说明符》https://dev.epicgames.com/documentation/zh-cn/unreal-engine/metadata-specifiers-in-unreal-engine?application_version=5.0 EWS_Initial UMETA(DisplayName = "Initial State"), // 初始状态,武器可以被人物捡起 EWS_Equipped UMETA(DisplayName = "Equipped"), // 已装备状态,武器被人物捡起并装备使用 EWS_Dropped UMETA(DisplayName = "Dropped"), // 已丢弃状态,武器被人物丢弃 EWS_Max UMETA(DisplayName = "DefaultMax") // 大多数枚举常量都会有一个默认的最大常量,我们通过检查这个最大常量的值,就能知道枚举类型中有多少个常量 }; UCLASS() class BLASTER_API AWeapon : public AActor { GENERATED_BODY() ... private: UPROPERTY(VisibleAnywhere, Category = "Weapon Properties") // 添加所有地方可见的骨骼网格组件,这样就可以通过蓝图进行编辑武器,归类为 Weapon Properties class USkeletalMeshComponent* WeaponMesh; UPROPERTY(VisibleAnywhere, Category = "Weapon Properties") // 添加一个重叠体积(Overlap Volume),这里使用球体组件 “包裹” 武器骨骼体组件,用于判定人物是否碰到球体,若碰到人物将会拾取该武器 class USphereComponent* AreaSphere; UPROPERTY(VisibleAnywhere, Category = "Weapon Properties") // 添加新声明的武器状态枚举类型,归类为 Weapon Properties EWeaponState WeaponState; UPROPERTY(VisibleAnywhere, Category = "Weapon Properties") // 添加拾取组件 class UWidgetComponent* PickupWidget; /* P39 装备武器(Equipping Weapons)*/ public: FORCEINLINE void SetWeaponState(EWeaponState State) { WeaponState = State; } // 设置武器状态 // forceinline 是编程中用于强制内联函数的关键字或注解,主要用于减少函数调用开销,但需谨慎使用以避免代码膨胀或性能下降。 /* P39 装备武器(Equipping Weapons)*/ };
在 “
CombatComponent.h
” 中声明 “EquipWeapon()
” 函数,用于指定要装备的武器;然后,在 “CombatComponent.cpp
” 中完成 “EquipWeapon()
函数的定义,在这个函数中我们需要设置要装备的武器、借助函数 “SetWeaponState()
” 设置已装备的武器的状态、获取武器插槽并附加武器在插槽上,以及设置武器所属并保证武器不能被拾取。/*** CombatComponent.h ***/ // Fill out your copyright notice in the Description page of Project Settings. #pragma once #include "CoreMinimal.h" #include "Components/ActorComponent.h" #include "CombatComponent.generated.h" /* P39 装备武器(Equipping Weapons)*/ // 可以在此处做向前声明,这样在写 EquipWeapon() 函数的输入参数时就不需要 class 关键字 // class Aweapon; /* P39 装备武器(Equipping Weapons)*/ UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) ) class BLASTER_API UCombatComponent : public UActorComponent { GENERATED_BODY() public: // Sets default values for this component's properties UCombatComponent(); /* P39 装备武器(Equipping Weapons)*/ friend class ABlasterCharacter; // 将人物角色类 ABlasterCharcter 设置为枪战功能组件类 UCombatComponent 的友元类 // void EquipWeapon(AWeapon* WeaponToEquip); void EquipWeapon(class AWeapon* WeaponToEquip); // 指定要装备的武器 /* P39 装备武器(Equipping Weapons)*/ ... }
/*** CombatComponent.cpp ***/ // Fill out your copyright notice in the Description page of Project Settings. /* P39 装备武器(Equipping Weapons)*/ #include "CombatComponent.h" // 原来自动生成的代码是 #include "BlasterComponents/CombatComponent.h",这里需要把 "BlasterComponents/" 去掉,否则找不到文件 "CombatComponent.h" #include "Blaster/Weapon/Weapon.h" #include "Blaster/Character/BlasterCharacter.h" #include "Engine/SkeletalMeshSocket.h" /* P39 装备武器(Equipping Weapons)*/ ... /* P39 装备武器(Equipping Weapons)*/ 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")); // 根据插槽名称搜索并获取右手插槽,需要添加头文件 "Blaster/Character/BlasterCharacter.h" if (HandSocket) { HandSocket->AttachActor(EquippedWeapon, Character->GetMesh()); // 将武器附加在右手插槽上,需要添加头文件 "Engine/SkeletalMeshSocket.h" } // 设置武器所属并保证武器不能被拾取 EquippedWeapon->SetOwner(Character); // 设置武器所属 EquippedWeapon->ShowPickupWidget(false); // 设置拾取组件不可见,被装备的武器将不能再被拾取 } /* P39 装备武器(Equipping Weapons)*/ ...
回到 “
BlasterCharacter.cpp
” 中完成回调函数 “EquipButtonPressed()
” 的定义,当 “Combat
” 不为空且被控端为服务器时,按下 E 键设置与人物角色重叠的武器为要装备的武器。/*** BlasterCharacter.cpp ***/ ... /* P39 装备武器(Equipping Weapons)*/ // 按下 E 键装备与人物角色重叠的武器 void ABlasterCharacter::EquipButtonPressed() { // Combat 不为空且被控端为服务器时,设置与人物角色重叠的武器为要装备的武器 if (Combat && HasAuthority()) { Combat->EquipWeapon(OverlappingWeapon); // 设置要装备的武器为与人物角色重叠的武器 } } /* P39 装备武器(Equipping Weapons)*/ ...
编译后进行测试,可以看到对服务器端上的人物角色进行测试时它可以拾起并装备与之重叠的武器,而对客户端上的人物角色测试却不可以,这正是我们目前这节课想要的效果,即客户端上的人物角色不能决定何时拾取并装备与之重叠的武器,而应该由服务器端判断、决定,我们将在下节课进一步完善客户端上的人物角色拾取并装备武器的功能。
39.3 Summary
本节课我们开始实现枪战游戏中的一些基本特性。为保持代码结构清晰,首先,我们新建了一个枪战功能组件 C++ 类 “CombatComponent
” 来管理所有与枪战相关的功能,在人物角色类 “BlasterCharacter
” 中声明并创建该组件实例,通过 Actor 类继承函数 “PostInitializeComponents()
” 函数安全地初始化其内部对人物角色类的引用;通过将 BlasterCharacter
设为 CombatComponent
的友元类,实现两者间的紧密协作与数据访问。
接着,我们在项目设置中添加动作映射 “Equip
”,绑定按键 E,用于人物角色在与武器发生重叠时按下 E 键拾取并装备武器,在 “BlasterCharacter
” 中声明回调函数 “EquipButtonPressed()
”,并在 “SetupPlayerInputComponent
” 中完成将动作映射与回调函数进行绑定。
下一步,我们在角色骨骼网格体 “SK_EpicCharacter
” 的右手骨骼节点 “hand_r
” 上创建插槽 “RightHandSocket
”,为该插槽添加武器预览资产并参考 “Idle_Rifle_Ironsights
” 动画来调整其位置和旋转,确保武器正确附着且无穿模。
为实现武器装备逻辑,我们先在武器类 “Weapon
” 中定义内联函数 “SetWeaponState()
” 用于更新武器状态(四种武器状态的枚举类型);然后,在 “CombatComponent
” 中实现核心函数 “EquipWeapon()
”,具体实现步骤包括设置武器引用及武器状态,查找人物角色骨骼上的插槽,并将武器附加到找到的插槽上,设置武器的 Owner 为当前人物角色,并隐藏武器的拾取组件。最后我们完成 “EquipButtonPressed()
” 函数的定义,当 “Combat
” 组件存在且当前角色在服务器上时,即可拾取并装备当前重叠的武器。
经测试,实现的效果是服务器端上的人物角色进行测试时它可以拾起并装备与之重叠的武器,武器正确附着于右手插槽上,而客户端上的人物角色不能决定何时拾取并装备与之重叠的武器,由服务器端判断、决定,我们将在下节课进一步完完善网络复制逻辑,确保所有客户端都能正确拾取并装备武器。
在 39.1 创建枪战功能组件 C++ 类 的步骤 4 中作者在声明友元类时,将人物角色类 “ABlasterCharater
” 写成了 “UBlasterCharacter
”,此时并没有出现警告和报错(但是在之后会报错),这说明声明友元类有点像前向声明(Forward declaring),编译器并不真正关心这个类是否存在,而是声明后如果有这个类存在将拥有完全访问权限。