UE 投射物学习之路
实现发射粒子
1. 创建魔法粒子
在UE中从Actor派生出一个公共class,命名为SMagicProjectile
,表示这个类用于实现魔法投掷物攻击。创建完毕后在SMagicProjectile.h
文件中添加三个用于计算碰撞和显示特效的组件,并在SMagicProjectile.cpp
中创建实例:
//SMagicProjectile.h
#pragma once
#include "CoreMinimal.h"
#include "Components/SphereComponent.h"
#include "GameFramework/Actor.h"
#include "GameFramework/ProjectileMovementComponent.h"
#include "Particles/ParticleSystemComponent.h"
#include "SMagicProjectile.generated.h"
class USphereComponent;
class UProjectileMovementComponent;
class UParticleSystemComponent;
UCLASS()
class ACTIONROGUELIKE_API ASMagicProjectile : public AActor
{
GENERATED_BODY()
protected:
// 球体,用于计算碰撞
UPROPERTY(BlueprintReadOnly,VisibleAnywhere)
USphereComponent* SphereComp;
// 投射体,控制球体的运动
UPROPERTY(BlueprintReadOnly,VisibleAnywhere)
UProjectileMovementComponent* MovementComp;
// 粒子系统,控制特效
UPROPERTY(BlueprintReadOnly,VisibleAnywhere)
UParticleSystemComponent* EffectComp;
};
//SMagicProjectile.cpp
#include "SMagicProjectile.h"
#include "SAttributeComponent.h"
#include "Components/SphereComponent.h"
#include "GameFramework/ProjectileMovementComponent.h"
#include "Particles/ParticleSystemComponent.h"
// Sets default values
ASMagicProjectile::ASMagicProjectile()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
SphereComp = CreateDefaultSubobject<USphereComponent>("SphereComp");
SphereComp->SetCollisionProfileName("Projectile");
SphereComp->OnComponentBeginOverlap.AddDynamic(this,&ASMagicProjectile::OnActorOverlap);
RootComponent = SphereComp;
EffectComp = CreateDefaultSubobject<UParticleSystemComponent>("EffectComp");
EffectComp->SetupAttachment(SphereComp);
MovementComp = CreateDefaultSubobject<UProjectileMovementComponent>("MovementComp");
MovementComp->InitialSpeed = 1000.0f;
//这表明物体的旋转将会跟随其速度向量。也就是说,物体将会朝它移动的方向旋转,
//这对于模拟例如子弹或者火箭这样的物体是很常见的,这些物体的尾部通常指向它们移动的反方向。
MovementComp->bRotationFollowsVelocity = true;
//这表示物体的初始速度是相对于物体自身的局部空间来定义的,而不是世界空间。
//局部空间意味着速度向量是相对于物体自身的方向和坐标系统。如果这个属性设置为false,那么初始速度将会在世界空间中定义,与物体的朝向无关。
MovementComp->bInitialVelocityInLocalSpace = true;
}
代码编写完毕后,需要在UE中创建蓝图类
然后打开MagicProjectileBP,在粒子系统控件EffectComp的“粒子”属性中随意选择一种模板,我这里选择的是P_Gideon_Primary_Projectile。在选择模板后我们可以直接在界面中间的视口中看到粒子特效的样子。
2.控制粒子生成
我们需要通过鼠标点击或键盘按下,来控制生成上述魔法粒子。要实现这个功能,可以参考前文有关控制人物移动的操作,需要在SCharacter.cpp
的SetupPlayerInputComponent()
函数中绑定一个新动作。不同于控制移动的轴绑定,对于按下按钮这种动作需要使用操作绑定,即BindAction()
函数:
//SCharacter.cpp
PlayerInputComponent->BindAction("PrimaryAttack",IE_Pressed,this,&ASCharacter::PrimaryAttack);
为此,需要增加一个控制释放攻击的函数PrimaryAttack()。这个函数的核心是GetWorld()在当前世界下SpawnActor()来生成上面创建的魔法粒子。其中传入三个对象参数分别为:要生成的对象的class、所有Actor基本的Transform变换属性(控制Actor的位置和缩放)、生成的相关参数设置
//SCharacter.cpp
void ASCharacter::PrimaryAttack() {
// Spawn Transform Matrix, spawn的变换矩阵
// 朝向角色方向,在角色的中心位置生成
//Rotation(旋转)、Translation(平移)和Scale3D(缩放)
FTransform SpawnTM = FTransform(GetActorRotation(),GetActorLocation());
// 参数设置。
// 此处设置碰撞检测规则为:即使碰撞也总是生成,因为粒子在角色中间生成必然碰撞
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
// 所有能放置或生成的对象都是Actor
GetWorld()->SpawnActor<AActor>(ProjectileClass, SpawnTM, SpawnParams);
}
在上面的代码中,传入SpawnActor()的第一个参数是ProjectileClass,这是一个需要在SCharacter.h定义的AActor子类。同时,利用PROPERTY宏定义将其暴露在UE中,这样就可以在Player蓝图类的细节面板中,像选择参数那样直接选择MagicProjection实例进行调用。
//SCharacter.h
UCLASS()
class ACTIONROGUELIKE_API ASCharacter : public ACharacter
{
protected:
//TSubclassOf 是一个模板类,用于存储对某个特定类的引用
//在UE的蓝图系统中,TSubclassOf 也用于指定一个类或其子类作为蓝图节点的输入。
// 投射体子类
UPROPERTY(EditAnywhere,Category = "Attack")
TSubclassOf<AActor> ProjectileClass;
void PrimaryAttack();
};
打开UE,将PrimaryAttack绑定为鼠标左键;同时将以SCharacter创建的蓝图中的ProjectileClass选择为第一步创建的蓝图类MagicProjectile。
现在运行关卡,点击鼠标左键就可以从角色的中心发射出魔法粒子。
3.更改粒子发射位置
最佳的攻击演出效果,应该是从角色的手掌发出,所以首先需要找到骨骼体模型中手的位置。双击Player使用的网格体可以打开Gideon的模型,再点击右上角可以切换为骨骼。界面左侧会出现所有的骨骼和Socket,可以找到角色右手的位置为“Muzzle_01”。
要找到这个位置,UE提供了相应函数供开发者调用:
FVector HandLocation = GetMesh()->GetSocketLocation("Muzzle_01");
现在角色就可以从右手发射魔法粒子了,后续还会配合动画播放,将会实现角色伸手发射粒子攻击的演出效果。但目前发射的粒子不能产生物理碰撞,如果向墙壁发射,粒子会直接穿过墙壁。
4.实现物理碰撞
首先,将MagicProjectile的MovementComp组件的“发射重力范围”设置为0,这样发射的粒子可以不收重力下落而保持直线运动。然后在项目设置 -> 碰撞 -> Preset中新建名为“Projectile”的配置,用于魔法粒子碰撞检测。
然后在MagicProjectile中的“碰撞”属性中设置使用该配置,或者直接使用如下C++代码实现:
//SMagicProjectile.cpp
SphereComp->SetCollisionProfileName("Projectile");
现在角色发射的粒子就可以和墙壁发生碰撞了。(这里发生碰撞的前提是 世界中的那个物体遇到WorldDynamic类型时选择的是 Block)
优化攻击
1. 销毁粒子
在MagicProjectile的蓝图选择SphereComp,在细节面板中的“事件”中添加“组件命中时”事件,这个事件会在角色Gideon发出的魔法粒子命中(Hit)物体时触发。为了更加方便的调试,我们在其后添加“绘制调试球体(Draw Debug Sphere)”,其Center传入“获取Actor位置(Get Actor Location)”,即发生重叠事件时粒子所在的位置。同时,将Radius设置为10,Duration设置为1,Color换成任意明显的颜色。这样,在世界中发射粒子时就可以看见事件触发的位置。
这里需要注意的是,在之前的内容中,我们设置了SphereComp的“碰撞预设”为自定义的Projectile,在其中设置了对WorldStatic和PhysicsBody为阻挡。现在,为了对所有物体都可以正确进行碰撞,我们将Projectile中除了Visibility和Camera的选项全置为阻挡。同时,我们也需要设置墙面对WorldStatic为阻挡(或者直接设置碰撞预设为PhysicsActor)。运行关卡,可以发现在墙面阻挡魔法粒子的同时绘制了Debug球体。
接下来,需要实现魔法粒子击中物体后的销毁。
2. 使用Instigator
课程中进行到这一步时,出现了发射的魔法粒子直接在Gideon手中爆炸的情况,原因可能是魔法粒子一创建就HIt到了自身角色,从而触发后续的事件。虽然我自己复现时没有出现这个情况,但出于学习的目的,本示例也会同课程内容一样使用Instigator。
根据课程中的讲述,在UE中实现伤害判定时通常会引入Instigator,即发起者,在本节中就是角色Gideon
一种使用Instigator的常见场景是射击游戏,在玩家发射子弹击杀了某个角色后,需要更新玩家的奖励和数据统计时,就可以通过这个Instigator快速找到发射子弹的玩家对象。因此,在这个示例中我们也尝试使用Instigator。
回到SCharacter.cpp的PrimaryAttack_TimeElapsed函数中,在SpawnParams中直接添加
Instigator:SpawnParams.Instigator = this;
保存编译后回到UE,在蓝图中添加在进入Hit事件前判断是不是自己的逻辑。在,创建“获取Instigator(Get Instigator)”节点,并与Other Actor(命中对象)进行比较,在结果为True使用Branch分支执行后续的节点
黑洞技能
1. 代码结构更改
随着课程内容的深入,我们将会添加的技能会越来越多,单纯在SCharacter.cpp里不断添加新代码会使结构变得愈发臃肿。为了后续开发的可扩展性,需要重构技能部分的代码。
因为Gideon这个角色的技能都是从右手发出不同的粒子,那从右手发出这个功能的代码是可以复用的。因此把这部分代码单独封装,只需要传入需要生成的类即可。于是,在SCharacter中加入一个负责生成粒子的函数SpawnProjectile(记得在.h文件中声明):
//SCharacter.h
void SpawnProjectile(TSubclassOf<AActor> ClassToSpawn);
//SCharacter.cpp
void ASCharacter::SpawnProjectile(TSubclassOf<AActor> ClassToSpawn)
{
if(ensure(ClassToSpawn))
{
// 获取模型右手位置
FVector RightHandLoc = GetMesh()->GetSocketLocation("Muzzle_01");
// 检测距离为 5000 cm = 50 m
FVector TraceStart = CameraComp->GetComponentLocation();
FVector TraceEnd = TraceStart + ( GetControlRotation().Vector() * 5000 );
// 检测半径
FCollisionShape Shape;
Shape.SetSphere(20.0f);
// 不要检测自己角色
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);
// 碰撞设置
FCollisionObjectQueryParams ObjParams;
ObjParams.AddObjectTypesToQuery(ECC_WorldStatic);
ObjParams.AddObjectTypesToQuery(ECC_WorldDynamic);
ObjParams.AddObjectTypesToQuery(ECC_Pawn);
FHitResult Hit;
if (GetWorld()->SweepSingleByObjectType(Hit, TraceStart, TraceEnd, FQuat::Identity, ObjParams, Shape, Params)) {
TraceEnd = Hit.ImpactPoint;
}
// 尾向量 - 头向量 = 方向向量 eg:起点(0,0) 终点(1,1),方向向量为(1,1)
FRotator ProjRotation = FRotationMatrix::MakeFromX(TraceEnd - RightHandLoc).Rotator();
// 朝向检测到的落点方向,在角色的右手位置生成
FTransform SpawnTM = FTransform(ProjRotation, RightHandLoc);
// 此处设置碰撞检测规则为:即使碰撞也总是生成
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnParams.Instigator = this;
/*
GetWorld()->SpawnActor<AActor>(ProjectileClass, SpawnTM, SpawnParams);
*/
GetWorld()->SpawnActor<AActor>(ClassToSpawn,SpawnTM,SpawnParams);
}
}
//2024.10.11
// 生成角度和坐标
// 获取人物模型中手的坐标,GetSocketLocation可以获取骨骼中的插件
const FVector HandLocation = GetMesh()->GetSocketLocation("Muzzle_01");
// 未校正前的生成方位
// const FTransform SpawnTM = FTransform(GetControlRotation(), HandLocation);
FActorSpawnParameters SpawnParams;
// 无论任何情况都生成(无视重叠,碰撞,覆盖...)
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
// 将角色自身作为触发者传入,以便子弹判断正确的交互对象
SpawnParams.Instigator = this;
// 碰撞参数,忽略自身
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);
// 获取Camera组件
APlayerCameraManager* CurrentCamera = GetWorld()->GetFirstPlayerController()->PlayerCameraManager;
// 方向是摄像机视角的正前方(屏幕正中央),这里不要把手部模型的坐标传入,方向会偏
FVector TraceDirection = CurrentCamera->GetActorForwardVector();
// 起始位置是摄像机的位置
FVector TraceStart = CurrentCamera->GetCameraLocation();
// 终点是一段距离,后面的5000不固定
FVector TraceEnd = TraceStart + (TraceDirection * 5000);
FHitResult Hit;
// Line Trace检测与障碍物的撞击点
if (GetWorld()->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_GameTraceChannel1, Params))
{
// 将撞击点作为方向向量的终点位置
TraceEnd = Hit.ImpactPoint;
}
// 起始点是我们的子弹生成点(手),终点是目标点,获得Rotation
FRotator ProjRotation = (TraceEnd - HandLocation).Rotation();
// 最终获得校正后的生成方位
FTransform SpawnTM = FTransform(ProjRotation, HandLocation);
// 在世界中生成
GetWorld()->SpawnActor<AActor>(ClassToSpawn, SpawnTM, SpawnParams);
相应的,此前PrimaryAttack_TimeElapsed函数就保留一行代码:
void ASCharacter::PrimaryAttack_TimeElapsed() {
SpawnProjectile(ProjectileClass);
}
2. 实现黑洞粒子
首先,复制一份之前PrimaryAttack的代码,把名字改成BlackHoleAttack和BlackHoleAttack_TimeElapsed。在SetupPlayerInputComponent添加后,再在UE里绑定任意按键。运行测试,若角色能正常发射之前的魔法粒子则可继续,接下来就需要将生成的粒子改更为黑洞粒子。
参考我们第一次实现发射粒子的做法,在SCharacter.h中新建一个子类并暴露在蓝图中,然后在BlackHoleAttack_TimeElapsed传入我们创建的子类名字:
// SCharacter.h
UPROPERTY(EditAnywhere, Category = "Attack")
TSubclassOf<AActor> BlackHoleProjectileClass;
// SCharacter.cpp
void ASurCharacter::BlackHoleAttack_TimeElapsed() {
SpawnProjectile(BlackHoleProjectileClass);
}
随后就打开UE,在Player的蓝图编辑器中找到Attack属性下的BlackHoleProjectileClass,暂时把它设置为已有的普攻魔法粒子MagicProjectile,编译保存并运行测试。
课程中考虑到接下来还要实现几个不同的粒子,所以先创建一个基类来复用一些共有的代码内容。在UE中从Actor派生一个SProjectileBase类,使用ABSTRACT宏将该类声明为"抽象基类",使其不显示在UE的下拉窗中,防止我们在关卡中添加此类的Actor
// ASProjectileBase.cpp
ASProjectileBase::ASurProjectileBase()
{
SphereComp = CreateDefaultSubobject<USphereComponent>("SphereComp");
SphereComp->SetCollisionProfileName("Projectile");
RootComponent = SphereComp;
MoveComp = CreateDefaultSubobject<UProjectileMovementComponent>("ProjectileMoveComp");
MoveComp->bRotationFollowsVelocity = true;
MoveComp->bInitialVelocityInLocalSpace = true;
MoveComp->ProjectileGravityScale = 0.0f;
MoveComp->InitialSpeed = 500;
EffectComp = CreateDefaultSubobject<UParticleSystemComponent>("EffectComp");
EffectComp->SetupAttachment(RootComponent);
}
接下来换到UE中,由于黑洞的功能较为简单,所以仅使用UE就很容易实现。在UE中创建黑洞的蓝图类Proj_BlackHole,继承自SProjectileBase。
打开蓝图编辑器,再添加Physics中的径向力组件RadialForce,这个组件可以实现在一个范围内产生力。在属性“径向力组件”中设置“要影响的对象”,点击右边小三角形将Pawn删除,这样就不会把我们自己吸进去。然后,设置其半径为500,设置“力”属性为-2,000,000。
另外,我们不希望这个黑洞在移动时会被物体阻挡,所以选择SphereComp,切换其碰撞预设为Custom,并设置如下:
由于这是一个有时间限制的技能,因此我们选择Proj_Base(自身),设置Actor属性的“初始生命周期”为5秒,即要添加动画的时长。
最后,选择EffectComp,为这个粒子选择一个模板。
此外,课程中还在Proj_BlackHole的蓝图中添加了销毁碰到的、启用了“模拟物理”的物体(没有启用“模拟物理”,一般是不与技能交互的静态物体),大家可以尝试添加查看效果:
*
位移技能
实现角色的粒子位移技能,即发射出一个特殊的粒子,经过一定时间后,该粒子会爆炸并将角色传送到爆炸位置。
1. 创建粒子类
考虑到后面制作的粒子都需要实现爆炸效果,所以在SProjectileBase中添加实现爆炸的函数和OnActorHit事件函数,爆炸函数仅包括最基本的爆炸后销毁自身的功能,方便子类可以根据自己的需求来进行重写。
// SProjectileBase.h
UPROPERTY(EditDefaultsOnly, Category = "Effects")
UParticleSystem* ImpactVFX;//VFX指Visual effects, 即视觉特效,负责爆炸时的特效
UFUNCTION()
virtual void OnActorHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);
UFUNCTION(BlueprintCallable, BlueprintNativeEvent)
void Explode(); // 爆炸函数
// SProjectileBase.cpp
void ASProjectileBase::OnActorHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
Explode();
}
// BlueprintNativeEvent需要加上后缀_Implementation
void ASProjectileBase::Explode_Implementation()
{
// IsPendingKill确保对象有效,防止重复destroy
if (ensure(IsPendingKill()))
{
// 触发爆炸时的特效,特效样式在UE中设置ImpactVFX变量
UGameplayStatics::SpawnEmitterAtLocation(this, ImpactVFX, GetActorLocation(), GetActorRotation());
Destroy();
}
}
由于这个技能需要实现额外的传送角色代码,因此我们要在UE中创建一个新的粒子类SDashProjectile,在创建时选择“显示所有类”,并通过搜索选择,从上一节创建的基类SProjectileBase派生出来。
课程中在DashProjectile中设置两个时间,经过DetonateDelay后生成爆炸特效,然后再经过TeleportDelay后传送角色。将控制爆炸的TimerHandle_DelayedDetonate声明为类的保护成员,当粒子提前遇到障碍物爆炸调用Explode函数时,我们可以手动清除这个Timer,从而防止触发两次。相对的,控制传送延迟的Timer不涉及这个问题,因此定义为局部变量即可:
//SDashProjectile.h
#pragma once
#include "CoreMinimal.h"
#include "SProjectileBase.h"
#include "SDashProjectile.generated.h"
/**
*
*/
UCLASS()
class ACTIONROGUELIKE_API ASDashProjectile : public ASProjectileBase
{
GENERATED_BODY()
public:
ASDashProjectile();
protected:
UPROPERTY(EditDefaultsOnly, Category = "Teleport")
float TeleportDelay; // 传送延迟
UPROPERTY(EditDefaultsOnly, Category = "Teleport")
float DetonateDelay; // 爆炸延迟
// hit后清除计时器
FTimerHandle TimerHandle_DelayedDetonate;
virtual void Explode_Implementation() override;
void TeleportInstigator();
virtual void BeginPlay() override;
};
2. 实现传送
实现角色的传送,可以直接使用UE的TeleportTo函数接口。这部分需要注意的是,一进入Explode就要清除控制爆炸的Timer,防止提前触发Hit事件爆炸后、原有的计时器结束再次触发。
//SDashProjectile.cpp
#include "SDashProjectile.h"
#include "Kismet/GameplayStatics.h"
#include "Particles/ParticleSystemComponent.h"
#include "GameFramework/ProjectileMovementComponent.h"
ASDashProjectile::ASDashProjectile()
{
TeleportDelay = 0.2f;
DetonateDelay = 0.2f;
MovementComp->InitialSpeed = 6000.0f;
}
void ASDashProjectile::BeginPlay()
{
Super::BeginPlay();
// 到爆炸时间后,触发Explode()
GetWorldTimerManager().SetTimer(TimerHandle_DelayedDetonate, this, &ASDashProjectile::Explode, DetonateDelay);
}
void ASDashProjectile::Explode_Implementation()
{
// 可能有碰到障碍物提前爆炸的情况,因此要清除Timer防止被调用两次
GetWorldTimerManager().ClearTimer(TimerHandle_DelayedDetonate);
// 爆炸特效
UGameplayStatics::SpawnEmitterAtLocation(this, ImpactVFX, GetActorLocation(), GetActorRotation());
// 粒子爆炸后关闭系统、停止移动、停止碰撞
EffectComp->DeactivateSystem();
MovementComp->StopMovementImmediately();
SetActorEnableCollision(false);
// 到传送时间后,触发传送
FTimerHandle TimerHandle_DelayedTeleport;
GetWorldTimerManager().SetTimer(TimerHandle_DelayedTeleport, this, &ASDashProjectile::TeleportInstigator, TeleportDelay);
// 不调用base中的Explode中的destroy自身,因为传送时间计时器需要一点时间
//Super::Explode_Implementation();
}
void ASDashProjectile::TeleportInstigator()
{
AActor* ActorToTeleport = GetInstigator();
if (ensure(ActorToTeleport))
{
// 传送时保持角色原始Rotation
ActorToTeleport->TeleportTo(GetActorLocation(), ActorToTeleport->GetActorRotation(), false, false);
}
// 之前没有调用,现在销毁
Destroy();
}
接下来就是常规的绑定按键操作,在UE中创建蓝图类,然后设置发射的粒子为该蓝图类,这已经在之前的内容出现过很多次,故不再赘述。
3. 动画设置
最后,在创建的蓝图类中设置粒子的动画。一共需要设置两个动画:一是发射出去粒子的样子,对应ProjectileBase中的Effect Comp;二是爆炸时的动画,对应刚刚声明的ImpactVFX。
在C++中我们将两个延迟使用UPROPERTY暴露到了UE中,所以此时可以很方便地调节数据。我将爆炸延迟设置为1,传送延迟设置为0.2,即正常情况下粒子飞行1s后再经过0.2s,角色会传送到粒子最终位置。但如果粒子遇到障碍物,则代码中清除Timer的语句就会发挥作用,在0.2s后直接传送角色