Thx to Kaan Alpar! 感谢您给予我前进的力量.
注意: 项目所需资源下载不了的可以私信我, 我发给你
项目前览:
在这个项目中,我会手把手教你如何制作一个坦克大战!
在游戏中,你需要通过发射炮弹消灭全部的敌人,敌人累计命中你三次,你就输了.
消灭所有的炮塔就赢了, 然后会进入下一关, 共3关;


为此我们需要学习:
1. 从0搭建输出控制系统, 实现效果:玩家通过输入控制坦克
2. 设置伪AI, 使敌人能够检测锁定玩家
3. 投掷物发射系统。
4. 生命值、伤害与摧毁机制。
5. 包含UI的胜利与失败条件。
6. 特效(粒子、音效、镜头震动)7. 关卡控制
章节1:创建坦克

步骤1:
点击左上角工具-新建C++类,我们选择继承自Pawn类,命名为BasePawn

步骤2:
在BasePawn.h中包含头文件,并且额外声明这些组件
#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "Components/CapsuleComponent.h"
#include "BasePawn.generated.h"//所有头文件必须在这个头文件之上!!!
/*
Components/CapsuleComponent.h
在C++中为角色或类人生物创建碰撞体积时必须包含的头文件。
它定义了角色在游戏世界中的物理存在,是处理移动、碰撞和交互的基础。
*/
/*----------------------------------------------------*/
UPROPERTY(VisbleAnyWhere)
UCapsuleComponent* CapsuleComp;
UPROPERTY(VisibleAnywhere)
UStaticMeshComponent* BaseMesh;
UPROPERTY(VisibleAnywhere)
UStaticMeshComponent* TurretMesh;
UCapsuleComponent* CapsuleComp;
作用: 这是碰撞与根组件。
详细解释:
碰撞体: 它定义了这个Actor在游戏世界中的物理边界,用于检测与其他物体的碰撞(如子弹打到炮塔)或重叠(如玩家进入警戒范围)。
根组件: 在Actor的组件层级中,它通常被设置为根组件。其他组件(如下面的网格)会附加到它下面,它的变换(位置、旋转)决定了整个Actor在世界中的位置和朝向。
对于炮塔/角色类对象,使用胶囊体作为碰撞形状比长方体更平滑、更符合生物或类人机械的轮廓。
UStaticMeshComponent* BaseMesh;
作用: 这是底座/车体的静态网格组件。
详细解释:
视觉表现: 它负责渲染炮塔或坦克的下半部分、固定底座或移动车体的3D模型。
功能: 这部分通常负责移动(如果是坦克底盘)或固定位置(如果是固定炮塔的基座)。它的旋转可能仅限于Yaw(水平旋转)。
UStaticMeshComponent* TurretMesh;
作用: 这是炮塔的静态网格组件。
详细解释:
视觉与功能表现: 它负责渲染炮塔的上半部分、可旋转的炮塔的3D模型。
核心功能: 这部分通常会独立于底座进行旋转(既能水平旋转,也可能有有限的俯仰角),以追踪和瞄准目标。它是开火点的父级组件。
步骤3:
在构造函数里构建它们之间的关系;
ABasePawn::ABasePawn()
{
PrimaryActorTick.bCanEverTick = true;
// 1. 创建并设置胶囊体为根组件
CapsuleComp = CreateDefaultSubobject<UCapsuleComponent>(TEXT("CapsuleComp"));
SetRootComponent(CapsuleComp); // 设置为根
CapsuleComp->InitCapsuleSize(55.f, 100.f);
// 2. 创建底座网格,并附加到胶囊体上
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
BaseMesh->SetupAttachment(RootComponent); // 附加到根组件
// 3. 创建炮塔网格,并附加到底座网格上(这样炮塔旋转时,底座不会跟着转)
TurretMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("TurretMesh"));
TurretMesh->SetupAttachment(BaseMesh); // 附加到底座,形成层级
}
你的炮塔Actor (ATank/TurretActor)
├── **CapsuleComp** (根,负责碰撞、Actor位置)
│ └── **BaseMesh** (底座/车体,可能负责移动转向)
│ └── **TurretMesh** (炮塔,独立旋转负责瞄准)
CreateDefaultSubobject<>()是一个模板函数,用于在构造函数中创建并注册一个子对象(通常是组件)。这个子对象会成为Actor的一部分,并且可以被序列化(保存到磁盘)和通过网络复制(如果设置了复制)。特点:
只能在构造函数中调用:因为这个函数是用于设置对象的默认属性,所以它只应该在Actor的构造函数中使用。
创建并注册组件:它创建指定类型的组件,并注册到Actor,使得引擎可以管理它(例如在编辑器中显示、保存、加载等)。
返回一个指向新创建组件的指针,并且已经用给定的名字(FName)注册。
创建的组件是默认子对象,这意味着它是Actor的默认配置的一部分。当你在编辑器中放置一个Actor时,这些组件就会被创建。
步骤4:
创建Tank类:新建C++类,继承BasePawn类,命名为Tank

进入VS后, 理论上Tank类的C++代码都是空的, 我们需要自己创建三个基础函数"BeginPlay(),Tick(),SetupPlayerInputComponent()"和一个构造函数"ATank()".
可以把这三个基础函数从BasePawn.h和BasePawn.cpp中剪切过来(记得修改作用域)
然后声明并且定义一个构造函数
//----------------------------Tank.h-------------------------------------
#pragma once
#include "CoreMinimal.h"
#include "BasePawn.h"
#include "Tank.generated.h"
/**
*
*/
UCLASS()
class BATTLEBLASTER_API ATank : public ABasePawn
{
GENERATED_BODY()
protected:
virtual void BeginPlay() override;
public:
ATank();
virtual void Tick(float DeltaTime) override;
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
};
//--------------------------------Tank.cpp------------------------------------
#include "Tank.h"
//记得修改作用域!!!
ATank::ATank()
{
}
void ATank::BeginPlay()
{
Super::BeginPlay();
}
// Called every frame
void ATank::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
// Called to bind functionality to input
void ATank::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
}
步骤5:
在Tank.h中添加弹簧臂组件,必须要先导入头文件,然后再实例化 臂组件 和 相机组件;
//Tank.h
#pragma once
#include "CoreMinimal.h"
#include "BasePawn.h"
//--------------步骤5:添加头文件---------------
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
//--------------------------------------------
#include "Tank.generated.h"
UCLASS()
class BATTLEBLASTER_API ATank : public ABasePawn
{
GENERATED_BODY()
protected:
virtual void BeginPlay() override;
public:
ATank();
virtual void Tick(float DeltaTime) override;
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
//-------------步骤5:实例化组件---------------
UPROPERTY(VisibleAnywhere)
USpringArmComponent* SpringArmComp;
UPROPERTY(VisibleAnywhere)
UCameraComponent* CameraComp;
//-------------------------------------------
};
步骤6:
在构造函数中,为Tank初始化相机组件和弹簧臂组件,并且完善附加关系
ATank::ATank()
{
//创建弹簧臂组件并且赋值给Tank类成员
SpringArmComp = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArmComp"));
SpringArmComp->SetupAttachment(CapsuleComp);//把这个组件附加到Tank的胶囊体
//创建相机组件,并且赋值到Tank类成员
CameraComp = CreateDefaultSubobject<UCameraComponent>(TEXT("CameraComp"));
CameraComp->SetupAttachment(SpringArmComp);//把这个组件附加到弹簧臂组件上.(为啥不是胶囊体?)
}

把C++类Tank直接拖入场景后可以看到组件的附加关系,以及相机视角
步骤7:配置Tank蓝图
创建蓝图BP_Tank,继承自Tank.cpp,然后在BaseMesh安装底座网格体,在TurretMash安装炮塔网格体.

切到右视图,修改网格体的位置和胶囊体大小,使网格体和胶囊体适配,

修改胶囊体的碰撞预设,改成Pawn:

修改弹簧臂的长度以及角度:


步骤8:
打开自动占用:设置为玩家0

步骤9: 构建C++游戏模式
游戏模式是一个actor,用来设定游戏的规则,它会一直存在,从游戏开始到游戏结束或者进入新关卡.
我们将会创建一个C++类,然后基于此类创建一个蓝图,我们就可以在此蓝图中:追踪游戏中还有多少敌人,显示游戏的UI信息,判断游戏是否结束.
9.1现在开始构建游戏模式类:
继承游戏模式基类GameModeBase,命名为BattleBlasterGameMode

9.2创建并配置游戏模式的蓝图:
继承自BattleBlasterGameMode, 命名为:BP_BattleBlasterGameMode
进入蓝图后把默认pawn类设置为BP_Tank

如何让游戏真的按照这个蓝图定义的玩法进行呢?
我们需要在编辑---项目设置---项目---地图和模式---默认游戏模式---修改为BP_BattleBlasterGameMode

测试效果:
我们把之前加入地图里的BP_Tank的实例删掉,然后点击开始游戏,会发现Tank会在游戏开始位置生成, 通过修改游戏起始点位置可以改变Tank实例的位置(这个位置和Tank胶囊体也有关系,胶囊体太大会导致Tank离地太远)
检查游戏模式,是否符合预期

步骤10: 构建输入映射上下文
新建一个文件夹Input, 然后创建一个输入映射上下文叫做IMC_Default

打开VS,找到BattleBlaster.Build.cs,在AddRange()中加入"EnhanceInput"
PublicDependencyModuleNames.AddRange(new string[] { "Core",
"CoreUObject", "Engine","InputCore","EnhancedInput" });
然后保存并退出VS,在项目文件夹里面重新生成VS项目文件

在Tank.h中包含输入映射上下文的头文件然后再实例化一个输入映射上下文:
#include "InputMappingContext.h"
#include "EnhancedInputSubsystems.h"
UPROPERTY(EditAnywhere,Category = "Input")
UInputMappingContext* DefaultMappingContext;
在Tank.cpp文件中的BeginPlay()里面初始化和输入控制相关的代码:
现阶段, 你无需深入理解这段代码的作用,因为这是一个模板,很多项目都是这样写的
这段代码的主要作用是在坦克生成时,为控制该坦克的玩家控制器注册输入映射上下文,从而启用对该坦克的控制输入。
void ATank::BeginPlay()
{
Super::BeginPlay();
//获取玩家控制器指针
APlayerController* PlayerController = Cast<APlayerController>(Controller);
if (PlayerController) {
ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
if (LocalPlayer) {
UEnhancedInputLocalPlayerSubsystem* Subsystem;
Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LocalPlayer);
if (Subsystem) {
Subsystem->AddMappingContext(DefaultMappingContext, 0);
}
}
}
}
//----另外一种写法-功能相同--------------------------------------------------------
void ATank::BeginPlay()
{
Super::BeginPlay();
if (APlayerController* PlayerController = Cast<APlayerController>(Controller)) {
if (ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer()) {
if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LocalPlayer);) {
Subsystem->AddMappingContext(DefaultMappingContext, 0);
}
}
}
}
保存并重新编译,然后在BP_Tank蓝图里面把IMC_Default加入

步骤11:为坦克创建动作:前后移动
创建动作,并且为其绑定按键,使其可以移动
11.1在input中创建一个IA_Move动作,然后在IMC_Default中为其绑定按键


11.2在Tank.h中和Tank.cpp中声明动作和函数
在Tank.h中加入这三个头文件:
//Tank.h
#include "InputAction.h"
#include "InputActionValue.h"
#include "EnhancedInputComponent.h"
//声明动作
UPROPERTY(EditAnywhere, Category = "Input")
UInputAction* MoveAction;
//声明动作函数
void MoveInput();
11.3在Tank.cpp中定义函数,并且把函数和动作绑定
//Tank.cpp
//把函数和动作绑定
void ATank::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
if (UEnhancedInputComponent* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
EIC->BindAction(MoveAction,ETriggerEvent::Triggered,this, &ATank::MoveInput);
}
}
//定义函数
void ATank::MoveInput()
{
}
11.4在BP_Tank的蓝图中把之前定义的动作传给Tank

11.5修改按键效果
现在有个问题就是:我们的前进w和后退s都是对应一个动作,我们需要区分w和s, 使其在响应函数函数中能够反馈不同的值;
点击IA_Move蓝图, 然后在操作---值类型---修改类型为浮点型

重写MoveInput()函数,我们想要让其具备识别w和s的功能
用FInputActionValue类实例可以获取到按键反馈的信息,在这个信息中可以用其成员函数Get(), 提取出InputValue,每一次按下w或者s就会赋值一次InputValue.
//Tank.h-------------------------------------------
void MoveInput(const FInputActionValue& Value);
//Tank.cpp-----------------------------------------
void ATank::MoveInput(const FInputActionValue& Value)
{
float InputValue = Value.Get<float>();
UE_LOG(LogTemp, Display, TEXT("InputValue: %f"),InputValue);
}
打开IMC_Default, 然后为s添加一个修改器"否定"

这样按下w时InputValue = 1;按下s时InputValue = -1;
步骤12:为Tank实现效果:前后移动
既然我们已经可以通过w或s修改InputValue的值,那么也可以控制物体的前后:
使用AddActorLocalOffset()函数,会在局部空间中给角色一个偏移
#include "Kismet/GameplayStatics.h"//为了调用这个函数GetWorldDeltaSeconds
void ATank::MoveInput(const FInputActionValue& Value)
{
//获取InputValue的值
float InputValue = Value.Get<float>();
//定义增量初始值
FVector DeltaLocation = FVector(0.0f, 0.0f, 0.0f);
//修改增量X的值
DeltaLocation.X = Speed * InputValue * UGameplayStatics::GetWorldDeltaSeconds(GetWorld());
//UGameplayStatics::GetWorldDeltaSeconds(GetWorld())相当于就是获取了DeltaTime.
AddActorLocalOffset(DeltaLocation,true);//true表示遇到障碍物会停下,false会直接穿模
}
步骤13:为坦克创建并实现动作:左右转向
13.1创建转向动作并且命名为IA_Turn,设置值类型为浮点型

13.2在输入映射上下文IMC_Default上绑定动作,并且为动作绑定按键,要把A按键设为否定


13.3在C++,Tank类中声明动作, 动作速度和响应函数.
//Tank.h
UPROPERTY(EditAnywhere, Category = "Input")
UInputAction* TurnAction;//左右转向
float Speed_Turn = 30.0f;//转向速度
void TurnInput(const FInputActionValue& Value);//转向响应函数
void ATank::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
if (UEnhancedInputComponent* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
EIC->BindAction(MoveAction, ETriggerEvent::Triggered, this, &ATank::MoveInput);
EIC->BindAction(TurnAction,ETriggerEvent::Triggered,this, &ATank::TurnInput);
//绑定动作和响应函数
}
}
//实现响应函数
void ATank::TurnInput(const FInputActionValue& Value)
{
float InputValue = Value.Get<float>();
FRotator DeltaAngle = FRotator(0.0f, 0.0f, 0.0f);
DeltaAngle.Yaw = Speed_Turn * InputValue * UGameplayStatics::GetWorldDeltaSeconds(GetWorld());
AddActorLocalRotation(DeltaAngle);
}
13.4把之前创建的动作传入坦克蓝图

目前已经可以控制坦克的前后和转向了
13.4优化转向效果
打开BP_Tank蓝图,把弹簧臂的摄像机延时打开

步骤14.鼠标控制炮塔
我们要实现功能:让坦克的炮塔mesh一直追踪朝向鼠标位置,只要点击就会发射炮弹.
14.1实时获取鼠标的位置
//Tank.cpp
/**
* 坦克类的Tick函数(每帧执行)
* 核心功能:获取玩家鼠标光标指向的世界碰撞点,并在该位置绘制红色调试球体,用于可视化光标瞄准位置
* @param DeltaTime 从上一帧到当前帧的时间间隔(秒),用于帧速率无关的逻辑计算
*/
void ATank::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 获取当前坦克所属的控制器,并安全转换为玩家控制器类型(APlayerController)
// Cast是虚幻引擎的安全类型转换函数,若转换失败会返回nullptr,避免非法指针访问
APlayerController* PlayerController = Cast<APlayerController>(GetController());
// 检查玩家控制器是否有效(避免空指针访问)
if (PlayerController)
{
// 声明碰撞结果结构体,用于存储鼠标光标下的碰撞信息(位置、法线、碰撞对象等)
FHitResult HitResult;
// 获取鼠标光标正下方的世界碰撞结果
// 参数1:ECC_Visibility - 使用"可见性"碰撞通道(只检测能被看到的对象,忽略碰撞体但检测视觉几何体)
// 参数2:false - 不忽略自身(即坦克自身也会被检测)
// 参数3:HitResult - 输出参数,存储碰撞结果数据
PlayerController->GetHitResultUnderCursor(ECC_Visibility, false, HitResult);
// 在碰撞点绘制调试球体(仅在编辑器/开发模式下可见)
// 参数1:GetWorld() - 获取当前游戏世界上下文
// 参数2:HitResult.ImpactPoint - 碰撞的实际接触点(世界坐标)
// 参数3:25.0f - 球体半径(单位:厘米,虚幻默认单位)
// 参数4:12 - 球体分段数(数值越大球体越光滑,性能消耗越高)
// 参数5:FColor::Red - 球体绘制颜色(红色)
DrawDebugSphere(GetWorld(), HitResult.ImpactPoint, 25.0f, 12, FColor::Red);
}
}
14.2 设置阻挡体积
这个方法有个小缺点:就是如果光标放在无法命中的区域,就会导致无法采集鼠标位置信息,tank也无法转向.
解决方案很简单: 把外部区域加上一个透明的可以被选中的区域(阻挡体积)(空气墙)




打开其碰撞设置: 我们将预设设定为自定义,忽略一切,只保留Visibility为Block.
现在就可以看到在地图外也能选中了.

题外话: 为什么不用缩放用画刷?其实在这里由于这个阻挡体积是透明的,用哪个都无所谓,但是如果操作对象有纹理,不建议用缩放,因为缩放会导致纹理的拉伸.
影响项 画刷设置 缩放 几何精度 基于原始尺寸生成顶点,无拉伸畸变(比如圆柱分段数设为 32,边缘光滑) 仅缩放已有顶点,非均匀缩放(如 X=2、Y=1)会导致几何 / 纹理拉伸畸变 碰撞体 碰撞体基于原始尺寸生成,精度高、无畸变 碰撞体随缩放拉伸,非均匀缩放易出现碰撞穿模 / 精度问题 纹理 UV UV 基于原始尺寸映射,比例正常 纹理随缩放拉伸(比如缩放 X=2,纹理会被拉长 2 倍) 光照烘焙 光照贴图密度基于原始尺寸计算,光照均匀 缩放后易出现光照贴图拉伸、漏光或阴影畸变
14.3 让炮塔mesh朝向光标位置
由于玩家操作的tank和敌方tank都需要转向功能,所以这个功能我们写到他们共同的父类(BasePawn)中

//-------------BasePawn.h----------------------------------------------
void RotateTurret(FVector LookAtTarget);
//-------------BasePawn.cpp----------------------------------------------
/**
* 基类Pawn的炮塔旋转核心函数
* 功能:计算炮塔指向目标位置的旋转角度,并让炮塔网格体仅绕Z轴(水平方向)旋转朝向目标
* 适用场景:坦克/炮台等需要炮塔水平瞄准目标的Pawn类
* @param LookAtTarget 目标位置的世界空间坐标(如鼠标点击点、敌方位置)
*/
void ABasePawn::RotateTurret(FVector LookAtTarget)
{
// 1. 计算从炮塔当前位置指向目标位置的方向向量
// TurretMesh->GetComponentLocation():获取炮塔网格体的世界空间中心位置
// LookAtTarget - 炮塔位置:得到“炮塔→目标”的方向向量(仅表示方向和距离,无旋转信息)
FVector VectorToTarget = LookAtTarget - TurretMesh->GetComponentLocation();
// 2. 从方向向量提取旋转信息,并仅保留水平旋转(Yaw偏航)
// VectorToTarget.Rotation():将方向向量转换为对应的旋转器(包含Pitch/Yaw/Roll)
// FRotator(0, Yaw, 0):强制将俯仰(Pitch)和翻滚(Roll)设为0,避免炮塔上下/侧翻(符合炮塔仅水平旋转的设计)
FRotator LookAtRotation = FRotator(0.0f, VectorToTarget.Rotation().Yaw, 0.0f);
// 3. 应用旋转到炮塔网格体
// SetWorldRotation:直接设置组件的世界空间旋转,让炮塔朝向目标方向
TurretMesh->SetWorldRotation(LookAtRotation);
}
回到Tank.cpp的Tick函数中调用RotateTurret(HitResult.ImpactPoint);
//Tank.cpp
void ATank::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 获取当前坦克所属的控制器,并安全转换为玩家控制器类型(APlayerController)
// Cast是虚幻引擎的安全类型转换函数,若转换失败会返回nullptr,避免非法指针访问
APlayerController* PlayerController = Cast<APlayerController>(GetController());
// 检查玩家控制器是否有效(避免空指针访问)
if (PlayerController)
{
// 声明碰撞结果结构体,用于存储鼠标光标下的碰撞信息(位置、法线、碰撞对象等)
FHitResult HitResult;
// 获取鼠标光标正下方的世界碰撞结果
// 参数1:ECC_Visibility - 使用"可见性"碰撞通道(只检测能被看到的对象,忽略碰撞体但检测视觉几何体)
// 参数2:false - 不忽略自身(即坦克自身也会被检测)
// 参数3:HitResult - 输出参数,存储碰撞结果数据
PlayerController->GetHitResultUnderCursor(ECC_Visibility, false, HitResult);
RotateTurret(HitResult.ImpactPoint);
}
}
14.4 赋值朝向改为插值朝向:
通过这几个步骤,现在勉强可以让tank炮塔跟随光标,但是还有一个缺点,就是这个炮塔旋转很突兀,不够丝滑,因为每次旋转,其实都不算旋转,是直接修改炮塔朝向到光标,没有任何过度.
解决方案:把赋值朝向改为插值朝向
//BasePawn.cpp
/**
* 基类Pawn的炮塔旋转核心函数
* 功能:计算炮塔指向目标位置的旋转角度,并让炮塔网格体仅绕Z轴(水平方向)旋转朝向目标
* 适用场景:坦克/炮台等需要炮塔水平瞄准目标的Pawn类
* @param LookAtTarget 目标位置的世界空间坐标(如鼠标点击点、敌方位置)
*/
void ABasePawn::RotateTurret(FVector LookAtTarget)
{
// 1. 计算从炮塔当前位置指向目标位置的方向向量
// TurretMesh->GetComponentLocation():获取炮塔网格体的世界空间中心位置
// LookAtTarget - 炮塔位置:得到“炮塔→目标”的方向向量(仅表示方向和距离,无旋转信息)
FVector VectorToTarget = LookAtTarget - TurretMesh->GetComponentLocation();
// 2. 从方向向量提取旋转信息,并仅保留水平旋转(Yaw偏航)
// VectorToTarget.Rotation():将方向向量转换为对应的旋转器(包含Pitch/Yaw/Roll)
// FRotator(0, Yaw, 0):强制将俯仰(Pitch)和翻滚(Roll)设为0,避免炮塔上下/侧翻(符合炮塔仅水平旋转的设计)
FRotator LookAtRotation = FRotator(0.0f, VectorToTarget.Rotation().Yaw, 0.0f);
// 3. 计算平滑插值后的旋转(核心优化:避免炮塔瞬间转向,实现流畅的旋转过渡)
// FMath::RInterpTo:UE内置的旋转插值函数,基于帧间隔实现线性插值旋转
// 参数1:插值起始值 - 炮塔当前的实际旋转角度(从组件获取)
// 参数2:插值目标值 - 计算出的朝向目标的理想旋转角度
// 参数3:时间步长 - 从上一帧到当前帧的时间间隔(GetDeltaSeconds()保证旋转速度与帧率无关)
// 参数4:插值速度 - 旋转的平滑系数(数值越大旋转越快,10.0f为兼顾手感的经验值,可根据需求调整)
FRotator InterpolatedRotation = FMath::RInterpTo(
TurretMesh->GetComponentRotation(), // 起始旋转
LookAtRotation, // 目标旋转
GetWorld()->GetDeltaSeconds(), // 帧间隔时间
10.0f // 插值速度
);
// 4. 将平滑插值后的旋转应用到炮塔网格体
// 替换直接设置目标旋转的方式,让炮塔以渐变方式转向目标,提升游戏视觉反馈
TurretMesh->SetWorldRotation(InterpolatedRotation);
}
章节2 :设置敌人
步骤1. 创建并且配置C++类
新建C++类, 继承自BasePawn,命名为Tower.

//Tower.h
#pragma once
#include "CoreMinimal.h"
#include "BasePawn.h"
//包含Tank的头文件,以后敌人会获取Tank的坐标,然后转向Tank
#include "Tank.h"
#include "Tower.generated.h"
UCLASS()
class BATTLEBLASTER_API ATower : public ABasePawn
{
GENERATED_BODY()
protected:
virtual void BeginPlay() override;
public:
virtual void Tick(float DeltaTime) override;
ATank* Tank;
};
//Tower.cpp
#include "Tower.h"
void ATower::BeginPlay()
{
Super::BeginPlay();
}
void ATower::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
步骤2.创建并配置敌人蓝图
2.1在蓝图文件夹创建蓝图,继承自Tower,命名为BP_Tower

2.2为底座和炮塔添加对应的网格体

2.3调整胶囊体和网格的位置

2.4修改碰撞预设为pawn

步骤3:放置敌人
直接从蓝图中把敌人拖到场景中,然后自己调整位置

为了方便管理: 在大纲中新建一个文件夹专门用于放置敌人

步骤4:建立敌人和玩家的联系
让敌人感知到玩家的位置,然后瞄准玩家
在章节2的步骤1中,我们在Tower.h中声明了一个ATank* Tank;的指针用于让一个敌人获取玩家的指针;但是我们有多个敌人,每个敌人都需要获取到玩家的指针
我们不妨在游戏模式类里设置这些:在游戏模式里面找出所有敌人和玩家.
4.1 获取所有敌人指针
获取到所有敌人指针后,为每个敌人绑定玩家的指针
//BattleBlasterGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "BattleBlasterGameMode.generated.h"
UCLASS()
class BATTLEBLASTER_API ABattleBlasterGameMode : public AGameModeBase
{
GENERATED_BODY()
protected:
virtual void BeginPlay() override;
public:
ATank* Tank;
int32 TowerCount;//记录敌人数量
};
//BattleBlasterGameMode.cpp
// 引入游戏模式头文件(当前类的核心声明)
#include "BattleBlasterGameMode.h"
// 引入UE内置的游戏玩法静态工具类(提供Actor查找、玩家获取等通用功能)
#include "Kismet/GameplayStatics.h"
// 引入塔楼Actor的头文件(用于识别场景中的塔楼类)
#include "Tower.h"
void ABattleBlasterGameMode::BeginPlay()
{
// 调用父类的BeginPlay函数,确保游戏模式的基础初始化逻辑正常执行(UE重写虚函数必调)
Super::BeginPlay();
// 1. 获取场景中所有塔楼ATower的指针
// 声明TArray数组,用于存储场景中所有ATower类型的Actor指针
TArray<AActor*> Towers;
// 调用UE静态工具函数,批量获取世界中指定类的所有Actor
// 参数1:GetWorld() - 当前游戏世界上下文(确定查找范围)
// 参数2:ATower::StaticClass() - 要查找的Actor类(塔楼类)
// 参数3:Towers - 输出参数,查找到的所有塔楼Actor会存入该数组
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATower::StaticClass(), Towers);
// 将塔楼数组的元素数量赋值给游戏模式的TowerCount变量(记录总塔楼数)
TowerCount = Towers.Num();
UE_LOG(LogTemp, Display, TEXT("Number of towers: %d"), TowerCount);
// 2. 获取玩家控制的坦克(ATank)指针
// 注释:该游戏设计为单玩家模式,因此直接获取索引0的玩家Pawn(无需数组存储)
// UGameplayStatics::GetPlayerPawn:获取指定索引的玩家控制的Pawn对象
APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
if (PlayerPawn)
{
// 将通用的APawn类型安全转换为游戏自定义的ATank类型
// 转换后赋值给游戏模式的Tank成员变量,后续可通过该指针监听坦克状态、调用坦克方法
Tank = Cast<ATank>(PlayerPawn);
if (!Tank)
{
UE_LOG(LogTemp, Display, TEXT("GameMode: Failed to find the tank actor!"));
}
}
}
4.2让敌人瞄准玩家
很简单,在Tower.cpp的Tick()中复用一下父类的转向函数就行:
void ATower::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if(Tank){
RotateTurret(Tank->GetActorLocation());
}
}
但是这有一个缺点,就是无论玩家在哪都会被敌人瞄准,为了解决这个问题:
//Tower.h
//设置敌人的探测距离
UPROPERTY(EditAnywhere)
float FireRange = 800.0f;
//Tower.cpp
#include "Tower.h"
void ATower::BeginPlay()
{
Super::BeginPlay();
}
void ATower::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (Tank) {
float DistanceToTank = FVector::Dist
(GetActorLocation(), Tank->GetActorLocation());
if (DistanceToTank <= FireRange) {
RotateTurret(Tank->GetActorLocation());
}
}
}
章节3: 搭建投射物系统
玩家坦克和敌人都可以开火,所有我们可以把开火函数写在他们共同的父类中
步骤1:创建开火动作
1.1创建开火动作
创建开火动作IA_Fire, 然后在输入映射上下文里面绑定动作为鼠标左键


1.2类中声明动作和响应函数
在玩家坦克类中声明开火动作和并且绑定开火函数:
//Tank.h
UPROPERTY(EditAnywhere, Category = "Input")
UInputAction* FireAction;//开火动作
//BasePawn.cpp
void ATank::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
if (UEnhancedInputComponent* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
EIC->BindAction(MoveAction, ETriggerEvent::Triggered, this, &ATank::MoveInput);
EIC->BindAction(TurnAction,ETriggerEvent::Triggered,this, &ATank::TurnInput);
//把开火函数绑定到开火动作
EIC->BindAction(FireAction, ETriggerEvent::Started,this, &ATank::Fire);
}
}
void ABasePawn::Fire()
{
}
把之前创建的动作传入BP_Tank蓝图//和BP_Tower蓝图

步骤2 选择弹幕生成位置:
子弹生成的位置 的选择需要注意一下几点:
1.不能离本体的网格太近了, 不然会导致炮弹直接打到自己身上;
2.弹幕位置必须和炮塔相绑定,炮塔旋转,弹幕位置也要围绕炮塔转
可以直接在炮塔前面放置一个场景组件,然后让场景组件和炮塔绑定,开火时直接获取这个组件的位置作为子弹起始位置,这样就不要用数学手段计算弹幕的实时位置了.
2.1 声明一个组件
//BasePawn.h
UPROPERTY(VisibleAnywhere)
USceneComponent* ProjectileSpawnPoint;
2.2 把弹幕附加到炮塔网格上
//BasePawn.cpp
ABasePawn::ABasePawn()
{
PrimaryActorTick.bCanEverTick = true;
// 1. 创建并设置胶囊体为根组件
CapsuleComp = CreateDefaultSubobject<UCapsuleComponent>(TEXT("CapsuleComp"));
SetRootComponent(CapsuleComp); // 设置为根
CapsuleComp->InitCapsuleSize(55.f, 100.f);
// 2. 创建底座网格,并附加到胶囊体上
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
BaseMesh->SetupAttachment(RootComponent); // 附加到根组件
// 3. 创建炮塔网格,并附加到底座网格上(这样炮塔旋转时,底座不会跟着转)
TurretMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("TurretMesh"));
TurretMesh->SetupAttachment(BaseMesh); // 附加到底座,形成层级
//------------------------------------------------
//4. 设置弹幕生成点,将其附加到炮塔网格
ProjectileSpawnPoint = CreateDefaultSubobject<USceneComponent>(TEXT
("ProjectileSpawnPoint"));
ProjectileSpawnPoint->SetupAttachment(TurretMesh);
//------------------------------------------------
}
2.3在蓝图中设置弹幕起始点
在BP_Tank蓝图和BP_Tower蓝图中设置弹幕起始点位置


2.4Fire()函数获取弹幕位置
//BasePawn.cpp
void ABasePawn::Fire()
{
FVector SpawnLocation = ProjectileSpawnPoint->GetComponentLocation();
FRotator SpawnRotation = ProjectileSpawnPoint->GetComponentRotation();
//以下两个函数用于生成调试线条
DrawDebugSphere(GetWorld(), SpawnLocation, 25.0f, 12, FColor::Red, false, 5.0f);
/*
要计算 SpawnLocation 沿 SpawnRotation 方向延伸 1000.0f 的坐标,
核心思路是将旋转器转换为前向单位向量 → 缩放向量长度至 1000 → 与原始位置叠加
*/
FVector TargetLocation = SpawnLocation + SpawnRotation.Vector() * 1000.0f;
DrawDebugLine(GetWorld(), SpawnLocation, TargetLocation, FColor::Blue,false,5.0f);
}

步骤3:设置定时器
3.1 声明定时器
敌人开炮需要定时器用来调控, 每隔2s尝试一次开火,如果玩家在射程内就会开火,如果不在就不开
//Tower.h
UPROPERTY(EditAnywhere)
float FireRate = 2.0f;//发射的间隔默认设置为2s
void CheckFireCondition();//定时器触发时要执行的函数(检查开火条件的成员函数)
核心功能:初始化塔楼的自动开火定时器,按固定间隔循环检测开火条件
* 设计逻辑:塔楼无需手动触发开火,通过定时器实现“每隔X秒检查一次是否能开火”的自动化逻辑
//Tower.cpp
void ATower::BeginPlay()
{
Super::BeginPlay();
// 声明定时器句柄(FTimerHandle):用于唯一标识这个开火定时器,后续可通过句柄暂停/清除定时器
// 注意:该句柄是局部变量,但SetTimer会关联到世界定时器管理器,无需担心生命周期
FTimerHandle FireTimerHandle;
// 获取世界定时器管理器,并设置循环定时器
// 定时器管理器(FTimerManager)是UE负责管理所有定时器的核心模块,通过GetWorld()获取
GetWorldTimerManager().SetTimer(
FireTimerHandle, // 参数1:定时器句柄(标识当前定时器)
this, // 参数2:回调函数所属的对象(当前塔楼实例)
&ATower::CheckFireCondition,
// 参数3:定时器触发时要执行的函数(检查开火条件的成员函数)
FireRate, // 参数4:触发间隔(秒),FireRate是塔楼类的自定义参数(如2.0f表示每2秒触发一次)
true // 参数5:是否循环触发(true=循环,false=仅触发一次)
);
}
3.2 实现开火检测函数的功能:
写法1:
//Tower.cpp
void ATower::CheckFireCondition()
{
if (Tank) {
float DistanceToTank = FVector::Dist(GetActorLocation(), Tank->GetActorLocation());
if (DistanceToTank <= FireRange) {
Fire();
}
}
}
写法2:
//--------------Tower.h-----------------------------
bool InFireRange();//此函数专门用于检测玩家是否进入射程
//--------------Tower.cpp---------------------------
bool ATower::InFireRange() {
return Tank && (FVector::Dist(GetActorLocation(), Tank->GetActorLocation()) <= FireRange);
}
void ATower::CheckFireCondition()
{
if (InFireRange()) {
Fire();
}
}
步骤4:设置投射物网格
创建一个C++actor类命名为Projectile

创建一个静态网格体组件:StaticMeshComponent然后把这个组件设置成根组件
//Projectile.h---------------------------
UPROPERTY(VisibleAnywhere)
UStaticMeshComponent* ProjectileMesh;
//Projectile.cpp-------------------------
AProjectile::AProjectile()
{
PrimaryActorTick.bCanEverTick = true;
ProjectileMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ProjectileMesh"));
SetRootComponent(ProjectileMesh);
}
创建一个蓝图继承自Projectile, 命名为BP_Projectile, 给这个蓝图挂上SM_Projectile网格

把碰撞预设设置为全部:BlockAll

步骤5: 修改开火函数
在BasePawn.h中声明一个ProjectileClass成员,用来接收一个Projectile类,然后SpawnActor函数ke'yi根据这个类在游戏场景里面产生实例对象;
//BasePawn.h-------------------------
#include "Projectile.h"
UPROPERTY(EditAnywhere)
TSubclassOf<AProjectile> ProjectileClass;
在BasePawn.cpp中修改Fire()
//BasePawn.cpp------------------------------------
void ABasePawn::Fire()
{
FVector SpawnLocation = ProjectileSpawnPoint->GetComponentLocation();
FRotator SpawnRotation = ProjectileSpawnPoint->GetComponentRotation();
GetWorld()->SpawnActor<AProjectile>(
ProjectileClass,
SpawnLocation,
SpawnRotation
);
//以下两个函数用于生成调试线条
DrawDebugSphere(GetWorld(), SpawnLocation, 25.0f, 12, FColor::Red, false, 5.0f);
/*
要计算 SpawnLocation 沿 SpawnRotation 方向延伸 1000.0f 的坐标,
核心思路是将旋转器转换为前向单位向量 → 缩放向量长度至 1000 → 与原始位置叠加
*/
FVector TargetLocation = SpawnLocation + SpawnRotation.Vector() * 1000.0f;
DrawDebugLine(GetWorld(), SpawnLocation, TargetLocation, FColor::Blue,false,5.0f);
}
修改玩家Tank和敌人Tower的蓝图, 为ProjectileClass挂载BP_Projectile类, 然后Fire函数就可以根据这个类在游戏中生成实例对象


步骤6: 处理子弹移动
可以用一个超强的组件, 管理子弹的行为!
//Projectile.h---------------------------------------------
#include "GameFramework/ProjectileMovementComponent.h"
UPROPERTY(VisibleAnywhere)
UProjectileMovementComponent* ProjectileMovementComp;
//Projectile.cpp
AProjectile::AProjectile()
{
PrimaryActorTick.bCanEverTick = true;
ProjectileMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ProjectileMesh"));
SetRootComponent(ProjectileMesh);
ProjectileMovementComp = CreateDefaultSubobject
<UProjectileMovementComponent>(TEXT("ProjectileMovementComp"));
ProjectileMovementComp->InitialSpeed = 4000.0f;
ProjectileMovementComp->MaxSpeed = 1000.0f;
}

步骤6: 处理子弹命中事件
-1.设置子弹的归属者
为子弹设置所属者,之后就可以根据子弹,判断伤害
//BasePawn.cpp
void ABasePawn::Fire()
{
FVector SpawnLocation = ProjectileSpawnPoint->GetComponentLocation();
FRotator SpawnRotation = ProjectileSpawnPoint->GetComponentRotation();
AProjectile* Projectile = GetWorld()->SpawnActor<AProjectile>(ProjectileClass,SpawnLocation,SpawnRotation);
if (Projectile) {
Projectile->SetOwner(this);
/*AActor* ProjectileOwner = Projectile->GetOwner();
if (ProjectileOwner) {
UE_LOG(LogTemp, Display, TEXT("Projectile owner: %s"),
*ProjectileOwner->GetActorNameOrLabel());
}*/
}
}
声明一个委托函数;
//Projectile.h
/*
【核心功能】UPrimitiveComponent 组件"物理碰撞命中(Hit)"事件的回调函数
遵循UE内置的 OnComponentHit 委托签名,当当前Actor的Primitive组件(如胶囊体、静态网格、碰撞体)
与其他Actor/组件发生「实质性物理碰撞」(非触发器/重叠事件)时,UE引擎会自动调用此函数
开发者可在函数内编写碰撞后的业务逻辑(如扣血、播放音效、施加物理力、生成特效等)
【触发前提】
1. 目标Primitive组件需开启 bGenerateHitEvents;
2. 函数需绑定到组件的 OnComponentHit 委托;
3. 碰撞为"阻挡型"(非Trigger)
*/
UFUNCTION()
void OnHit(
UPrimitiveComponent* HitComponent,
// 【自身碰撞组件】触发本次碰撞的「当前Actor」的Primitive组件(如角色的胶囊体、武器的碰撞体)
AActor* OtherActor,
// 【碰撞对方Actor】与自身发生碰撞的「另一方」所属的Actor(可能为空,必须先判空)
UPrimitiveComponent* OtherComp,
// 【对方碰撞组件】OtherActor 上实际发生碰撞的Primitive组件(如敌人的静态网格体、场景的碰撞盒)
FVector NormalImpulse,
// 【碰撞冲量法线】碰撞产生的冲量方向(单位向量),代表碰撞力的作用方向(用于计算反弹/击退)
const FHitResult& Hit
// 【碰撞详细结果】只读结构体,包含碰撞点、表面法线、物理材质、碰撞类型等核心信息
);
把委托函数绑定委托实例:
//Projectile.cpp
void AProjectile::BeginPlay()
{
Super::BeginPlay();
ProjectileMesh->OnComponentHit.AddDynamic(this, &AProjectile::OnHit);
}
void AProjectile::OnHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
if (OtherActor) {
UE_LOG(LogTemp, Display, TEXT("Projectile hit: %s"),
*OtherActor->GetActorNameOrLabel());
}
}
0. 打开BP_Projectile的碰撞---模拟生成命中事件:
开启该开关后,UE 的物理引擎(PhysX)会在组件发生实质性物理碰撞时,“模拟 / 检测” 这个碰撞过程,并自动生成 OnComponentHit 命中事件(触发之前的 OnHit 回调函数);关闭则即使组件发生物理碰撞,引擎也不会生成该事件,OnComponentHit 委托永远不会触发。

在碰撞预设栏:选择自定义"Custom",然后把对象类型改为世界动态"WorldDynamic"

1: 玩家生命值组件
由于生命值这个属性是个非常基础的属性, 很多各种各样的类都需要用到, 不如直接做成一个组件, 如果哪个类需要此功能就直接复用, 非常方便
创建组件,命名为HealthComponent

2. 设置生命值相关的属性
//HealthComponent.h
//设置玩家最大生命
UPROPERTY(EditAnywhere)
float MaxHealth = 100.0f;
//保存玩家当前生命
UPROPERTY(VisibleAnywhere)
float Health;
//HealthComponent.cpp
void UHealthComponent::BeginPlay()
{
Super::BeginPlay();
//初始化玩家的生命值
Health = MaxHealth;
}
3.回到炮弹Projectile.cpp,为其设置伤害值
//Projectile.h
UPROPERTY(EditAnywhere)
float Damage = 25.0f;
4.把生命值组件绑定到角色和敌人蓝图
为了方便控制敌人和角色各自的生命值, 所以不绑到其父类BasePawn上;注意你搜索组件的时候,直接搜Health就行,后面的Component引擎会自动去掉;

5.触发伤害,降低生命值

ApplyDamage() 函数调用
↓
触发伤害处理逻辑
↓
自动触发 OnTakeAnyDamage 委托
↓
执行所有绑定到此委托的处理函数
我们将会调用一个库函数ApplyDamage帮我判断命中, 如果命中, 会触发一个委托, 委托会触发一个函数, 函数内部有我们写的扣除生命值的逻辑;
//Projectile.cpp
#include "Kismet/GameplayStatics.h"
void AProjectile::OnHit(
UPrimitiveComponent* HitComponent, // 碰撞组件:炮弹自身的碰撞体组件
AActor* OtherActor, // 其他Actor:被炮弹击中的Actor对象
UPrimitiveComponent* OtherComp, // 其他碰撞组件:被击中Actor的碰撞组件
FVector NormalImpulse, // 法向冲量:碰撞时产生的冲击力向量
const FHitResult& Hit // 命中结果:详细的碰撞检测结果数据
)
{
/*
// 调试日志:打印被击中Actor的名称(已注释掉)
if (OtherActor) {
UE_LOG(LogTemp, Display, TEXT("Projectile hit: %s"),
*OtherActor->GetActorNameOrLabel());
}
*/
// 获取此炮弹的所属者(即发射此炮弹的Actor)
AActor* MyOwner = GetOwner();
// 多重安全检查:确保应用伤害的条件全部满足
if (MyOwner && // 1. 炮弹必须有所有者(避免空指针访问)
OtherActor && // 2. 必须确实击中了某个有效Actor
(OtherActor != MyOwner) && // 3. 避免伤害自己:不能击中发射者自身
(OtherActor != this)) // 4. 避免自伤:不能击中炮弹自己(边缘情况防护)
{
// 此函数用于对 被炮弹击中的Actor对象 造成伤害
UGameplayStatics::ApplyDamage(
OtherActor, // 目标Actor:要接收伤害的对象
Damage, // 伤害值:炮弹的伤害量(为类成员变量25.0f)
MyOwner->GetInstigatorController(), // 伤害发起控制器:所有者的控制器(用于记录伤害来源)
this, // 伤害造成者:炮弹自身(用于追踪具体伤害来源)
UDamageType::StaticClass() // 伤害类型类:使用基础伤害类型(可扩展为自定义类型)
);
// 注意:ApplyDamage会触发目标Actor的TakeDamage事件,目标需要有相应的处理逻辑
}
// 无论是否造成伤害,碰撞后都销毁炮弹对象
// Destroy()会延迟到当前帧结束时执行,确保当前函数逻辑完整执行
Destroy();
// 可选扩展:可在此处添加命中特效(粒子、音效、屏幕震动等)
// 例如:UGameplayStatics::SpawnEmitterAtLocation(...) 播放命中粒子效果
}
6. 使用伤害委托

设置受伤委托的响应函数, 这个函数内部我们会写具体的扣生命值的逻辑, 函数参数这么多是为了符合委托的语法规定, 所有的响应函数签名的参数部分必须类型相同, 这个在委托声明时就已经固定,
//HealthComponent.h
//设置响应函数
UFUNCTION()
void OnDamageTaken(
AActor* DamagedActor,
float Damage,
const class UDamageType* DamageType,
class AController* InstigatedBy,
AActor* DamageCauser
);
如何查看委托的响应函数需要什么参数类型?先点击委托实例,然后速览定义,再对委托类速览定义, 然后就可以看到委托类的参数要求, 从OnTakeAnyDamage实例开始, 后面的是类型, 参数, 类型, 参数....交替的, 全部复制过来, 把中间的逗号去掉就行,不要复制AActor OnTakeAnyDamage



如果对这个委托有疑问, 可以看这个文章:草履虫也能看懂的虚幻引擎的委托机制-优快云博客
//HealthComponent.cpp
void UHealthComponent::BeginPlay()
{
Super::BeginPlay();
// 初始化当前生命值为最大生命值
// 通常在游戏开始或对象生成时将生命值设为满值
Health = MaxHealth;
// 获取此组件的所有者(一个AActor),并将其OnTakeAnyDamage委托绑定到本组件的OnDamageTaken函数
// OnTakeAnyDamage是AActor的内置委托,当该Actor受到伤害时会被触发
// AddDynamic将我们的成员函数绑定到该委托,使本组件能响应伤害事件
GetOwner()->OnTakeAnyDamage.AddDynamic(this, &UHealthComponent::OnDamageTaken);
}
// 伤害处理函数,当所属Actor受到伤害时自动调用
// 函数签名必须与OnTakeAnyDamage委托的参数类型完全匹配
void UHealthComponent::OnDamageTaken(
AActor* DamagedActor, // 受到伤害的Actor(通常是本组件的所有者)
float Damage, // 受到的伤害值(来自ApplyDamage()函数的Damage参数)
const UDamageType* DamageType, // 伤害类型(如普通伤害、火焰伤害等)
AController* InstigatedBy, // 伤害发起者的控制器(用于追踪谁造成了伤害)
AActor* DamageCauser // 直接造成伤害的Actor(如子弹、爆炸物等)
)
{
// 安全检查:确保伤害值大于0,避免无效伤害或治疗逻辑错误
// 如果未来需要处理负伤害(治疗),可以移除此条件或修改逻辑
if (Damage > 0.0f) {
// 扣除生命值:当前生命值减去伤害值
Health -= Damage;
// 检查生命值是否耗尽(小于等于0)
if (Health <= 0.0f) {
// 生命值耗尽,销毁所属的Actor
// Destroy()会移除Actor及其所有组件,触发死亡逻辑
GetOwner()->Destroy();
// 注意:在此处还可以触发更多死亡相关逻辑,如:
// 1. 播放死亡动画
// 2. 掉落物品
// 3. 触发游戏事件
// 4. 生成特效和音效
}
// 注意:此处没有触发生命值变化事件
// 如需通知其他系统(如UI更新),应在此处添加事件广播
}
}
到这里,我们游戏的主体已经完成了, 我们可以通过发射炮弹, 每一个炮弹的伤害是25点, 打中四次, 就可以消灭一个敌人, 同样的, 我们被打四次也会阵亡; 但是我们如果阵亡, 玩家角色的镜头也会被消除, 所以我们需要再优化, 除此之外我们的游戏还不够丰满, 我想为炮弹添加一些特效, 命中时产生爆炸粒子, 以及一些音效;
7.角色阵亡处理函数构建
在实际游戏开发中, 角色阵亡不能粗暴地把角色实例Destory, 而是需要单独设计一个函数用来处理角色阵亡相关的逻辑.
玩家伪阵亡: 如果玩家Tank阵亡, 直接Destory, 玩家角色的镜头也会被消除, 所以我们需要再优化, 不是单纯Destory玩家, 而是将其隐藏, 然后屏蔽Tank的外部输入, 为其添加一个存活状态, 并将其状态设置为阵亡, 调整敌人瞄准函数逻辑---检测玩家Tank是否存活, 如果不存活不执行瞄准射击逻辑
为了达到以上目标, 我们需要重构角色生命值低于0后的逻辑, 不是单纯的Destory角色, 而是要执行一套复杂流程:
我们需要把玩家阵亡的信息传递给GameMode, 然后在GameMode里面检测阵亡角色是敌人还是玩家, 如果是玩家应该要隐藏,并且输出游戏失败, 如果是敌人就直接销毁, 而且要扣除敌人数量, 检测敌人数量如果为0, 则玩家胜利.

先提前写一下GameMode里处理角色阵亡的函数
//BattleBlasterGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "Tank.h"
#include "BattleBlasterGameMode.generated.h"
UCLASS()
class BATTLEBLASTER_API ABattleBlasterGameMode : public AGameModeBase
{
GENERATED_BODY()
protected:
virtual void BeginPlay() override;
public:
ATank* Tank;
int32 TowerCount;//记录敌人数量
//处理角色阵亡的函数
void ActorDied(AActor* DeadActor);
};
//BattleBlasterGameMode.cpp
void ABattleBlasterGameMode::ActorDied(AActor* DeadActor)
{
}
由于我们需要把角色阵亡的消息传递给GameMode, 所以我们要把"BattleBlasterGameMode.h"加入HealthComponent
//HealthComponent.h
#include "BattleBlasterGameMode.h"
//存储游戏模式的指针,方便类间通信,后续把角色阵亡信息发给游戏模式
ABattleBlasterGameMode* BattleBlasterGameMode;
//HealthComponent.cpp
void UHealthComponent::BeginPlay()
{
Super::BeginPlay();
Health = MaxHealth;
GetOwner()->OnTakeAnyDamage.AddDynamic(this, &UHealthComponent::OnDamageTaken);
// 获取当前游戏世界的游戏模式基类指针
// UGameplayStatics::GetGameMode() 是虚幻引擎的全局函数,用于获取当前世界的游戏模式
// GetWorld() 返回当前对象所在的世界上下文,确保在正确的游戏世界中获取游戏模式
AGameModeBase* GameMode = UGameplayStatics::GetGameMode(GetWorld());
if (GameMode) {
// 将通用的游戏模式基类指针转换为特定的派生类指针
BattleBlasterGameMode = Cast<ABattleBlasterGameMode>(GameMode);
}
}
void UHealthComponent::OnDamageTaken(
AActor* DamagedActor, // 受到伤害的Actor(通常是本组件的所有者)
float Damage, // 受到的伤害值(来自ApplyDamage()函数的Damage参数)
const UDamageType* DamageType, // 伤害类型(如普通伤害、火焰伤害等)
AController* InstigatedBy, // 伤害发起者的控制器(用于追踪谁造成了伤害)
AActor* DamageCauser // 直接造成伤害的Actor(如子弹、爆炸物等)
)
{
// 安全检查:确保伤害值大于0,避免无效伤害或治疗逻辑错误
// 如果未来需要处理负伤害(治疗),可以移除此条件或修改逻辑
if (Damage > 0.0f) {
// 扣除生命值:当前生命值减去伤害值
Health -= Damage;
// 检查生命值是否耗尽(小于等于0)
if (Health <= 0.0f) {
// 生命值耗尽,销毁所属的Actor
// Destroy()会移除Actor及其所有组件,触发死亡逻辑
GetOwner()->Destroy();
}
}
}
8. 完善ActorDied()函数
void ABattleBlasterGameMode::ActorDied(AActor* DeadActor)
{
//执行分支:判断阵亡对象是玩家还是敌人
if (DeadActor == Tank) {
UE_LOG(LogTemp, Display, TEXT("Tank died,defeat!"));
//Tank->HandleDestruction();
}
else {
//我们没有存放所有敌人的指针, 所以销毁时, 要把它们统一转换类型, 统一销毁
ATower* DeadTower = Cast<ATower>(DeadActor);
if (DeadTower) {
UE_LOG(LogTemp, Display, TEXT("A Tower just died!"));
DeadTower->Destroy();
TowerCount--;
//DeadTower->HandleDestruction();
if (TowerCount == 0) {
UE_LOG(LogTemp, Display, TEXT("All towers are dead, Victory!"));
}
}
}
}
对于HandleDestruction()函数, 我们肯定需要写两个不同版本给玩家Tank和敌人Tower, 但是我们不一定完全分离, 因为这两个函数之间有相同的部分, 为了偷懒, 我们可以把相同部分(死亡音效,死亡粒子)写在它们的父亲BasePawn类中, 然后写独特部分之前用super调用父类销毁;

Super 关键词:
虚幻引擎中的 Super 关键词,本质是在子类中直接引用其对应的父类(超类),核心用于调用父类中同名的函数或访问父类成员,最常见的场景是在子类重写的函数里执行父类的原始实现逻辑。
例如: 通过 Super:: 函数名 () 执行父类原有逻辑,避免子类重写后丢失父类功能。
在虚幻引擎里,既然能直接写父类名::函数名 () 来调用父类逻辑,为何还要专门用 Super 关键词?
Super 的核心价值在于适配继承层级的灵活性和代码的可维护性:当你修改子类的父类(比如把 ACharacter 换成自定义的 MyBaseCharacter),用硬编码的父类名需要逐处修改所有调用代码,而 Super 会自动指向当前子类的直接父类,无需手动调整;同时在多层继承中,Super 能精准调用 “直接父类” 的逻辑,而非你手动指定的某个上层父类,更贴合继承体系的常规调用意图。
//BasePawn.h
void HandleDestruction();
//BasePawn.cpp
void ABasePawn::HandleDestruction()
{
}
/*-----------------------------------------------------*/
//Tank.h
void HandleDestruction();
//Tank.cpp
void ATank::HandleDestruction()
{
Super::HandleDestruction();
}
/*-----------------------------------------------------*/
//Tower.h
void HandleDestruction();
//Tower.cpp
void ATower::HandleDestruction()
{
Super::HandleDestruction();
}
8.1设置玩家Tank的销毁函数
//Tank.h void ATank::SetPlayerEnabled(bool Enabled); /*创建玩家状态变量IsAlive 通过访问这个变量确定玩家的状态, 敌人可以判断是否需要射击玩家*/ bool IsAlive = true; /*------------------------------------------------------------*/ //Tank.cpp void ATank::HandleDestruction() { Super::HandleDestruction(); UE_LOG(LogTemp, Display, TEXT("Tank HandleDestruction!")); // 隐藏坦克的可见网格体和组件 // 注意:这不同于Destroy(),对象仍然存在,只是不可见 // 适用于需要保留对象但隐藏其视觉效果的情况(如玩家重生前) SetActorHiddenInGame(true); // 禁用坦克的Tick函数调用 // Tick是每帧执行的更新函数,禁用后可以节省性能 // 这对于隐藏或暂时不活动的对象是常见的优化手段 SetActorTickEnabled(false); // 禁用玩家的输入控制 // 确保玩家在坦克"死亡"期间无法控制已隐藏的坦克 SetPlayerEnabled(false); //设置玩家状态为阵亡 IsAlive = false; } void ATank::SetPlayerEnabled(bool Enabled) { APlayerController* PlayerController = Cast<APlayerController>(Controller); if (PlayerController) { if (Enabled) { // 启用输入:允许玩家控制坦克 EnableInput(PlayerController); } else { // 禁用输入:阻止玩家控制坦克 DisableInput(PlayerController); } } }
8.2设置敌人Tower的销毁函数
void ATower::HandleDestruction() { Super::HandleDestruction(); UE_LOG(LogTemp, Display, TEXT("Tower HandleDestruction!")); Destroy(); }
8.3设置ActorDied()函数
void ABattleBlasterGameMode::ActorDied(AActor* DeadActor) { //执行分支:判断阵亡对象是玩家还是敌人 if (DeadActor == Tank) { UE_LOG(LogTemp, Display, TEXT("Tank died,defeat!")); Tank->HandleDestruction(); } else { //我们没有存放所有敌人的指针, 所以销毁时, 要把它们统一转换类型, 统一销毁 ATower* DeadTower = Cast<ATower>(DeadActor); if (DeadTower) { DeadTower->HandleDestruction(); TowerCount--; if (TowerCount == 0) { UE_LOG(LogTemp, Display, TEXT("All towers are dead, Victory!")); } } } }
8.4修改敌人的瞄准函数
//Tower.cpp void ATower::CheckFireCondition() { //增加检测Tank是否存活 if (Tank && Tank->IsAlive && InFireRange()) { Fire(); } } //此函数专门用于检测玩家是否进入射程 bool ATower::InFireRange() { return Tank && (FVector::Dist(GetActorLocation(), Tank->GetActorLocation()) <= FireRange); }
章节四: 完善项目
显示/修改 鼠标光标
玩家的鼠标光标, 本质上就是一个玩家控制器类的实例, 至于光标的样式, 也就是这个实例中的一个选项.
步骤1: 在蓝图资产文件夹下右击添加蓝图类, 继承自玩家控制器PlayerController类, 命名为BP_BattleBlasterPlayerController
步骤2: 进入BP_BattleBlasterPlayerController蓝图, 然后勾选显示鼠标光标, 如果想要别的准星形状, 在默认鼠标光标区域可以自己选择
步骤3: 进入BP_BattleBlasterGameMode游戏模式蓝图的玩家控制器选项中选中刚才创建的BP_BattleBlasterPlayerController
效果图: 进入游戏后鼠标依然正常显示
补充一个小细节:
如果现在Tank阵亡了, 玩家依然可以操控光标, 如何隐藏呢?
我们在阻断Tank输入的时候, 顺便让光标隐藏, 光标的可见性由一个bool变量bShowMouseCursor控制;//Tank.cpp void ATank::SetPlayerEnabled(bool Enabled) { APlayerController* PlayerController = Cast<APlayerController>(Controller); if (PlayerController) { if (Enabled) { // 启用输入:允许玩家控制坦克 EnableInput(PlayerController); PlayerController->bShowMouseCursor = true; } else { // 禁用输入:阻止玩家控制坦克 DisableInput(PlayerController); PlayerController->bShowMouseCursor = false; } } }
记录游戏结束状态:
用两个变量, 一个记录是否结束游戏, 一个记录是否胜利
后续通过判断这两个变量执行相应操作//BattleBlasterGameMode.cpp void ABattleBlasterGameMode::ActorDied(AActor* DeadActor) { bool IsGameOver = false; bool IsVictory = false; //执行分支:判断阵亡对象是玩家还是敌人 if (DeadActor == Tank) { UE_LOG(LogTemp, Display, TEXT("Tank died,defeat!")); Tank->HandleDestruction(); IsGameOver = true; } else { //我们没有存放所有敌人的指针, 所以销毁时, 要把它们统一转换类型, 统一销毁 ATower* DeadTower = Cast<ATower>(DeadActor); if (DeadTower) { DeadTower->HandleDestruction(); TowerCount--; if (TowerCount == 0) { UE_LOG(LogTemp, Display, TEXT("All towers are dead, Victory!")); IsGameOver = true; IsVictory = true; } } } //后续操作,基于两个状态变量 if (IsGameOver) { FString GameOverString = IsVictory ? "Victory!" : "Defeat!"; UE_LOG(LogTemp, Display, TEXT("Game Over: %s"),*GameOverString); } }
游戏关卡控制:
游戏的关卡控制逻辑 实际上不应该放置在游戏模式类中, 因为每一次加载新关卡都会重新初始化游戏模式类;比如你在游戏模式类中声明了三个关卡,然后写了一个计数器用来记住你所在的关卡层级, 然后你写了第一关结束后跳转到第二关的逻辑, 计数器++, 但是进入第二关后, 你的计数器会被重置为起始关卡, 所以如果你要写多个关卡, 你不能写在游戏模式类中, 最好是写在游戏实例类中, 游戏实例的生命周期是整个游戏期间, 然后让游戏实例类暴露接口给游戏模式类调用;
关卡控制案例1: 关卡重启
由于关卡重启比较简单, 而且不涉及多关卡切换, 所以可以写在游戏模式类中;
游戏结束后, 等待3秒后, 自动重新加载关卡
//BattleBlasterGameMode.h public: UPROPERTY(EditAnywhere) float GameOverDelay = 3.0f; void OnGameOverTimerTimeOut();//BattleBlasterGameMode.cpp void ABattleBlasterGameMode::ActorDied(AActor* DeadActor) { /*....省略大量代码....*/ // 后续操作:基于两个状态变量处理游戏结束逻辑 if (IsGameOver) { // 根据胜负结果生成游戏结束文本 FString GameOverString = IsVictory ? "Victory!" : "Defeat!"; UE_LOG(LogTemp, Display, TEXT("Game Over: %s"),*GameOverString); // 设置一个计时器,延迟执行游戏结束后续操作 FTimerHandle GameOverTimerHandle; // 计时器句柄(局部变量,执行后自动清理) GetWorldTimerManager().SetTimer( GameOverTimerHandle, // 计时器句柄 this, // 回调对象(当前游戏模式) &ABattleBlasterGameMode::OnGameOverTimerTimeOut, // 回调函数 GameOverDelay, // 延迟时间(秒) false // 不循环执行 ); } } void ABattleBlasterGameMode::OnGameOverTimerTimeOut() { // 获取当前关卡的名称 FString CurrentLevelName = UGameplayStatics::GetCurrentLevelName(GetWorld()); // 重新加载当前关卡(实现游戏重启功能) UGameplayStatics::OpenLevel(GetWorld(), *CurrentLevelName); }关卡控制案例2: 关卡切换
涉及到多关卡问题时, 我们需要把具体关卡控制的逻辑写在游戏实例GameInstance类中, 这个类的生命周期是整个游戏期间, 然后再游戏模式类中调用关卡切换的接口;
步骤1: 把是否胜利的变量放入头文件, 方便其他成员函数访问
//BattleBlasterGameMode.h bool IsVictory = false; /*----------------------------------------------------------------------------*/ //BattleBlasterGameMode.cpp void ABattleBlasterGameMode::OnGameOverTimerTimeOut() { // 获取当前关卡的名称 FString CurrentLevelName = UGameplayStatics::GetCurrentLevelName(GetWorld()); if (IsVictory) { } else { // 重新加载当前关卡(实现游戏重启功能) UGameplayStatics::OpenLevel(GetWorld(), *CurrentLevelName); } }
步骤2: 新建C++类, 继承自:GameInstance, 命名为BattleBlasterGameInstance
//BattleBlasterGameInstance.h public: //最后一个关卡的索引/最大关卡数量 UPROPERTY(EditAnywhere) int32 LastLevelIndex = 3; //当前关卡的索引 UPROPERTY(VisibleAnywhere) int32 CurrentLevelIndex = 1;
步骤3: 新建蓝图BP_BattleBlasterGameInstance,继承自BattleBlasterGameInstance;
步骤4: 打开项目设置
步骤5: 在地图文件夹里再多创建两个关卡:
可以直接ctrl+c,ctrl+v,快速复制, 然后再做一点修改
进入第二关和第三关的地图, 然后做出一些修改, 以便区分;
tips:
1. 使用顶视图更加方便搭建这种平面场景2. ctrl+d可以复制场景物体
3. 长按shift, 再选中多个物体, 然后按alt拖物体,就可以复制一大堆物体
步骤6. 我们需要写一个切换关卡的函数:
//BattleBlasterGameInstance.h #pragma once #include "CoreMinimal.h" #include "Engine/GameInstance.h" #include "BattleBlasterGameInstance.generated.h" UCLASS() class BATTLEBLASTER_API UBattleBlasterGameInstance : public UGameInstance { GENERATED_BODY() public: //最后一个关卡的索引/最大关卡数量 UPROPERTY(EditAnywhere) int32 LastLevelIndex = 3; //当前关卡的索引 UPROPERTY(VisibleAnywhere) int32 CurrentLevelIndex = 1; void LoadNextLevel(); void RestartCurrentLevel(); void RestartGame(); private: //此函数把游戏的关卡切换到第Index关; void ChangeLevel(int32 Index); };//BattleBlasterGameInstance.cpp #include "BattleBlasterGameInstance.h" #include "kismet/GameplayStatics.h" void UBattleBlasterGameInstance::ChangeLevel(int32 Index) { // 检测索引是否合法 if (Index > 0 && Index <= LastLevelIndex) { // 更新当前关卡的索引,记录玩家进度 CurrentLevelIndex = Index; // 格式化生成关卡名称字符串,格式为"Level_X"(X为关卡编号) FString LevelNameString = FString::Printf(TEXT("Level_%d"), CurrentLevelIndex); // 使用游戏实用函数打开指定关卡 // 注意:星号(*)操作符用于将FString转换为OpenLevel所需的FName类型 UGameplayStatics::OpenLevel(GetWorld(), *LevelNameString); } // 注意:如果索引不合法,这里没有任何操作,可能需要添加日志或错误处理 } void UBattleBlasterGameInstance::LoadNextLevel() { // 检查是否还有后续关卡 if (CurrentLevelIndex < LastLevelIndex) { // 加载下一关:传递当前关卡索引+1给ChangeLevel函数 // 注意:这里使用CurrentLevelIndex+1而不是++CurrentLevelIndex, // 是为了保持CurrentLevelIndex值不变,由ChangeLevel函数统一更新 ChangeLevel(CurrentLevelIndex + 1); } else { // 如果当前已是最后一关,则重新开始游戏(回到第一关) RestartGame(); } } void UBattleBlasterGameInstance::RestartCurrentLevel() { ChangeLevel(CurrentLevelIndex); } void UBattleBlasterGameInstance::RestartGame() { ChangeLevel(1); }步骤7: 在游戏模式类中调用关卡切换函数
//BattleBlasterGameMode.cpp #include "BattleBlasterGameInstance.h" void ABattleBlasterGameMode::OnGameOverTimerTimeOut() { // 获取游戏实例 UGameInstance* GameInstance = GetGameInstance(); if (GameInstance) { // 尝试将游戏实例转换为自定义的游戏实例类 UBattleBlasterGameInstance* BattleBlasterGameInstance = Cast<UBattleBlasterGameInstance>(GameInstance); if (BattleBlasterGameInstance) { // 根据游戏结果选择不同的关卡加载策略 if (IsVictory) // 如果游戏胜利 { // 加载下一关卡 BattleBlasterGameInstance->LoadNextLevel(); } else // 如果游戏失败 { // 重新加载当前关卡 BattleBlasterGameInstance->RestartCurrentLevel(); } } } }
游戏UI组件
组件: 倒计时显示
步骤1:设置定时器
主要就是需要设置一个定时器, 定时器的设置非常简单, 先声明一个句柄, 一个回调函数, 和一个时间数值, 再选择合适的地方调用GetWorldTimerManager().SetTimer();再填入一些参数就行了;
//BattleBlasterGameMode.h public: //游戏开始倒计时时间 UPROPERTY(EditAnywhere) int32 CountdownDelay = 3; //由于需要多次计时,这里有两个时间值, 一个存时间, 一个用时间 int32 CountdownSeconds; //句柄 FTimerHandle CountdownTimerHandle; //回调函数 void OnCountdownTimerTimeout();//BattleBlasterGameMode.cpp void ABattleBlasterGameMode::BeginPlay() { Super::BeginPlay(); /*........省略大量代码........*/ // 初始化倒计时数值:将倒计时初始值赋值给当前倒计时秒数 CountdownSeconds = CountdownDelay; // GetWorldTimerManager() 是UE的便捷函数,等价于 GetWorld()->GetTimerManager() GetWorldTimerManager().SetTimer( // 【参数1】定时器句柄:唯一标识这个倒计时定时器,用于后续停止/清理定时器 CountdownTimerHandle, // 【参数2】回调函数所属对象:指向当前ABattleBlasterGameMode实例本身 this, // 【参数3】定时器触发的回调函数:每次定时器到时间就执行这个函数 &ABattleBlasterGameMode::OnCountdownTimerTimeout, // 【参数4】触发间隔:1.0秒(每1秒执行一次回调函数) 1.0f, // 【参数5】是否循环:true表示循环触发,直到手动调用ClearTimer停止 true ); } //回调函数的逻辑 void ABattleBlasterGameMode::OnCountdownTimerTimeout() { CountdownSeconds -= 1; if (CountdownSeconds > 0) { UE_LOG(LogTemp, Display, TEXT("Countdown: %d"), CountdownSeconds); } else if(CountdownSeconds == 0){ UE_LOG(LogTemp, Display, TEXT("Go!")); Tank->SetPlayerEnabled(true); } else { GetWorldTimerManager().ClearTimer(CountdownTimerHandle); UE_LOG(LogTemp, Display, TEXT("Clear timer!")); } }步骤2: 设置UI小组件蓝图
右击空白处选择用户界面, 控件蓝图, 命名为: WBP_ScreenMessage
按住向右拖
锚点是用来保证无论窗口如何变化, UI控件始终可以保持在相对屏幕的某个区域, 任何组件都可以和锚点绑定
步骤3: 创建C++用户空间类,来管理用户控件
继承自UserWidget,命名为:ScreenMessage
在BattleBlaster.Build.cs文件中, 添加UMG到PublicDependencyModuleNames, 如图所示:

然后关闭VS和UE,重新加载项目

//ScreenMessage.h
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Components/TextBlock.h"
#include "ScreenMessage.generated.h"
UCLASS()
class BATTLEBLASTER_API UScreenMessage : public UUserWidget
{
GENERATED_BODY()
public:
/*meta = (BindWidget) 元数据标签是专门用于 UMG UI 开发的核心元数据*/
UPROPERTY(EditAnywhere, meta = (BindWidget))
UTextBlock* MessageTextBlock;
};
进入WBP_ScreenMessage, 点击右上角的图标, 再点中间的类设置, 再在细节面板找到类选项, 把父类改成ScreenMessage

目前已经成功把文本和C++类关联上了, 接下来创建一个函数专门用来修改这个文本框里的文本
//ScreenMessage.h
class BATTLEBLASTER_API UScreenMessage : public UUserWidget
{
GENERATED_BODY()
public:
/*.............省略无关代码.............*/
void SetMessageText(FString Message);
};
//ScreenMessage.cpp
void UScreenMessage::SetMessageText(FString Message)
{
FText MessageText = FText::FromString(Message);
MessageTextBlock->SetText(MessageText);
}
现在尝试在别的类中调用此函数, 来设置UI文本, 我们在游戏模式类中调用吧, 这个地方适合放置公共功能:
//BattleBlasterGameMode.h
#include "ScreenMessage.h"
public:
/**
* UScreenMessage类的子类引用(Unreal安全类型)
* @note TSubclassOf是Unreal特有的模板类型,用于安全地引用UClass对象,仅接受UScreenMessage或其子类
* 通常在蓝图中指定要实例化的UI控件蓝图类,避免裸UClass指针带来的类型不安全问题
*/
UPROPERTY(EditAnywhere)
TSubclassOf<UScreenMessage> ScreenMessageClass;
/**
* UScreenMessage UI控件的实例指针
* @note 用于持有通过ScreenMessageClass创建的UI控件实例,可通过该指针调用SetMessageText等成员函数
* 操作屏幕消息的显示内容、样式等,为空时表示控件尚未创建或已销毁
*/
UScreenMessage* ScreenMessageWidget;
//BattleBlasterGameMode.cpp
void ABattleBlasterGameMode::BeginPlay()
{
/*............省略无关代码...........*/
// 【功能】获取本地玩家(索引0)的玩家控制器
// 说明:通过UGameplayStatics工具类获取当前世界中第0号玩家的控制器
// 参数:GetWorld()=当前游戏世界指针;0=玩家索引(通常对应本地玩家)
APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
// 安全校验:确保玩家控制器已成功获取(避免空指针操作)
if (PlayerController)
{
// 【功能】创建UScreenMessage类型的UI控件实例
// 说明:通过CreateWidget模板函数创建控件,依赖之前定义的ScreenMessageClass(控件类)
// 参数:PlayerController=控件的拥有者;ScreenMessageClass=要实例化的UI控件类
ScreenMessageWidget = CreateWidget<UScreenMessage>(PlayerController, ScreenMessageClass);
// 安全校验:确保UI控件已成功创建
if (ScreenMessageWidget)
{
// 【功能】将UI控件添加到玩家的屏幕UI层级
// 说明:AddToPlayerScreen会让控件在游戏中显示(属于玩家全局UI层)
ScreenMessageWidget->AddToPlayerScreen();
// 【功能】设置UI控件的显示文本
// 说明:调用之前定义的SetMessageText函数,将消息内容设为“Get Ready!”
ScreenMessageWidget->SetMessageText("Get Ready!");
}
}
/*............省略无关代码...........*/
}

重构定时器的代码, 转而使用UI组件文本显示, 而不是输出到控制台
//BattleBlasterGameMode.cpp
/**
* @brief 倒计时定时器超时后的回调函数
* @note 该函数属于ABattleBlasterGameMode游戏模式类,每一次倒计时定时器触发时会执行此函数
* 核心逻辑:递减倒计时秒数 → 根据秒数状态更新UI/启用玩家/清除定时器
*/
void ABattleBlasterGameMode::OnCountdownTimerTimeout()
{
// 倒计时秒数递减(每次定时器触发,秒数减1)
CountdownSeconds -= 1;
// 分支1:倒计时未结束(秒数>0)→ 更新UI显示当前剩余秒数
if (CountdownSeconds > 0) {
// 【功能】将倒计时秒数设置为屏幕消息的显示文本
// 说明:
// 1. FString::FromInt(CountdownSeconds):将整数类型的倒计时秒数(CountdownSeconds)转换为FString字符串
// 2. 调用SetMessageText函数,把转换后的字符串作为消息内容传递给屏幕UI控件
ScreenMessageWidget->SetMessageText(FString::FromInt(CountdownSeconds));
}
// 分支2:倒计时结束(秒数=0)→ 触发游戏开始逻辑
else if (CountdownSeconds == 0) {
// 更新UI显示"Go!",提示玩家游戏开始
ScreenMessageWidget->SetMessageText("Go!");
// 启用玩家坦克的操作权限(允许玩家控制坦克)
Tank->SetPlayerEnabled(true);
}
// 分支3:倒计时秒数<0(防御性逻辑)→ 清除定时器避免无效触发
else {
// 通过世界定时器管理器清除倒计时定时器,停止后续的超时回调
GetWorldTimerManager().ClearTimer(CountdownTimerHandle);
// 将屏幕消息UI控件设置为隐藏状态,倒计时完全结束后不再显示该UI
// 关键说明:
// - ESlateVisibility::Hidden:控件隐藏,但仍会占用UI布局空间(仅不可见)
// - 区别于ESlateVisibility::Collapsed(隐藏且释放布局空间),此处用Hidden更适配临时隐藏的UI场景
ScreenMessageWidget->SetVisibility(ESlateVisibility::Hidden);
}
}
void ABattleBlasterGameMode::ActorDied(AActor* DeadActor)
{
bool IsGameOver = false;
//执行分支:判断阵亡对象是玩家还是敌人
if (DeadActor == Tank) {
UE_LOG(LogTemp, Display, TEXT("Tank died,defeat!"));
Tank->HandleDestruction();
IsGameOver = true;
}
else {
//我们没有存放所有敌人的指针, 所以销毁时, 要把它们统一转换类型, 统一销毁
ATower* DeadTower = Cast<ATower>(DeadActor);
if (DeadTower) {
DeadTower->HandleDestruction();
TowerCount--;
if (TowerCount == 0) {
IsGameOver = true;
IsVictory = true;
}
}
}
// 后续操作:基于两个状态变量处理游戏结束逻辑
if (IsGameOver) {
// 根据胜负结果生成游戏结束文本
FString GameOverString = IsVictory ? "Victory!" : "Defeat!";
ScreenMessageWidget->SetMessageText(GameOverString);
ScreenMessageWidget->SetVisibility(ESlateVisibility::Visible);
// 设置一个计时器,延迟执行游戏结束后续操作
FTimerHandle GameOverTimerHandle; // 计时器句柄(局部变量,执行后自动清理)
GetWorldTimerManager().SetTimer(
GameOverTimerHandle, // 计时器句柄
this, // 回调对象(当前游戏模式)
&ABattleBlasterGameMode::OnGameOverTimerTimeOut, // 回调函数
GameOverDelay, // 延迟时间(秒)
false // 不循环执行
);
}
}
游戏粒子效果:
一般来说实现粒子效果的方法有两种:
第一种: 单独创建一个粒子组件, 绑定到需要粒子特效的对象上, 然后粒子组件不断追踪这个对象
第二种: 利用函数计算对象位置, 然后仅在指定位置播放粒子效果
这两种方法不是割裂的, 是相互配合的, 我们用一个粒子组件来模拟投射物炮弹的轨迹, 用函数计算炮弹落点, 这样很棒!
步骤1: 打开BattleBlaster.build.cs文件, 加入Niagara

然后退出VS,退出UE,重新生成项目

步骤2: 为投射物炮弹添加粒子组件
//Projectile.h
#include "NiagaraComponent.h"
#include "NiagaraFunctionLibrary.h"
public:
/* @brief 拖尾粒子效果的组件实例(Niagara粒子组件)
* - UNiagaraComponent是Niagara粒子系统的**实例化组件**,挂载在Actor上用于实时渲染拖尾粒子(如子弹/炮弹拖尾)
* - 该组件与Actor生命周期绑定,是运行时实际显示粒子效果的载体
*/
UPROPERTY(VisibleAnywhere)
UNiagaraComponent* TrailParticles;
/* @brief 命中粒子效果的模板资源(Niagara粒子系统)
* - UNiagaraSystem是粒子效果的**模板资源**(非实例),本身不渲染,需通过Spawn等方式实例化后才能显示
* - 用于在命中目标(如地面、敌人、物体)时生成对应的粒子效果(如火花、爆炸、烟雾)
*/
UPROPERTY(EditAnywhere)
UNiagaraSystem* HitParticles;
//Projectile.cpp
/* @brief 炮弹Actor的构造函数
* @note 核心作用:初始化炮弹的核心组件(网格体、移动组件、拖尾粒子组件),并设置基础运动参数
* 构造函数在Actor创建时执行,仅初始化组件和默认参数,不处理运行时逻辑
*/
AProjectile::AProjectile()
{
// 开启Actor的Tick函数(每帧执行Tick())
// 说明:若需每帧更新炮弹状态(如轨迹修正、碰撞检测)则保留,无需时可关闭以提升性能
PrimaryActorTick.bCanEverTick = true;
// 创建炮弹的静态网格体组件(可视化炮弹模型)
// CreateDefaultSubobject:在构造函数中创建Actor的子组件,参数为组件名称(用于编辑器识别)
ProjectileMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ProjectileMesh"));
// 将网格体组件设为Actor的根组件(根组件决定Actor的位置/旋转/缩放,所有子组件附着到根组件)
SetRootComponent(ProjectileMesh);
// 创建炮弹移动组件(Unreal内置的弹道运动组件,自动处理抛物线、速度等物理运动)
ProjectileMovementComp = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComp"));
// 设置炮弹初始发射速度(单位:厘米/秒,Unreal默认单位为厘米)
ProjectileMovementComp->InitialSpeed = 4000.0f;
// 设置炮弹最大移动速度
ProjectileMovementComp->MaxSpeed = 10000.0f;
//--------------------------------------------------------------------------------------
// 创建拖尾粒子组件(用于显示炮弹飞行时的拖尾特效)
TrailParticles = CreateDefaultSubobject<UNiagaraComponent>(TEXT("TrailParticles"));
// 将拖尾粒子组件附着到根组件(网格体),继承根组件的位置/旋转,随炮弹同步移动
TrailParticles->SetupAttachment(RootComponent);
}
void AProjectile::OnHit(
UPrimitiveComponent* HitComponent, // 碰撞组件:炮弹自身的碰撞体组件
AActor* OtherActor, // 其他Actor:被炮弹击中的Actor对象
UPrimitiveComponent* OtherComp, // 其他碰撞组件:被击中Actor的碰撞组件
FVector NormalImpulse, // 法向冲量:碰撞时产生的冲击力向量
const FHitResult& Hit // 命中结果:详细的碰撞检测结果数据
)
{
// 获取此炮弹的所属者(即发射此炮弹的Actor)
AActor* MyOwner = GetOwner();
// 多重安全检查:确保应用伤害的条件全部满足
if (MyOwner && // 1. 炮弹必须有所有者(避免空指针访问)
OtherActor && // 2. 必须确实击中了某个有效Actor
(OtherActor != MyOwner) && // 3. 避免伤害自己:不能击中发射者自身
(OtherActor != this)) // 4. 避免自伤:不能击中炮弹自己(边缘情况防护)
{
// 此函数用于对 被炮弹击中的Actor对象 造成伤害
UGameplayStatics::ApplyDamage(
OtherActor, // 目标Actor:要接收伤害的对象
Damage, // 伤害值:炮弹的伤害量(为类成员变量25.0f)
MyOwner->GetInstigatorController(), // 伤害发起控制器:所有者的控制器(用于记录伤害来源)
this, // 伤害造成者:炮弹自身(用于追踪具体伤害来源)
UDamageType::StaticClass() // 伤害类型类:使用基础伤害类型(可扩展为自定义类型)
);
// 注意:ApplyDamage会触发目标Actor的TakeDamage事件,目标需要有相应的处理逻辑
//--------------------------------------------------------------------------------
// 校验:命中粒子模板(HitParticles)是否已配置
if (HitParticles)
{
// 在炮弹当前位置+旋转处,生成命中粒子效果
// SpawnSystemAtLocation:实例化Niagara系统并在指定位置渲染
UNiagaraFunctionLibrary::SpawnSystemAtLocation(GetWorld(), HitParticles,
GetActorLocation(), GetActorRotation());
}
}
//---------------------------------------------------------------------------------
// 无论是否造成伤害,碰撞后都销毁炮弹对象
// Destroy()会延迟到当前帧结束时执行,确保当前函数逻辑完整执行
Destroy();
// 可选扩展:可在此处添加命中特效(粒子、音效、屏幕震动等)
// 例如:UGameplayStatics::SpawnEmitterAtLocation(...) 播放命中粒子效果
}
步骤3: 在BP_Projectile中挂接粒子特效, 然后移动到合适位置



步骤4: 阵亡特效
由于敌人和玩家都可能会阵亡, 所以我们可以把这个特效写在基类BasePawn里, 这样很省事
4.1 把需要的头文件加入BasePawn.h中:
//BasePawn.h #include "NiagaraFunctionLibrary.h"//粒子特效 public: UPROPERTY(EditAnywhere) UNiagaraSystem* DeathParticles;//BasePawn.cpp void ABasePawn::HandleDestruction() { if (DeathParticles) { // 在当前Pawn的位置和旋转角度,实例化并显示死亡粒子特效 UNiagaraFunctionLibrary::SpawnSystemAtLocation(GetWorld(), DeathParticles,GetActorLocation(),GetActorRotation()); } }
步骤5: 为玩家BP_Tank和敌人BP_Tower挂载粒子效果

最终效果图:


游戏音效:
//Projectile.h
#include "Kismet/GameplayStatics.h"
public:
UPROPERTY(EditAnywhere)
USoundBase* LaunchSound;
UPROPERTY(EditAnywhere)
USoundBase* HitSound;
//Projectile.cpp
void AProjectile::BeginPlay()
{
Super::BeginPlay();
ProjectileMesh->OnComponentHit.AddDynamic(this, &AProjectile::OnHit);
//----------------------------------------------------------------------------
//投射物生成时,产生发射音效
if (LaunchSound) {
UGameplayStatics::PlaySoundAtLocation(GetWorld(), LaunchSound, GetActorLocation());
}
//----------------------------------------------------------------------------
}
void AProjectile::OnHit(
UPrimitiveComponent* HitComponent, // 碰撞组件:炮弹自身的碰撞体组件
AActor* OtherActor, // 其他Actor:被炮弹击中的Actor对象
UPrimitiveComponent* OtherComp, // 其他碰撞组件:被击中Actor的碰撞组件
FVector NormalImpulse, // 法向冲量:碰撞时产生的冲击力向量
const FHitResult& Hit // 命中结果:详细的碰撞检测结果数据
)
{
// 获取此炮弹的所属者(即发射此炮弹的Actor)
AActor* MyOwner = GetOwner();
// 多重安全检查:确保应用伤害的条件全部满足
if (MyOwner && // 1. 炮弹必须有所有者(避免空指针访问)
OtherActor && // 2. 必须确实击中了某个有效Actor
(OtherActor != MyOwner) && // 3. 避免伤害自己:不能击中发射者自身
(OtherActor != this)) // 4. 避免自伤:不能击中炮弹自己(边缘情况防护)
{
// 此函数用于对 被炮弹击中的Actor对象 造成伤害
UGameplayStatics::ApplyDamage(
OtherActor, // 目标Actor:要接收伤害的对象
Damage, // 伤害值:炮弹的伤害量(为类成员变量25.0f)
MyOwner->GetInstigatorController(), // 伤害发起控制器:所有者的控制器(用于记录伤害来源)
this, // 伤害造成者:炮弹自身(用于追踪具体伤害来源)
UDamageType::StaticClass() // 伤害类型类:使用基础伤害类型(可扩展为自定义类型)
);
// 注意:ApplyDamage会触发目标Actor的TakeDamage事件,目标需要有相应的处理逻辑
// 校验:命中粒子模板(HitParticles)是否已配置
if (HitParticles)
{
// 在炮弹当前位置+旋转处,生成命中粒子效果
// SpawnSystemAtLocation:实例化Niagara系统并在指定位置渲染
UNiagaraFunctionLibrary::SpawnSystemAtLocation(GetWorld(), HitParticles,
GetActorLocation(), GetActorRotation());
}
//-----------------------------------------------------------------------
if (HitSound) {
UGameplayStatics::PlaySoundAtLocation(GetWorld(), HitSound, GetActorLocation());
//------------------------------------------------------------------------
}
}
在BP_Projectile中挂载音效:

画面震荡效果:
实现相机震荡的效果非常简单, 就是制作一个蓝图, 然后在需要震荡的代码段, 调用即可
步骤1: 创建一个蓝图, 继承自CameraShakeBase类,命名为:BP_HitCameraShake

步骤2: 根晃动模式使用柏林噪声算法, 小参数可以按自己喜好填, 下边是我的参数

步骤3: 进入VS调用这个功能
//Projectile.h
public:
UPROPERTY(EditAnywhere)
TSubclassOf<UCameraShakeBase> HitCameraShakeClass;
//Projectile.cpp
void AProjectile::OnHit(
UPrimitiveComponent* HitComponent, // 碰撞组件:炮弹自身的碰撞体组件
AActor* OtherActor, // 其他Actor:被炮弹击中的Actor对象
UPrimitiveComponent* OtherComp, // 其他碰撞组件:被击中Actor的碰撞组件
FVector NormalImpulse, // 法向冲量:碰撞时产生的冲击力向量
const FHitResult& Hit // 命中结果:详细的碰撞检测结果数据
)
{
// 获取此炮弹的所属者(即发射此炮弹的Actor)
AActor* MyOwner = GetOwner();
// 多重安全检查:确保应用伤害的条件全部满足
if (MyOwner && // 1. 炮弹必须有所有者(避免空指针访问)
OtherActor && // 2. 必须确实击中了某个有效Actor
(OtherActor != MyOwner) && // 3. 避免伤害自己:不能击中发射者自身
(OtherActor != this)) // 4. 避免自伤:不能击中炮弹自己(边缘情况防护)
{
// 此函数用于对 被炮弹击中的Actor对象 造成伤害
UGameplayStatics::ApplyDamage(
OtherActor, // 目标Actor:要接收伤害的对象
Damage, // 伤害值:炮弹的伤害量(为类成员变量25.0f)
MyOwner->GetInstigatorController(), // 伤害发起控制器:所有者的控制器(用于记录伤害来源)
this, // 伤害造成者:炮弹自身(用于追踪具体伤害来源)
UDamageType::StaticClass() // 伤害类型类:使用基础伤害类型(可扩展为自定义类型)
);
// 注意:ApplyDamage会触发目标Actor的TakeDamage事件,目标需要有相应的处理逻辑
// 校验:命中粒子模板(HitParticles)是否已配置
if (HitParticles)
{
// 在炮弹当前位置+旋转处,生成命中粒子效果
// SpawnSystemAtLocation:实例化Niagara系统并在指定位置渲染
UNiagaraFunctionLibrary::SpawnSystemAtLocation(GetWorld(), HitParticles,
GetActorLocation(), GetActorRotation());
}
if (HitSound) {
UGameplayStatics::PlaySoundAtLocation(GetWorld(), HitSound, GetActorLocation());
}
//----------------------------------------------------------------------------------
// HitCameraShakeClass通常是UCameraShake的子类,用于定义相机震动的幅度、频率等参数
if (HitCameraShakeClass)
{
// 获取本地第0号玩家的控制器(Unreal中通常用索引0对应本地玩家)
// 说明:相机是客户端本地资源,需通过玩家控制器触发客户端的相机震动
APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
// 安全校验:确保玩家控制器已成功获取(避免空指针操作)
if (PlayerController)
{
// 在客户端启动相机震动效果
// ClientStartCameraShake:仅在客户端执行相机震动逻辑(相机是客户端本地组件)
// 参数HitCameraShakeClass:指定要播放的相机震动模板,实现命中时的屏幕抖动反馈
PlayerController->ClientStartCameraShake(HitCameraShakeClass);
}
}
}
//------------------------------------------------------------------------------------
// 无论是否造成伤害,碰撞后都销毁炮弹对象
// Destroy()会延迟到当前帧结束时执行,确保当前函数逻辑完整执行
Destroy();
}
步骤4: 在BP_Projectile蓝图中挂在, 我们定义的相机抖动蓝图

拓展: 按上面的步骤, 我们可以给角色死亡时也添加一个画面抖动, 这个抖动更加疯狂
总结:
终于做完了这个游戏! 这个游戏虽然花了我们很多时间, 但是, 依然有点简陋, 当然, 这只是一个开始, 我会持续更新, 在未来, 我会为这个游戏, 添加更为丰富的UI界面, 更加复杂的游戏机制, 双人或多人本地同屏对战, 在服务器上用帧同步技术实现多人远程在线对战...敬请期待!



















1万+

被折叠的 条评论
为什么被折叠?



