虚幻引擎 C++ 制作“射击FPS游戏“

项目前览:


目标:

  • 项目、资源与测试关卡设置
  • 角色动画
  • 枪械与射击系统
  • 伤害与死亡机制
  • 基于行为树的敌方 AI
  • 音效、粒子特效与 HUD 界面
  • 关卡设计

章节一. 搭建游戏框架

搭建游戏框架包括以下几个关键点:

  1. 创建角色蓝图

  2. 创建游戏模式蓝图

  3. 创建控制器蓝图

步骤1.创建,保存并启用关卡

搭建地图:

  1. 在内容文件夹下, 创建一个MyStuff文件夹, 再在MyStuff里创建一个Maps文件夹用来存关卡, 然后点左上角文件菜单, 新建关卡选择"基本"

  2. 然后把关卡保存到MyStuff / Maps / 文件夹, 命名为:TestLevel

  3. 在左上角编辑-项目设置-地图与模式-中修改默认地图为我们创建的TestLevel

步骤2:配置游戏项目框架

1.修改地面参数, 把地面变大

  1. 在项目大纲中创建一个文件夹专门用于管理几何体:Geometry并且把地面Floor拖进去

3.创建几个几何体用来搭建一个简单场景

按住ctrl,在大纲中点击物体ID,可以连续选择, 连选后, 右击选择组,就可以把多个物体联合在一起,点任意一个都可以操作整体


打开虚幻商城: 搜索Paragon: Wraith

点击加入我的lib,然后点加入我的工程, 多点几次, 网络不好的话, 我每次都要点好多次

配置父子角色蓝图:

如果我们有一个角色蓝图A, 然后用这个角色蓝图A创建一个子蓝图, 那么子蓝图修改不会影响父蓝图, 父蓝图的修改会对子蓝图生效

创建一个文件夹存放我们自己的蓝图, 然后把上面的子蓝图加入, 然后创建一个游戏模式类蓝图;

在游戏模式蓝图中可以修改默认角色蓝图, 我们改为子蓝图

加入游戏控制器:

为我们自己的控制器挂载映射上下文:

章节二. 角色基础移动动画

虚幻引擎_动画蓝图/混合空间/状态机_超详细教学https://blog.csdn.net/Howrun777/article/details/156781213

这个教学, 非常详细讲解了如何为一个角色构建基础的移动动画, 而且用的资产就是本项目的资产.

为了防止本教程篇幅爆炸, 我把这部分的教程解耦了, 而且此分支教程非常值得学习.

后续的教程也是基于此分支教程.

章节三. 射击系统

1. 准星UI:

虚幻引擎_用户小控件_准星https://blog.csdn.net/Howrun777/article/details/156863517?sharetype=blogdetail&sharerId=156863517&sharerefer=PC&sharesource=Howrun777&spm=1011.2480.3001.8118

2. 射击输入动作:

为射击子弹添加一个输入动作:IA_Shoot , 输出值为bool类型

在输入映射上下文为其绑定 鼠标左键 和 手柄正面按钮左键

在C++类中声明动作

为其创建一个动作响应函数:void Shoot()

为函数绑定触发事件

在蓝图中挂载动作

cpp 复制代码
//ShootSamCharacter.h
protected:
	UPROPERTY(EditAnywhere, Category = "Input")
	UInputAction* ShootAction;
public:
    	void Shoot();

绑定射击响应函数:

cpp 复制代码
//ShootSamCharacter.cpp
void AShooterSamCharacter::Shoot() {
	UE_LOG(LogTemp,Display,TEXT("Shoot!"));
}


void AShooterSamCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	// Set up action bindings
	if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent)) {
		
		// Jumping
		EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &ACharacter::Jump);
		EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &ACharacter::StopJumping);

		// Moving
		EnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AShooterSamCharacter::Move);
		EnhancedInputComponent->BindAction(MouseLookAction, ETriggerEvent::Triggered, this, &AShooterSamCharacter::Look);

		// Looking
		EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &AShooterSamCharacter::Look);
		//shooting
		EnhancedInputComponent->BindAction(ShootAction, ETriggerEvent::Started, this, &AShooterSamCharacter::Shoot);
	}
	else
	{
	}
}

挂载蓝图:

3. 设计枪械角色类:

思路:

这是在虚幻中创建 "枪支" Actor 的基础流程,目的是做出一个能显示、能交互的枪支对象:

  1. 新建 Gun 的 C++ Actor 类,命名为Gun

    Actor类 是虚幻中 "可放置到场景里的对象" 的基础类,创建Gun类是为了给 "枪支" 定义专属的逻辑(比如射击、显示模型)

  2. 用 Scene Component 做根组件

    根组件是 Actor 的 "基础容器",所有其他组件都要依附于根组件。Scene Component 是最基础的根组件类型,能提供位置、旋转、缩放的基础功能,是枪支在场景中 "存在" 的核心载体

  3. 类中附加骨骼网格组件

    骨骼网格组件是用来显示枪支 3D 模型的(比如枪的外观),把它挂在根组件上,枪支的模型就能跟着根组件的位置 / 旋转一起变化

  4. 定义 PullTrigger () 函数

    这是枪支的 "交互逻辑入口",后续可以在这个函数里写 "扣动扳机" 对应的功能(比如发射子弹、播放音效等)

实操:

创建C++类, 继承自Actor类,命名为Gun
cpp 复制代码
//Gun.h
public:	
	UPROPERTY(VisibleAnywhere)
	// 场景根组件:作为Actor所有其他组件的父组件,提供基础的位置/旋转/缩放功能
	// 所有Actor必须有根组件才能在场景中定位
	USceneComponent* SceneRoot;

	UPROPERTY(VisibleAnywhere)
	// 骨骼网格组件:用于显示带有骨骼动画的3D模型(比如枪支的3D模型)
	// 区别于StaticMeshComponent(静态网格,无动画),适合有动画的枪支模型
	USkeletalMeshComponent* Mesh;

	// 扣动扳机函数:枪支的核心交互函数,后续可在其中实现射击、播放音效、生成子弹等逻辑
	void PullTrigger();
cpp 复制代码
//Gun.cpp
AGun::AGun()
{
	PrimaryActorTick.bCanEverTick = true;
	// CreateDefaultSubobject:虚幻专用的组件创建函数,用于在构造阶段创建默认子组件
	// 模板参数<USceneComponent>:指定要创建的组件类型为场景组件
	// TEXT("Scene Root"):给组件命名(编辑器中可见,需唯一),方便识别和调试
	SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("Scene Root"));
	// SetRootComponent:将创建的SceneRoot设置为Actor的根组件
	// 所有Actor必须有且仅有一个根组件,是其他组件的挂载基础
	SetRootComponent(SceneRoot);
	// 创建骨骼网格组件,用于显示枪支的3D骨骼模型
	// TEXT("Mesh"):组件名称,编辑器中会显示该名称
	Mesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Mesh"));
	// SetupAttachment:将Mesh组件挂载到SceneRoot(根组件)上
	// 挂载后,Mesh的位置/旋转/缩放会继承根组件的变换,实现整体移动
	Mesh->SetupAttachment(SceneRoot);
}
void AGun::BeginPlay(){Super::BeginPlay();}
void AGun::Tick(float DeltaTime){Super::Tick(DeltaTime);}
// PullTrigger函数:扣动扳机的核心逻辑实现(当前为测试版本)
void AGun::PullTrigger()
{
	UE_LOG(LogTemp, Display, TEXT("Bang!"));
}
枪械蓝图创建:

新建蓝图, 继承自Gun类, 命名为:BP_Rifle

为蓝图的Mesh挂载骨骼网格体

在虚幻引擎中,枪械模型用骨骼网格体资产(Skeletal Mesh)而非静态网格体资产(Static Mesh),核心原因是枪械需要动画交互,而骨骼网格体是支持 "部件级动画" 的唯一选择。具体可以拆解为这几个实际开发中的需求:

1. 枪械需要 "部件级动画",静态网格体做不到

静态网格体是无骨骼、无关节的 "整体模型" ,只能做 "整体位移 / 旋转 / 缩放",但没法实现枪械的局部部件动画------ 而实际游戏中,枪械的核心交互都依赖局部动画:

  • 扣动扳机时,扳机的小幅度转动;
  • 换弹时,弹匣的插拔、枪栓的拉动;
  • 上膛时,枪机的后坐与复位;
  • 甚至是射击后的枪口轻微晃动、枪托的震动。

骨骼网格体自带骨骼层级结构(比如 "枪身→枪栓→扳机" 的父子骨骼),可以通过动画控制单个骨骼的运动,让这些局部部件独立动起来;而静态网格体是 "一块硬模型",做不了这种细分动画。

2. 骨骼网格体适配虚幻的动画系统

虚幻的动画工具链(动画蓝图、动画蒙太奇、混合空间等)仅支持骨骼网格体

  • 你可以用 "动画蓝图" 给枪械做 "换弹→上膛→射击" 的动画流程逻辑;
  • 可以用 "动画蒙太奇" 触发单次动画(比如按 R 键播放换弹动画);
  • 这些功能静态网格体完全无法使用 ------ 静态网格体只能通过代码手动改整体的 Transform,做不出自然的部件动画。

3. 骨骼网格体支持 "蒙皮权重",动画更自然

骨骼网格体有蒙皮权重(模型顶点与骨骼的绑定关系),可以让模型跟随骨骼运动时产生 "自然的变形"(比如枪托因后坐力轻微弯曲的效果);而静态网格体是 "刚性模型",变形只能靠代码强制拉伸,效果会很僵硬。

静态网格体的适用场景:

静态网格体只适合完全无动画的 "静止物体"(比如墙壁、箱子、路边的石头)------ 这类物体不需要局部部件动,用静态网格体性能更好、资源占用更低。

4. 玩家类获取枪械类指针:

整个流程思路:

在角色的 BeginPlay 中生成枪支 Actor:

"角色初始化→生成枪支→关联枪支→绑定射击交互":

  1. 调用GetWorld()->SpawnActor(生成 Actor 的函数)。

准备 "枪支蓝图对应的 UClass 对象"(连接 C++ 与蓝图的类引用)。

定义 "枪支指针成员变量"(用于保存生成的枪支实例)。

  1. 将枪支的 "所有者" 设置为当前角色。

  2. 当我们射击时,调用(枪支的)PullTrigger函数。

实操:

1. 调用GetWorld()->SpawnActor生成枪支
  • 为什么用GetWorld()->SpawnActor?虚幻中生成 Actor 必须通过 "世界上下文" (即当前关卡的世界对象),GetWorld()是 Character 类的成员函数,能获取当前关卡的世界指针,通过它调用SpawnActor才能把枪支生成到关卡里。
2. 两个核心准备项
  • 枪支蓝图对应的 UClass 对象 :和之前的HUDWidgetClass类似,这是 "C++ 代码与枪支蓝图的桥梁"------ 你需要在 Character 类中声明一个TSubclassOf<AGun>类型的变量(比如叫GunClass

  • 枪支指针成员变量 :在 Character 类中声明一个AGun*类型的成员变量(比如叫CurrentGun),用来保存SpawnActor生成的枪支实例 ------ 后续要调用PullTrigger,必须通过这个指针找到对应的枪支对象。

cpp 复制代码
//ShooterSamCharacter.h
#include "Gun.h"

protected:
	virtual void BeginPlay() override;
public:
	UPROPERTY(EditAnywhere)
	// TSubclassOf<AGun>:虚幻的"类型安全类引用",只能引用AGun类或其蓝图子类
	// 作用:作为C++代码和枪支蓝图的桥梁------在编辑器中把"基于AGun做的枪支蓝图"拖到这个变量上,
	// 代码就能知道要生成哪一种枪(比如手枪、步枪蓝图),且不会选错其他类型的Actor
	TSubclassOf<AGun> GunClass;
	// AGun类型的指针变量:用于保存「SpawnActor生成的枪支实例」
	// 后续调用PullTrigger()、设置枪支所有者、让枪支附着到角色手上等操作,都需要通过这个指针找到对应的枪支对象
	// 相当于角色的"当前持有的枪",是操作枪支的核心入口
	AGun* Gun;
3. 将枪支的所有者设为角色
  • 调用Gun->SetOwner(this)this是当前 Character 实例),作用是:让枪支知道 "自己属于哪个角色",后续可以通过Gun->GetOwner()获取角色,比如让枪支附着在角色的手上(后续可以做 "枪随角色移动" 的逻辑)。
cpp 复制代码
//ShooterSamCharacter.cpp
void AShooterSamCharacter::BeginPlay()
{
	Super::BeginPlay();
	// 生成枪支Actor的核心逻辑:
	// 1. GetWorld():获取当前关卡的世界上下文(虚幻中生成Actor必须依赖世界对象)
	// 2. SpawnActor<AGun>:生成AGun类型的Actor实例
	//    - 模板参数<AGun>:指定要生成的Actor类型
	//    - 参数GunClass:是TSubclassOf<AGun>类型,对应你在编辑器中选择的枪支蓝图
	//    - 此处省略了生成位置/旋转参数(默认生成在世界原点),后续可补充角色手部位置作为生成点
	Gun = GetWorld()->SpawnActor<AGun>(GunClass);
	if (Gun)
	{
		// SetOwner(this):将当前角色设置为枪支的"所有者"
		// 作用1:枪支可通过GetOwner()获取所属角色(比如后续让枪支附着在角色手上)
		// 作用2:虚幻的网络同步/碰撞检测会基于Owner判断归属,多人游戏中必备
		// 作用3:后续射击时,枪支能通过Owner找到角色的视角/位置,实现精准射击
		Gun->SetOwner(this);
	}
}

在BP_ShooterCharacter蓝图编辑器里把 "基于AGun做的枪支蓝图" 拖到这个变量上,SpawnActor会根据这个 UClass 生成对应的枪支实例。

目前还没有指定枪械模型生成的位置, 默认会在0,0,0位置, 而且不会跟手

4. 射击时调用PullTrigger
  • 当你在 Character 类中检测到 "射击输入"(比如按下鼠标左键)时,通过之前保存的CurrentGun指针,调用CurrentGun->PullTrigger()------ 这样就能触发你之前写的 "Bang!" 日志(后续可以扩展成实际射击逻辑)。

5. 玩家模型搭载枪械模型:

思路就是: 把枪械绑定到玩家骨骼的一个骨头上

找到骨骼资产Wraith_Skeleton

应该在:Content\ParagonWraith\Characters\Heroes\Wraith\Meshes\Wraith_Skeleton.uasset

理论上设计师会专门为你预留一个武器骨头, 这个骨头专门用于绑定武器: weapon_r

武器就是骨头, 骨头就是武器, 所以这个骨头是不可以换绑武器的

然而目前我们可以看到, 这个模型已经有武器绑定上了,但不是我们想要的武器

所以我们需要隐藏掉这个武器骨头weapon_r (隐藏操作在后续代码中实现)

然后在骨骼weapon_r下, 创建一个插销用于绑定我们自己的武器,重命名为WeaponSocket

由于WeaponSocket是基于weapon_r的, 所以WeaponSocket绑定武器资产就会在weapon_r的位置

cpp 复制代码
//ShooterSamCharacter.cpp
void AShooterSamCharacter::BeginPlay()
{
	Super::BeginPlay();

	// 隐藏角色骨骼网格体中名为"weapon_r"的骨骼(通常是角色自带的右手武器骨骼)
	// 1. GetMesh():获取角色的骨骼网格体组件(Character的核心显示组件)
	// 2. HideBoneByName:按骨骼名称隐藏指定骨骼
	//    - 第一个参数"weapon_r":要隐藏的骨骼名称(需和角色骨骼文件中的命名一致)
	//    - 第二个参数EPhysBodyOp::PBO_None:隐藏骨骼的操作模式,PBO_None表示仅隐藏视觉显示,不影响物理/碰撞
	// 作用:避免角色自带的默认武器骨骼和我们生成的枪支模型重叠,保证视觉效果统一
	GetMesh()->HideBoneByName("weapon_r", EPhysBodyOp::PBO_None);

	// 生成枪支Actor的核心逻辑:
	// 1. GetWorld():获取当前关卡的世界上下文(虚幻中生成Actor必须依赖世界对象)
	// 2. SpawnActor<AGun>:生成AGun类型的Actor实例
	//    - 模板参数<AGun>:指定要生成的Actor类型
	//    - 参数GunClass:是TSubclassOf<AGun>类型,对应你在编辑器中选择的枪支蓝图
	//    - 此处省略了生成位置/旋转参数(默认生成在世界原点),后续可补充角色手部位置作为生成点
	Gun = GetWorld()->SpawnActor<AGun>(GunClass);

	if (Gun)
	{
		// SetOwner(this):将当前角色设置为枪支的"所有者"
		// 作用1:枪支可通过GetOwner()获取所属角色(比如后续让枪支附着在角色手上)
		// 作用2:虚幻的网络同步/碰撞检测会基于Owner判断归属,多人游戏中必备
		// 作用3:后续射击时,枪支能通过Owner找到角色的视角/位置,实现精准射击
		Gun->SetOwner(this);

		// 将生成的枪支Actor附着到角色的骨骼网格体指定Socket上(核心:让枪支"拿在角色手里")
		// AttachToComponent:将一个组件(枪支的根组件)附着到另一个组件(角色Mesh)上
		// 参数1:目标附着组件(角色的骨骼网格体)
		// 参数2:附着变换规则: KeepRelativeTransform:保持枪支自身的相对变换,以Socket的位置/旋转为基准
		//        其他常用规则:SnapToTargetNotIncludingScale(吸附到目标,不包含缩放)
		// 参数3:角色骨骼中预定义的"武器Socket名称"(需在角色蓝图/骨骼中创建)
		//        Socket是骨骼上的"插槽",比如右手持枪的位置,保证枪支精准附着在角色手上
		Gun->AttachToComponent(
			GetMesh(), 
			FAttachmentTransformRules::KeepRelativeTransform, 
			TEXT("WeaponSocket")
		);
	}
}

6. 微调枪支位置:

启动游戏后, 按F8或者这个键, 进入旁观者模式, 我们可以仔细看看模型是否贴合, 如果不贴合, 就该调整, 直接点击选中, 挪动即可, 如果发现挪不动, 或者异常, 可以调整一下顶部的这些选项

找到合适的位置后, 右击拷贝枪械的位置坐标, 然后进入枪械蓝图BP_rifle

7. 射击命中事件处理-1

我们的目的: 开枪发射子弹, 然后获取命中信息, 处理命中对象

用射线投射取代真实子弹模型, 因为子弹太快, 本来你也看不清;

射线投射就是从玩家视角发射一个射线, 检测是否有命中敌人;

为啥不是武器视角? 因为观感上没啥区别, 我们准星位置就是射线瞄准的区域;

而且从玩家camera投射比从武器投射方便;

让枪支直接关联 "控制角色的控制器",方便后续实现射击视角、输入检测等逻辑,步骤和作用如下:

步骤 1:在Gun类中创建AController*成员变量

首先要明确:AController是虚幻中 "控制角色的核心类"(比如PlayerController是玩家控制器,负责处理玩家输入、视角等)。

Gun.h中添加声明(示例):

cpp 复制代码
//Gun.h
// 用于保存"控制射手角色的控制器"
AController* OwnerController;

作用:让枪支直接获取控制器的信息(比如玩家的视角方向、输入状态),不用绕 "Gun→Owner(角色)→GetController ()" 的步骤,更高效。

步骤 2:在生成枪支后的BeginPlay中赋值

赋值操作要写在角色的BeginPlay里、生成枪支之后(结合你之前的代码):

cpp 复制代码
//ShooterSamCharacter.cpp
void AShooterSamCharacter::BeginPlay()
{
    Super::BeginPlay();
    // ...(隐藏骨骼、生成Gun的代码)...
    if (Gun)
    {
        // ...(...)...
        // 新增:将角色的控制器赋值给Gun的OwnerController
        Gun->OwnerController = this->GetController();
        // ...(附着枪支的代码)...
    }
}

为什么在这里赋值?角色的BeginPlay执行时,已经拥有了自己的控制器(GetController()能拿到有效指针),此时生成枪支后直接赋值,能保证OwnerController是有效的。

枪支后续要实现 "射击射线检测",需要玩家的视角方向 (比如从 "控制器的视角位置" 向 "视角前方" 发射射线)------ 通过OwnerController,枪支可以直接调用OwnerController->GetPlayerViewPoint()获取视角位置和旋转,不用依赖角色的中间传递,逻辑更简洁。

步骤3:实现开火逻辑函数

cpp 复制代码
//Gun.cpp

// 扣动扳机的核心逻辑实现
// 功能:通过持有枪支的玩家控制器获取当前视角信息,并绘制可视化调试相机(仅开发阶段验证视角)
void AGun::PullTrigger()
{
	if (OwnerController)
	{
		// 声明变量:存储玩家视角(相机)在世界中的三维坐标(X/Y/Z)
		FVector ViewPointLocation;

		// 声明变量:存储玩家视角的旋转角度(俯仰角/Pitch、偏航角/Yaw、滚转角/Roll)
		// 决定了玩家"看向哪个方向",是射击射线的核心朝向依据
		FRotator ViewPointRotation;

		// 调用玩家控制器的核心接口:获取当前玩家的实际视角信息
		// 这是"引用传参"的典型用法------函数不会返回值,而是直接把计算结果写入两个变量中
		// 参数1:ViewPointLocation(输出)→ 填充相机的世界位置
		// 参数2:ViewPointRotation(输出)→ 填充相机的朝向
		OwnerController->GetPlayerViewPoint(ViewPointLocation, ViewPointRotation);

		// 绘制调试相机:在编辑器中可视化显示玩家视角,用于验证视角位置/朝向是否正确
		// 仅在开发/编辑器模式下可见,打包发布后自动失效,不影响游戏性能
		DrawDebugCamera(
			GetWorld(),                // 必传:当前关卡的世界上下文(调试绘制需要绑定到具体关卡)
			ViewPointLocation,         // 调试相机的位置(和玩家真实视角位置一致)
			ViewPointRotation,         // 调试相机的朝向(和玩家真实视角朝向一致)
			90.0f,                     // 调试相机的视野(FOV),匹配玩家相机默认的90°视野
			2.0f,                      // 调试相机的绘制缩放比例(数值越大,辅助线越明显)
			FColor::Red,               // 调试相机的绘制颜色(红色醒目,便于识别)
			true                       // 是否持久化显示:true=一直显示,false=仅显示1帧
		);
	}
}

8.命中事件处理-2-Line Trace

进入项目设置-引擎-碰撞, 新建检测通道,命名为Bullet

修改预设, 以下的类型必须被子弹忽略;

NoCollision

Spectator

Trigger

UI

cpp 复制代码
//Gun.h
	UPROPERTY(EditAnywhere)
	//子弹射程
	float MaxRange = 10000.0f;

如何确定检测通道的ID?

进入项目的根目录的Config/DefaultEngine.ini

按ctrl+f搜索Bullet

确定检测通道为: ECC_GameTraceChannel2

cpp 复制代码
//Gun.cpp
// 扣动扳机的核心射击逻辑
// 核心作用:模拟枪支的"射线射击",检测是否命中场景中的物体,并通过调试图形直观验证
void AGun::PullTrigger()
{// 完整流程:获取玩家视角 → 计算射击射线轨迹 → 执行碰撞检测(忽略自身/角色)→ 可视化命中位置
	if (OwnerController)
	{
		// 声明变量:存储玩家视角(相机)在世界空间中的三维坐标(X/Y/Z)
		// 后续作为射线检测的**起点**
		FVector ViewPointLocation;
		// 声明变量:存储玩家视角的旋转信息(Pitch=俯仰角、Yaw=偏航角、Roll=滚转角)
		// 决定射线的发射方向,是射击朝向的核心依据
		FRotator ViewPointRotation;
		// 调用玩家控制器的核心API:获取当前玩家的实际视角数据
		// 引用传参特性:函数不返回值,直接将计算好的"视角位置/朝向"写入两个变量
		// 这是虚幻获取玩家视角的标准方式,比从角色Mesh获取更精准(适配第一/第三人称)
		OwnerController->GetPlayerViewPoint(ViewPointLocation, ViewPointRotation);
		// 计算射线检测的**终点**:射击的最大射程位置
		// 1. ViewPointRotation.Vector():将旋转角度转为"单位方向向量"(仅表示方向,长度=1)
		// 2. * MaxRange:沿方向向量延伸至枪支最大射程(需在Gun.h中声明:UPROPERTY(EditAnywhere) float MaxRange = 1000.0f;)
		// 3. + ViewPointLocation:从玩家视角位置出发,得到射线终点坐标
		FVector EndLocation = ViewPointLocation + ViewPointRotation.Vector() * MaxRange;
		// 声明碰撞命中结果结构体:存储射线检测的所有命中信息(命中对象、命中点、命中法线等)
		FHitResult HitResult;
		// 声明碰撞查询参数:自定义射线检测的规则,避免"误命中"
		FCollisionQueryParams Params;
		// 忽略当前枪支Actor自身:防止射线刚发射就命中枪支模型,导致射击无效
		Params.AddIgnoredActor(this);
		// 忽略枪支的所有者(射手角色):避免第一人称视角下射线命中角色自身
		Params.AddIgnoredActor(GetOwner());
		// 执行单通道射线检测:判断射线是否命中物体(虚幻射击类游戏的核心检测方式)
		// LineTraceSingleByChannel:返回bool值(true=命中,false=未命中)
		bool IsHit = GetWorld()->LineTraceSingleByChannel(
			HitResult,               // 输出参数:命中结果会填充到该结构体
			ViewPointLocation,       // 射线起点(玩家视角)
			EndLocation,             // 射线终点(最大射程)
			ECC_GameTraceChannel2,   // 自定义碰撞通道(需在项目设置→碰撞中配置,比如命名为"WeaponTrace")
			Params                   // 碰撞规则参数(已忽略自身/角色)
		);
		// 如果射线命中了物体
		if (IsHit)
		{
			// 绘制调试球体:在命中点显示红色球体,可视化验证命中位置
			// 仅编辑器/开发模式可见,打包后自动失效,不影响游戏性能
			DrawDebugSphere(
				GetWorld(),            // 当前关卡的世界上下文(调试绘图必须绑定)
				HitResult.ImpactPoint, // 射线命中物体的精确位置(世界坐标)
				5.0f,                  // 球体半径(5厘米,醒目且不遮挡场景)
				16,                    // 球体分段数(16段足够圆润,性能开销小)
				FColor::Red,           // 球体颜色(红色易识别)
				true                   // 是否持久显示(true=一直显示,false=仅显示1帧)
			);
		}
	}
}

9. 为枪械添加粒子效果:

步骤1: 调用Niagara粒子系统:

在文件:ShooterSam.Build.cs中, 添加Niagara:

关闭UE, 重新生成解决方案, 关闭VS

进入项目文件夹:右击ShooterSam.uproject, 选择Generate VS

我们将会使用此路径下的粒子特效:

ShooterSam\Content\ParagonWraith\FX\Particles\Abilities\Primary\FX\

步骤2: 安装转换插件并调试粒子特效

由于这个粒子系统是UE的原版Cascade粒子系统, 我们使用的是Niagara, 所以我们需要转换类型, 转换需要安装插件:

这个插件需要重启UE, 我们直接重启;

右击粒子就可以选择转换为Niagara系统了

先把这两个转了, 其他的先不转, 卡是正常的, 我5090转这个都卡飞了;

转好后, 我们进去看看这个特效, 如果看起来有问题, 就去修改一下, 不会改的话, 就乱改一下;

比如P_Wraith_Primary_HitWorld_Converted和P_Wraith_Primary_MuzzleFlash_Converted

这两个特效就要把最后这个ShockWava节点关掉, 不然影响观感;

步骤3: 为Gun类配置粒子系统

配置枪口火焰和命中特效::
cpp 复制代码
//Gun.h

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
//------------------------------------------
#include "NiagaraFunctionLibrary.h"
#include "NiagaraComponent.h"
//-------------------------------------------
#include "Gun.generated.h"
/*
....
*/
public:
	UPROPERTY(VisibleAnywhere)
	USkeletalMeshComponent* Mesh;
//------------------------------------------------
	// 枪口火焰Niagara粒子组件(常驻组件)
	// 变量类型UNiagaraComponent:Niagara粒子的"运行时组件",是粒子特效在场景中的"实例载体"
	// 特性:常驻于枪支Actor中,可通过Activate/Deactivate控制播放/停止,适合需要重复触发的特效(如枪口火焰)
	UPROPERTY(VisibleAnywhere)
	UNiagaraComponent* MuzzleFlashParticleSystem;

	// 命中冲击Niagara粒子资产(一次性特效模板)
	// 变量类型UNiagaraSystem:Niagara粒子的"资源模板",是保存在内容浏览器中的静态资产(无运行时状态)
	// 特性:本身不能直接显示,需通过UNiagaraFunctionLibrary::SpawnSystemAtLocation生成一次性实例
	// 适用场景:射击命中后的冲击特效(每次命中生成独立实例,播放完自动销毁)
	UPROPERTY(EditAnywhere)
	UNiagaraSystem* ImpactParticleSystem;
//-------------------------------------------------
	UPROPERTY(EditAnywhere)
	float MaxRange = 10000.0f;
cpp 复制代码
//Gun.cpp
AGun::AGun()
{
	/*
    ......
    */
	// 创建枪口火焰粒子组件(基于Niagara引擎,虚幻新一代粒子系统,效果更优)
	// CreateDefaultSubobject:虚幻构造阶段创建默认子组件的标准函数,仅在构造函数中使用
	// 模板参数<UNiagaraComponent>:指定创建的组件类型为Niagara粒子组件(区别于旧的UParticleSystemComponent)
	// TEXT("Muzzle Flash"):组件名称(编辑器中可见),语义化命名便于识别和调试
	MuzzleFlashParticleSystem = CreateDefaultSubobject<UNiagaraComponent>(TEXT("Muzzle Flash"));

	// 将枪口火焰粒子组件挂载到枪支的骨骼网格组件(Mesh)上
	// SetupAttachment:组件挂载核心函数,让子组件(MuzzleFlash)跟随父组件(Mesh)的位置/旋转同步变化
	// 作用:枪口火焰会随枪支模型一起移动(比如角色举枪转动时,火焰位置同步更新)
	MuzzleFlashParticleSystem->SetupAttachment(Mesh);
}

void AGun::BeginPlay(){
	Super::BeginPlay();
	// 停用枪口火焰Niagara粒子组件,停止特效播放
	// Deactivate():Niagara组件的核心控制函数,作用是"软停止"粒子特效
	// 核心特性:
	// 1. 停止生成新的粒子(火焰/光效不再新增);
	// 2. 已生成的粒子(如枪口火焰的余烬、烟雾)会按自身生命周期自然播放完毕,视觉效果更真实;
	// 3. 组件本身不会被销毁,可再次调用Activate()重新激活,无需重新创建
	MuzzleFlashParticleSystem->Deactivate();
}

void AGun::PullTrigger()
{
    // 激活枪口火焰Niagara粒子组件,开始特效播放
	MuzzleFlashParticleSystem->Activate(true);
    if (OwnerController)
    {
        /*
        ......
        */
		if (IsHit)
		{
			// 在命中位置生成冲击粒子特效(如弹痕、火花、尘土)
			// UNiagaraFunctionLibrary::SpawnSystemAtLocation:生成一次性Niagara粒子的核心函数
			// 特性:生成独立的粒子实例,播放完毕后自动销毁,无需手动管理组件,适合一次性特效(如弹痕、爆炸)
			UNiagaraFunctionLibrary::SpawnSystemAtLocation(
				GetWorld(),                          // 必传:当前关卡的世界上下文(粒子需生成在具体关卡中)
				ImpactParticleSystem,                // 核心参数:Niagara粒子资产(需在Gun.h中声明并在编辑器中指定,如弹痕/火花特效)
				HitResult.ImpactPoint,               // 粒子生成位置:射线命中物体的精确世界坐标(冲击点)
				HitResult.ImpactPoint.Rotation()     // 粒子生成旋转:基于冲击点坐标的默认旋转(优化:建议改用HitResult.ImpactNormal.Rotation(),让粒子贴合命中表面)
			);
		}
    }
}

优化建议:实际开发中需挂载到枪支Mesh的"枪口Socket"(如MuzzleSocket),而非直接挂载到Mesh根节点,保证火焰精准出现在枪口位置

示例:MuzzleFlashParticleSystem->SetupAttachment(Mesh, TEXT("MuzzleSocket"));

在枪械蓝图BP_Rifle中挂载枪口粒子特效和击中粒子特效, 然后调整枪口粒子特效位置到枪口;


10. 命中伤害值

我们之前的线性追踪通道LineTraceSingleByChannel()会返回一个命中结果HitResult, 我们需要做的就是解析这个结果, 获取到命中物体的指针, 然后我们就可以为所欲为了, 比如扣这个角色的血量

声明血量:

cpp 复制代码
//Gun.h
public:
	UPROPERTY(EditAnywhere)
	float BulletDamage = 10.0f;

构建伤害模式:

cpp 复制代码
//Gun.cpp
#include "Kismet/GameplayStatics.h"

void AGun::PullTrigger()
{
	// 激活枪口火焰Niagara粒子组件,开始特效播放
	MuzzleFlashParticleSystem->Activate(true);
	if (OwnerController)
	{
		/*
        ........    
        */
		if (IsHit)
		{
			/*
            .........
            */
			// 从射线检测的命中结果中提取被击中的Actor(伤害施加的目标)
			// HitResult.GetActor():返回射线命中的第一个有效Actor(如敌人、可破坏箱子);
			// 若仅命中静态网格(如墙壁)/无Actor的碰撞体,返回nullptr,需做空指针检查
			AActor* HitActor = HitResult.GetActor();
			// 空指针安全校验:确保被击中的Actor有效,避免给空对象调用ApplyDamage导致崩溃
			if (HitActor)
			{
				// 调用虚幻全局静态函数ApplyDamage:给目标Actor施加伤害(官方标准伤害系统入口)
				// 该函数会自动触发HitActor的TakeDamage()虚函数,需在目标类(如敌人类)中重写此函数实现扣血/死亡
                // 该函数也会触发伤害委托(Delegate)(OnTakeAnyDamage)作为补充回调方式
				UGameplayStatics::ApplyDamage(
					HitActor,                // 必传:伤害目标(被击中的Actor)
					BulletDamage,            // 核心:单次射击的伤害值(需在Gun.h中声明为可编辑变量,如默认10点)
					OwnerController,         // 伤害归属的控制器(玩家控制器):多人游戏中标记"谁造成的伤害",网络同步必备
					this,                    // 伤害发起者(Instigator):触发伤害的Actor(此处为枪支,可追踪伤害来源)
					UDamageType::StaticClass() // 伤害类型:默认基础类型;可自定义子类(如UBulletDamageType)区分伤害类型(子弹/爆炸)
				);
			}
		}
	}
}

这个ApplyDemage()会自动触发委托实例OnTakeAnyDamage, 我们要向这个委托绑定响应函数;

于是我们要先明白此委托的函数签名模式, 然后再写逻辑;

如何知道签名模式呢?右击OnTakeAnyDamage,选择速览定义,

FTakeAnyDamageSignature OnTakeAnyDamage

然后右击委托类名FTakeAnyDamageSignature继续查找定义, 然后就可以看到了;

这个流程如果你不懂?看这个教学

草履虫也能看懂的虚幻引擎的委托机制https://blog.csdn.net/Howrun777/article/details/155206084

现在创建并绑定响应函数:

cpp 复制代码
//ShooterSamCharacter.h
UFUNCTION()
	void OnDamageTaken(AActor* DamagedActor, float Damage, const class UDamageType* DamageType, class AController* InstigatedBy, AActor* DamageCauser);
cpp 复制代码
//ShooterSamCharacter.cpp
void AShooterSamCharacter::BeginPlay()
{
	Super::BeginPlay();
    //为伤害委托绑定响应函数
	OnTakeAnyDamage.AddDynamic(this, &AShooterSamCharacter::OnDamageTaken);
	/*
    .........
    */
}
//伤害响应函数逻辑实现
void AShooterSamCharacter::OnDamageTaken(AActor* DamagedActor, float Damage, const UDamageType* DamageType, AController* InstigatedBy, AActor* DamageCauser)
{
	UE_LOG(LogTemp, Display, TEXT("Damage taken: %f"), Damage);
}

11. 生命值系统:

cpp 复制代码
//ShooterSamCharacter.h
public:
	UPROPERTY(EditAnywhere)
	//最大生命值
	float MaxHealth = 100.0f;
	//当前生命值
	float Health;
	UPROPERTY(BlueprintReadOnly)//方便在动画蓝图中调用
	//存活状态
	bool IsAlive = true;
cpp 复制代码
//ShooterSamCharacter.cpp
void AShooterSamCharacter::BeginPlay()
{
	Super::BeginPlay();
	Health = MaxHealth;
    /*
    ......
    */
}
// 角色受伤害的委托回调函数(绑定到AActor::OnTakeAnyDamage委托)
// 当角色受到任意伤害时,该函数会被自动调用,处理扣血、死亡逻辑
void AShooterSamCharacter::OnDamageTaken(AActor* DamagedActor, float Damage, const UDamageType* DamageType, AController* InstigatedBy, AActor* DamageCauser)
{
	if (IsAlive)
	{
		UE_LOG(LogTemp, Display, TEXT("Damage taken: %f"), Damage);
		Health -= Damage;
		// 血量≤0时触发角色死亡逻辑
		if (Health <= 0.0f)
		{
			// 标记角色为死亡状态:防止后续重复处理死亡逻辑
			IsAlive = false;
			// 安全处理:将血量强制置0(避免出现负数血量,导致逻辑异常)
			Health = 0.0f;
			// 关闭胶囊体碰撞:角色死亡后不再被射线/物理碰撞检测到(避免死后仍被攻击、卡地形)
			// GetCapsuleComponent():Character的核心碰撞组件,控制角色的碰撞/体积
			// ECollisionEnabled::NoCollision:完全关闭碰撞,包括检测和响应
			GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
			// 打印死亡日志:输出角色名称,便于调试时区分哪个角色死亡
			UE_LOG(LogTemp, Display, TEXT("Character died: %s"), *GetActorNameOrLabel());
		}
	}
}

12. 死亡动画:

死亡动画: Death_Forward

Content\ParagonWraith\Characters\Heroes\Wraith\Animations\Death_Forward.uasset

我们的思路是: 在原来的动画蓝图中加入一个生与死的状态机Main, 创建一个bool变量:IsDead, 然后根据这个变量切换生到死的动作, 然后在事件图表中捕获IsAlive变量, 为IsDead传值;

动画图表制作:

由于生存时的状态就是之前正常的状态, 死的状态就一个单次的死亡动画, 所以我们可以把之前的Locomation等动画直接剪切到生存时的动画状态机

事件图表制作:

捕获IsAlive变量, 为IsDead传值

章节四. Ai敌人

这个章节特别复杂麻烦, 我到时候会专门写一篇blog详细系统性讲解AI行为树相关的知识;

你需要明白一个大致的框架:

AI敌人本质上等于: 用BP_ShooterAI蓝图 来操控一个角色 , 蓝图主要完成AI行为树的初始化和启动, AI行为树 是AI行动的核心, AI行为树里面有很多逻辑分支 , 然后AI行为树的逻辑分支会依赖黑板提供数据;

新建AI蓝图并配置基础属性:

新建C++类,继承自AIController, 命名为:ShooterAI

新建蓝图, 继承自ShooterAI, 命名为BP_ShooterAI

在BP_ShooterCharacter蓝图中, 选择BP_ShooterAI作为默认AI控制器;

ShooterAI类用来完成AI对角色的控制, 然而ShooterAI的实现需要用行为树来规划ShooterAI的行为;

cpp 复制代码
//ShooterAI.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "ShooterAI.generated.h"
UCLASS()
class SHOOTERSAM_API AShooterAI : public AAIController
{
	GENERATED_BODY()
protected:
	virtual void BeginPlay() override;

};
cpp 复制代码
//ShooterAI.cpp
#include "ShooterAI.h"
#include "Kismet/GameplayStatics.h"
void AShooterAI::BeginPlay()
{
    Super::BeginPlay();
    // 获取游戏中第一个玩家的Pawn(玩家控制的角色)
    // UGameplayStatics::GetPlayerPawn:全局静态函数,用于获取指定索引的玩家Pawn
    // 参数1:GetWorld() → 当前关卡的世界上下文,必传
    // 参数2:0 → 玩家索引(多人游戏中0对应第一个玩家,1对应第二个,单人游戏固定传0)
    // 返回值:APawn* → 玩家的可操控角色(如你的ShooterSamCharacter),若没有玩家则返回nullptr
    APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
    if (PlayerPawn)
    {
        // 给AI设置"焦点目标"为玩家Pawn(核心:AI会持续追踪该目标)
        // SetFocus:AI Controller的核心函数,用于指定AI的关注目标
        // 1. AI的感知系统、寻路系统、行为树可基于该焦点目标做决策(比如向焦点移动、攻击焦点);
        // 2. 会自动更新AI的"视线方向"(比如AI会看向玩家);
        // 3. 焦点有优先级(默认Gameplay级别,可在参数中指定)
        SetFocus(PlayerPawn);
        // 清除AI的焦点目标(取消追踪)
        // Gameplay:焦点优先级(Gameplay是最高优先级之一)
        // ClearFocus(EAIFocusPriority::Gameplay);
    }
}

添加导航网格:

这个东西规定了AI的运动范围

导航网格默认是透明的, 打开这个眼睛的导航选项就可以看到了导航网格的投射了

AI基础接口:

1. AI向玩家移动的简单逻辑:

下面的这些代码是用来测试功能的, 后面要删的, 有点丑陋, 每帧都获取玩家指针, 我们可以用一个成员变量存放, 然后再BeginPlay()里面调用一次就行;

cpp 复制代码
//ShooterAI.cpp
void AShooterAI::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // 获取游戏中第一个玩家的Pawn(玩家控制的角色,如ShooterSamCharacter)
    // 参数1:GetWorld() → 当前关卡的世界上下文(必传)
    // 参数2:0 → 玩家索引(单人游戏固定传0,多人游戏可遍历索引获取多个玩家)
    // 返回值:若玩家存在则返回玩家Pawn,否则返回nullptr
    APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
    if (PlayerPawn)
    {
        // 核心:让AI向玩家Pawn移动(虚幻AI导航系统的核心函数)
        // MoveToActor:AI Controller的寻路函数,自动调用导航系统计算路径,控制AI移动到目标
        // 参数1:PlayerPawn → 移动的目标Actor(这里是玩家)
        // 参数2:200.0f → 停止距离(单位:厘米,虚幻中1单位=1厘米 → 200.0f=2米)
        //        作用:AI不会贴到玩家脸上,会在距离玩家2米的位置停止移动(预留攻击/反应空间)
        // 隐藏特性:该函数会持续尝试寻路,即使路径被阻挡,也会每帧重新计算路径
        MoveToActor(PlayerPawn, 200.0f);
    }
}

2. AI视线遮挡:能否看到玩家

cpp 复制代码
void AShooterAI::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // 获取游戏中第一个玩家的Pawn(玩家控制的角色,如ShooterSamCharacter)
    // 参数1:GetWorld() → 当前关卡的世界上下文(必传)
    // 参数2:0 → 玩家索引(单人游戏固定传0,多人游戏可遍历索引获取多个玩家)
    // 返回值:若玩家存在则返回玩家Pawn,否则返回nullptr
    APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
    if (PlayerPawn)
    {
        // 核心判断:AI是否能"看到"玩家(视线无遮挡)
        // LineOfSightTo:AI Controller内置的智能视线检测函数(优于手动射线检测)
        // 底层逻辑:
        // 1. 起点:AI的感知位置(如眼睛Socket);终点:玩家Pawn的碰撞中心
        // 2. 自动忽略AI自身、导航网格等无关碰撞体
        // 3. 适配AI的视野锥角度(非单纯直线,更符合"AI视野"逻辑)
        // 返回值:true=视线无遮挡(能看到玩家);false=视线被墙壁/障碍物阻挡
        if (LineOfSightTo(PlayerPawn))
        {
            // 给AI设置"焦点目标"为玩家 → AI会持续追踪玩家位置、自动转向玩家
            // 焦点是AI决策的核心参考(后续攻击、寻路都基于此目标)
            SetFocus(PlayerPawn);
            // 核心:让AI向玩家Pawn移动(虚幻AI导航系统的核心函数)
            // MoveToActor:AI Controller的寻路函数,自动调用导航系统计算路径,控制AI移动到目标
            // 参数1:PlayerPawn → 移动的目标Actor(这里是玩家)
            // 参数2:200.0f → 停止距离(单位:厘米,虚幻中1单位=1厘米 → 200.0f=2米)
            //        作用:AI不会贴到玩家脸上,会在距离玩家2米的位置停止移动(预留攻击/反应空间)
            // 隐藏特性:该函数会持续尝试寻路,即使路径被阻挡,也会每帧重新计算路径
            MoveToActor(PlayerPawn, 200.0f);
        }
        else
        {
            // 清空AI的焦点目标(取消对玩家的追踪)
            // EAIFocusPriority::Gameplay → 清空最高优先级的焦点(避免低优先级焦点干扰)
            ClearFocus(EAIFocusPriority::Gameplay);
            // 立即终止AI的所有寻路请求 → 让AI原地静止
            // 若不调用此函数:AI会继续执行之前的MoveToActor请求,出现"原地卡帧/向旧位置移动"的问题
            StopMovement();
        }
    }
}

AI行为树:

注释掉之前写的代码, 那些功能太简单了, 我们要借助行为树这个超强的工具:

cpp 复制代码
//ShooterAI.cpp
#include "ShooterAI.h"
#include "Kismet/GameplayStatics.h"

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

}
void AShooterAI::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

新建行为树和黑板:

命名为:BB_EnemyAI和BT_EnemyAI

请确保行为树的黑板资产绑定到BB_EnemyAI

维度 行为树(Behavior Tree) 黑板(Blackboard)
核心作用 定义 AI 的 "行为逻辑"(决策 + 行动) 存储 AI 的 "状态数据"(供行为树读取 / 写入)
表现形式 可视化的节点网络(根节点、选择器、任务等) 键值对的 "数据面板"(变量名 + 变量值)
核心内容 逻辑节点(选择器、序列、装饰器、任务) 数据变量(Bool/Vector/Float/Actor 等)
执行逻辑 从根节点开始,按节点规则循环执行(决策→行动) 被动存储 / 读取数据,无 "执行" 逻辑,仅做数据中转
是否产生行为 是(任务节点直接执行动作:移动、攻击、巡逻) 否(仅存数据,不直接产生任何行为)
举例(你的射击 AI) 1. 选择器节点:优先攻击→再追击→再巡逻2. 装饰节点:判断 "是否看到玩家"3. 任务节点:执行 "向玩家移动" 1. 变量 bIsPlayerInSight(是否看到玩家:true/false)2. 变量 PlayerLocation(玩家位置:Vector)3. 变量 CurrentHealth(AI 血量:100)

类中调用行为树:

cpp 复制代码
//ShooterAI.h
public:
	UPROPERTY(EditAnywhere)
	UBehaviorTree* EnemyAIBehaviorTree;
cpp 复制代码
//ShooterAI.cpp
void AShooterAI::BeginPlay()
{
    Super::BeginPlay();

    if (EnemyAIBehaviorTree)
    {
        // 第四步:启动行为树, 让AI开始执行行为树中的节点逻辑
        RunBehaviorTree(EnemyAIBehaviorTree);
    }

}

蓝图中挂载行为树BT_EnemyAI;

行为树的基础操作:

这是根节点, 每帧执行:

创建一个变量MoveLocation,存放一个地点, 执行行为树后 然后AI角色就会Move To这个地点


调用行为树:

cpp 复制代码
//ShooterAI.h
#include "ShooterSamCharacter.h"
public:
	// 1. 指向玩家角色的指针(存储AI需要攻击/追击的目标玩家)
	// AShooterSamCharacter:你的玩家角色类(继承自ACharacter),区别于通用的APawn,可直接访问玩家的血量、武器等专属属性
	AShooterSamCharacter* PlayerCharacter;
	// 2. 指向AI自身控制的角色指针(可选,用于获取AI自身的状态,如血量、位置)
	// 命名建议:改为AIShooterCharacter更易区分,避免和PlayerCharacter混淆
	AShooterSamCharacter* MyCharacter;
	// 3. 行为树启动函数:接收玩家角色作为参数,初始化行为树并绑定黑板数据
	// 参数Player:传入的目标玩家,让AI知道要攻击谁
	void StartBehaviorTree(AShooterSamCharacter* Player);
cpp 复制代码
//ShooterAI.cpp
// AI行为树启动函数:用于初始化AI自身角色、绑定目标玩家,并启动预设的行为树逻辑
// 参数 Player:传入的目标玩家角色(AI需要攻击/追击的对象)
void AShooterAI::StartBehaviorTree(AShooterSamCharacter* Player)
{
    // 第一步:校验行为树资产是否有效(行为树为空则后续逻辑无意义,直接跳过)
    // EnemyAIBehaviorTree是AI的行为树资产,需在编辑器中提前指定(如巡逻/攻击/追击逻辑)
    if (EnemyAIBehaviorTree)
    {
        // 第二步:获取AI控制器所控制的角色,并转换为自定义的ShooterSamCharacter类型
        // GetPawn():获取当前AI Controller所拥有的Pawn(即AI自身的角色实例)
        // Cast<>:类型转换,将通用APawn转换为自定义的玩家/AI角色类,方便访问专属属性(如血量、武器)
        MyCharacter = Cast<AShooterSamCharacter>(GetPawn());

        // 第三步:校验传入的玩家角色是否有效,有效则赋值给AI的PlayerCharacter变量(记录目标玩家)
        if (Player)
        {
            PlayerCharacter = Player;
        }

        // 第四步:启动行为树(核心操作)
        // RunBehaviorTree():让AI开始执行行为树中的节点逻辑(如巡逻、追击、攻击)
        // 注意:此处未绑定黑板数据,行为树无法直接读取PlayerCharacter,需补充黑板写入逻辑
        RunBehaviorTree(EnemyAIBehaviorTree);
    }
}

获取当前关卡的玩家角色 + 收集场景中所有的ShooterAI控制器Actor, 方便行为树调用

cpp 复制代码
//ShooterSamGameMode.h
protected:
	virtual void BeginPlay() override;
cpp 复制代码
//ShooterSamGameMode.cpp

#include "Kismet/GameplayStatics.h"
#include "ShooterSamCharacter.h"
#include "ShooterAI.h"

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

    // 第一步:获取游戏中第一个玩家的Pawn,并转换为自定义的ShooterSamCharacter类型
    // 1. UGameplayStatics::GetPlayerPawn:全局静态函数,获取指定索引的玩家可操控角色(Pawn)
    // 2. Cast<AShooterSamCharacter>:将通用的APawn类型转换为自定义玩家角色类
    AShooterSamCharacter* Player = Cast<AShooterSamCharacter>(UGameplayStatics::GetPlayerPawn(GetWorld(), 0));

    // 第二步:定义一个Actor数组,用于存储场景中所有的ShooterAI控制器实例
    // TArray<AActor*>:虚幻的动态数组容器,专门存储Actor指针(自动管理内存,比原生数组更安全)
    TArray<AActor*> ShooterAIActors;

    // 第三步:收集场景中所有类型为AShooterAI的Actor,存入上述数组
    // UGameplayStatics::GetAllActorsOfClass:全局静态函数,批量获取关卡中指定类的所有Actor实例
    // - 参数1:GetWorld() → 当前关卡的世界上下文
    // - 参数2:AShooterAI::StaticClass() → 要查找的Actor类(此处为自定义的AI控制器类)
    // - 参数3:ShooterAIActors → 输出参数,找到的所有AI Actor会存入这个数组
    // 注意:若场景中无AShooterAI实例,该数组会为空,需后续做空数组校验
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), AShooterAI::StaticClass(), ShooterAIActors);
    
    // 第四步:遍历所有找到的AI控制器,为每个AI绑定玩家并启动行为树
    // 循环方式:索引遍历(新手易理解,也可改用范围for循环:for (AActor* AIActor : ShooterAIActors))
    // LoopIndex:循环索引,int32是虚幻推荐的整数类型(适配不同平台)
    // ShooterAIActors.Num():获取数组元素个数,作为循环终止条件
    for (int32 LoopIndex = 0; LoopIndex < ShooterAIActors.Num(); LoopIndex++)
    {
        // 取出数组中当前索引的AI Actor(通用AActor类型)
        AActor* ShooterAIActor = ShooterAIActors[LoopIndex];

        // 将通用AActor转换为自定义AShooterAI类型(AI控制器专属类)
        // Cast<AShooterAI>:确保只处理AI控制器,过滤掉其他无关Actor(虽然GetAllActorsOfClass已筛选,但双重校验更安全)
        AShooterAI* ShooterAI = Cast<AShooterAI>(ShooterAIActor);
        if (ShooterAI)
        {
            // 调用AI的行为树启动函数,传入玩家角色(让AI知道要攻击/追击的目标)
            ShooterAI->StartBehaviorTree(Player);
            UE_LOG(LogTemp, Display, TEXT("%s starting behavior tree"), *ShooterAI->GetActorNameOrLabel());
        }
    }
}

StaticClass() 是虚幻引擎中 所有 UClass 派生类的静态成员函数 ,核心作用是:返回当前类的 UClass* 类型指针(可以理解为 "类的唯一身份证")------ 这个指针不是类的 "实例"(比如某个具体的 AI、某个玩家),而是类的 "元数据"(描述这个类是什么、有哪些属性 / 函数)。

现在我们让AI追杀玩家, 那么就需要把玩家的坐标传给AI, 我们把原来的MoveLocation改为PlayerLocation;然后再新建一个AI敌人的起始位置

安全地向黑板写入数据:

由于我们需要为黑板传入 玩家和 AI 的关键位置 然后再启动行为树, 所以我们写这样的函数StartBehaviorTree() , 实现黑板数据初始化和行为树启动

cpp 复制代码
//ShooterAI.cpp
#include "BehaviorTree/BlackboardComponent.h"
// AI行为树启动函数:用于初始化AI自身角色、绑定目标玩家,并启动预设的行为树逻辑
// 参数 Player:传入的目标玩家角色(AI需要攻击/追击的对象)
void AShooterAI::StartBehaviorTree(AShooterSamCharacter* Player)
{
        /*
        ......
        */
        // 第一步:获取当前AI控制器绑定的黑板组件(行为树的"数据仓库")
        // GetBlackboardComponent():AAIController的内置函数,返回与行为树绑定的UBlackboardComponent指针
        // 作用:通过该组件可读写黑板中的所有变量(如PlayerLocation、StartLocation)
        UBlackboardComponent* MyBlackboard = GetBlackboardComponent();

        // 第二步:多条件安全校验(缺一不可,避免空指针崩溃)
        // 1. MyBlackboard:确保黑板组件初始化成功(未绑定黑板资产时会返回nullptr)
        // 2. PlayerCharacter:确保目标玩家角色有效(未找到玩家时为nullptr)
        // 3. MyCharacter:确保AI自身角色有效(AI未绑定角色时为nullptr)
        if (MyBlackboard && PlayerCharacter && MyCharacter)
        {
            // 向黑板写入玩家的实时世界坐标(供行为树的"追击玩家"节点读取)
            // SetValueAsVector:将Vector类型数据写入黑板,第一个参数是黑板变量名(需与黑板资产中完全一致)
            // PlayerCharacter->GetActorLocation():获取玩家角色的根组件世界位置(X/Y/Z坐标)
            MyBlackboard->SetValueAsVector("PlayerLocation", PlayerCharacter->GetActorLocation());

            // 向黑板写入AI自身的初始位置(供行为树的"返回初始位置"节点读取,如玩家消失后AI返回出生点)
            // MyCharacter->GetActorLocation():获取AI角色的根组件世界位置
            MyBlackboard->SetValueAsVector("StartLocation", MyCharacter->GetActorLocation());
        }
        // 启动行为树(核心操作)
        // RunBehaviorTree():让AI开始执行行为树中的节点逻辑(如巡逻、追击、攻击)
        RunBehaviorTree(EnemyAIBehaviorTree);
    }
}

行为树增添节点:

例子1:

敌人按顺序执行以下步骤:

  1. 走向玩家位置

  2. 原地等待3秒

  3. 走向原来的位置

  4. 原地等待5秒

例子2:

添加装饰器:

这个装饰器的功能是, 检测黑板的某个值是否已经设置, 如果没有设置, 就不执行之后的内容, 比如敌人没有找到玩家的位置, 就不执行移动操作;

添加服务:

创建服务:

新建C++类, 命名为BTService_PlayerLocationIfSeen

cpp 复制代码
//BTService_PlayerLocationIfSeen.cpp
#include "BTService_PlayerLocationIfSeen.h"

UBTService_PlayerLocationIfSeen::UBTService_PlayerLocationIfSeen()
{
	NodeName = TEXT("Update PlayerLocation If Seen");
}

void UBTService_PlayerLocationIfSeen::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

	UE_LOG(LogTemp, Display, TEXT("Service is ticking: %f"), DeltaSeconds);
}
cpp 复制代码
//BTService_PlayerLocationIf
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/Services/BTService_BlackboardBase.h"
#include "BTService_PlayerLocationIfSeen.generated.h"
UCLASS()
class SHOOTERSAM_API UBTService_PlayerLocationIfSeen : public UBTService_BlackboardBase
{
	GENERATED_BODY()

public:
	UBTService_PlayerLocationIfSeen();

	virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
};Seen.h

为行为树添加服务:

首节点的服务会一直保持活跃, 分支的服务只会在那条分支触发;

这段代码的核心作用是 "行为树运行时,每帧检测 AI 是否能看到玩家 → 能看到则更新玩家位置到黑板 + 设焦点 → 看不到则清空位置 + 清焦点",是射击游戏 AI "动态追踪玩家" 的核心服务节点;

cpp 复制代码
//BTService_PlayerLocationIfSeen.cpp
#include "BTService_PlayerLocationIfSeen.h"
#include "ShooterAI.h"
#include "BehaviorTree/BlackboardComponent.h"

// 服务节点的构造函数:初始化节点基础属性
UBTService_PlayerLocationIfSeen::UBTService_PlayerLocationIfSeen()
{
    // 设置行为树编辑器中显示的节点名称(便于识别,非必需但建议设置)
    // 作用:在行为树编辑器中,该节点会显示为"Update PlayerLocation If Seen",而非默认类名
    NodeName = TEXT("Update PlayerLocation If Seen");
}

// 服务节点的核心Tick函数:每帧执行(行为树运行时),用于动态更新黑板数据
// 参数说明:
// - OwnerComp:当前行为树组件(关联AI控制器和行为树)
// - NodeMemory:节点内存(存储节点运行时状态,新手暂无需关注)
// - DeltaSeconds:帧间隔时间(秒),用于时间相关逻辑补偿
void UBTService_PlayerLocationIfSeen::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
    // 第一步:获取当前服务节点所属的AI控制器(转换为自定义ShooterAI类)
    // OwnerComp.GetAIOwner():获取行为树组件绑定的AI控制器(通用AAIController类型)
    // Cast<AShooterAI>:转换为自定义AI控制器,方便访问专属属性(如PlayerCharacter、LineOfSightTo)
    AShooterAI* OwnerController = Cast<AShooterAI>(OwnerComp.GetAIOwner());
    // 第二步:从AI控制器中获取已绑定的目标玩家角色
    // PlayerCharacter:AI控制器中存储的玩家引用(由StartBehaviorTree函数传入)
    AShooterSamCharacter* Player = OwnerController->PlayerCharacter;
    // 第三步:获取AI控制器绑定的黑板组件(用于读写黑板变量)
    UBlackboardComponent* Blackboard = OwnerController->GetBlackboardComponent();
    // 第四步:多条件安全校验(避免空指针崩溃)
    // 1. OwnerController:确保AI控制器有效
    // 2. Player:确保目标玩家角色有效(未死亡/未销毁)
    // 3. Blackboard:确保黑板组件初始化成功
    if (OwnerController && Player && Blackboard)
    {
        // 核心逻辑:判断AI是否能"看到"玩家(视线无遮挡)
        // LineOfSightTo:AI控制器内置的智能视线检测函数(优于手动射线检测)
        // 底层:自动检测AI感知位置到玩家碰撞中心的视线,忽略自身/导航网格等无关碰撞
        if (OwnerController->LineOfSightTo(Player))
        {
            // 能看到玩家:更新黑板中的玩家位置变量
            // GetSelectedBlackboardKey():获取行为树编辑器中为该节点选择的黑板变量名(如"PlayerLocation")
            // SetValueAsVector:将玩家实时位置写入黑板,供行为树的追击/攻击节点读取
            Blackboard->SetValueAsVector(GetSelectedBlackboardKey(), Player->GetActorLocation());
            // 给AI设置焦点为玩家:AI会自动转向玩家,保证追击/攻击时朝向正确
            OwnerController->SetFocus(Player);
        }
        else
        {
            // 看不到玩家:清空黑板中的玩家位置变量
            // ClearValue:避免AI使用旧的玩家位置继续追击(防止AI"穿墙追玩家")
            Blackboard->ClearValue(GetSelectedBlackboardKey());
            // 清空AI的焦点:取消对玩家的追踪,AI恢复默认朝向
            // EAIFocusPriority::Gameplay:清空游戏性优先级的焦点(最高优先级)
            OwnerController->ClearFocus(EAIFocusPriority::Gameplay);
        }
    }
}


现在运行游戏, 角色总是会卡顿, 解决方法是:

让角色MoveTo时刻获取玩家的位置

现在再为分支写一个服务类:BTService_PlayerLocation

cpp 复制代码
//BTService_PlayerLocation.h
#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/Services/BTService_BlackboardBase.h"
#include "BTService_PlayerLocation.generated.h"
UCLASS()
class SHOOTERSAM_API UBTService_PlayerLocation : public UBTService_BlackboardBase
{
	GENERATED_BODY()
	
public:
	UBTService_PlayerLocation();

	virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
};
cpp 复制代码
//BTService_PlayerLocation.cpp
#include "BTService_PlayerLocation.h"

#include "Kismet/GameplayStatics.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTService_PlayerLocation::UBTService_PlayerLocation()
{
	NodeName = TEXT("Get Player Location");
}

void UBTService_PlayerLocation::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

	APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
	UBlackboardComponent* Blackboard = OwnerComp.GetBlackboardComponent();

	if (PlayerPawn && Blackboard)
	{
		Blackboard->SetValueAsVector(GetSelectedBlackboardKey(), PlayerPawn->GetActorLocation());
	}
}

调用此服务:


C++自定义任务节点1:

命名为:BTTask_ClearBlackboardValue

cpp 复制代码
//BTTask_ClearBlackboardValue.cpp
#include "BTTask_ClearBlackboardValue.h"

#include "BehaviorTree/BlackboardComponent.h"

UBTTask_ClearBlackboardValue::UBTTask_ClearBlackboardValue()
{
	NodeName = TEXT("Clear Blackboard Value");
}

EBTNodeResult::Type UBTTask_ClearBlackboardValue::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	Super::ExecuteTask(OwnerComp, NodeMemory);

	UBlackboardComponent* Blackboard = OwnerComp.GetBlackboardComponent();
	if (Blackboard)
	{
		Blackboard->ClearValue(GetSelectedBlackboardKey());
	}

	return EBTNodeResult::Succeeded;
}
cpp 复制代码
//BTTask_ClearBlackboardValue.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/Tasks/BTTask_BlackboardBase.h"
#include "BTTask_ClearBlackboardValue.generated.h"
UCLASS()
class SHOOTERSAM_API UBTTask_ClearBlackboardValue : public UBTTask_BlackboardBase
{
	GENERATED_BODY()
	
public:
	UBTTask_ClearBlackboardValue();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};

右击空白处, 添加新的任务节点

C++自定义任务节点2:

如果你的任务C++类不需要用到黑板里的内容, 那么就用BTTaskNode类, 不然就用BTTask_BlackboardBase类

命名为:BTTaskNode_Shoot

cpp 复制代码
//.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTaskNode_Shoot.generated.h"

UCLASS()
class SHOOTERSAM_API UBTTaskNode_Shoot : public UBTTaskNode
{
	GENERATED_BODY()
	
public:
	UBTTaskNode_Shoot();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
cpp 复制代码
//BTTaskNode_Shoot.cpp
#include "BTTaskNode_Shoot.h"
#include "ShooterAI.h"

UBTTaskNode_Shoot::UBTTaskNode_Shoot()
{
	NodeName = TEXT("Shoot At Player");
}

EBTNodeResult::Type UBTTaskNode_Shoot::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	Super::ExecuteTask(OwnerComp, NodeMemory);

	EBTNodeResult::Type Result = EBTNodeResult::Failed;

	AShooterAI* OwnerController = Cast<AShooterAI>(OwnerComp.GetAIOwner());
	if (OwnerController)
	{
		AShooterSamCharacter* OwnerCharacter = OwnerController->MyCharacter;
		AShooterSamCharacter* PlayerCharacter = OwnerController->PlayerCharacter;

		if (OwnerCharacter && PlayerCharacter && PlayerCharacter->IsAlive)
		{
			OwnerCharacter->Shoot();
			Result = EBTNodeResult::Succeeded;
		}
	}

	return Result;
}

玩家在阵亡后断开控制器

只需在玩家阵亡后调用:DetachFromControllerPendingDestroy();函数即可

cpp 复制代码
//ShooterSamCharacter.cpp
void AShooterSamCharacter::OnDamageTaken(AActor* DamagedActor, float Damage, const UDamageType* DamageType, AController* InstigatedBy, AActor* DamageCauser)
{
	if (IsAlive)
	{
		Health -= Damage;
		if (Health <= 0.0f)
		{
			// 标记角色为死亡状态:防止后续重复处理死亡逻辑
			IsAlive = false;
			// 安全处理:将血量强制置0(避免出现负数血量,导致逻辑异常)
			Health = 0.0f;
			GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
			//玩家在阵亡后断开控制器
			DetachFromControllerPendingDestroy();
		}
	}
}

章节五:游戏战斗UI

玩家生命值状态条:

用C++控制进度条和生命值同步:

cpp 复制代码
//HUDWidget.h
#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Components/ProgressBar.h"
#include "HUDWidget.generated.h"

UCLASS()
class SHOOTERSAM_API UHUDWidget : public UUserWidget
{
	GENERATED_BODY()
public:
	UPROPERTY(EditAnywhere, meta = (BindWidgetOptional))
	UProgressBar* HealthBar;
	void SetHealthBarPercent(float NewPercent);
};
cpp 复制代码
//HUDWidget.cpp
#include "HUDWidget.h"
//进度条更新函数
void UHUDWidget::SetHealthBarPercent(float NewPercent)
{
	if (NewPercent >= 0.0f && NewPercent <= 1.0f)
	{
		HealthBar->SetPercent(NewPercent);
	}
}
cpp 复制代码
//ShooterSamCharacter.h
//声明血量更新函数
void UpdateHUD();
cpp 复制代码
//ShooterSamCharacter.cpp
#include "ShooterSamPlayerController.h"


void AShooterSamCharacter::BeginPlay()
{
	Super::BeginPlay();
	Health = MaxHealth;
    //初始化血量
	UpdateHUD();
    /*
    ............
    */
}
void AShooterSamCharacter::OnDamageTaken(AActor* DamagedActor, float Damage, const UDamageType* DamageType, AController* InstigatedBy, AActor* DamageCauser)
{
	if (IsAlive)
	{
		Health -= Damage;
        //受伤更新血量
		UpdateHUD();
        /*
        .....
        */
    }
}
//血量更新函数
void AShooterSamCharacter::UpdateHUD()
{
	AShooterSamPlayerController* PlayerController = Cast<AShooterSamPlayerController>(GetController());
	if (PlayerController)
	{
		float NewPercent = Health / MaxHealth;
		if (NewPercent < 0.0f)
		{
			NewPercent = 0.0f;
		}

		PlayerController->HUDWidget->SetHealthBarPercent(NewPercent);
	}
}

更加复杂的UI我后续会添加

章节六. 战斗音效

创建Sound Cue, 命名为:SC_RifleShot


一次选择多个音频文件然后右击操作面板空白处, 直接生成N->1随机节点

通过 调制器 调节音高和音量, 同一个音频可以有不同的效果

再建一个击打音效. 命名为SC_RifleShotImpact

在Gun的C++类中播放:

cpp 复制代码
//Gun.h
public:
	UPROPERTY(EditAnywhere)
	USoundBase* ShootSound;

	UPROPERTY(EditAnywhere)
	USoundBase* ImpactSound;
cpp 复制代码
//Gun.cpp
void AGun::PullTrigger()
{
	MuzzleFlashParticleSystem->Activate(true);
    //播放开火声音
	UGameplayStatics::PlaySoundAtLocation(GetWorld(), ShootSound, GetActorLocation());

	if (OwnerController)
	{
        /*....*/
		if (IsHit)
		{
			UNiagaraFunctionLibrary::SpawnSystemAtLocation(GetWorld(), ImpactParticleSystem, HitResult.ImpactPoint, HitResult.ImpactPoint.Rotation());
            //播放击中声音
			UGameplayStatics::PlaySoundAtLocation(GetWorld(), ImpactSound, HitResult.ImpactPoint);

			AActor* HitActor = HitResult.GetActor();
			if (HitActor)
            /*....*/
        }
    }
}

去BP_Rifle中挂载声音

添加环境音效:

相关推荐
极客柒2 小时前
Unity 大地图 高性能路径引导Shader
unity·游戏引擎
2501_948122632 小时前
React Native for OpenHarmony 实战:Steam 资讯 App 浏览历史页面
javascript·react native·react.js·游戏·ecmascript·harmonyos
孟无岐2 小时前
【Laya】InputManager 输入管理器
typescript·游戏引擎·游戏程序·laya
dear_bi_MyOnly3 小时前
用 Vibe Coding 打造 React 飞机大战游戏 —— 我的实践与学习心得
前端·react.js·游戏
喵星人工作室18 小时前
C++传说:神明之剑0.4.5装备机制彻底完成
开发语言·c++·游戏
归真仙人21 小时前
【UE】UMG安卓相关问题
android·ue5·游戏引擎·ue4·虚幻·unreal engine
上海云盾安全满满1 天前
游戏盾如何应对大规模DDoS攻击
游戏·ddos
BuHuaX1 天前
Unity项目怎么接入抖音小游戏?
unity·c#·游戏引擎·wasm·游戏策划
开开心心_Every1 天前
强制打字练习工具:打够百字才可退出
java·游戏·微信·eclipse·pdf·excel·语音识别