4 - 蓝图函数库,Actor组件和接口

本文将初步介绍UE5中有关蓝图函数库,Actor组件和用户接口等概念和应用,让项目变得更加松耦合。

松耦合思想

把许多逻辑写到一个Actor中后不容易维护,且代码会出现冗余。这时候需要“松耦合”的思想帮忙,将一些通用的逻辑封装到一起,容易管理和使用。

在UE中,能帮助我们实现“松耦合”的工具有三个:

  • 蓝图函数库类 BlueprintFunctionLibrary:将项目中一些通用的函数从Actor中移出来,以静态函数的形式封装到此类中。
  • Actor组件 ActorComponent:将一些Actor共用的逻辑封装成一个Actor组件中,并将其附加到原Actor中。
  • 接口 Unreal Interface:和Actor组件的思想相同,能使我们的项目结构更合理,组织更有序。

蓝图函数库

在UE5中,有一个BlueprintFunctionLibrary类,该类包含了一组不属于任何特定Actor的静态函数集合,可以在项目的多个部分中使用。例如之前使用的Kismet库,其中的GameplayStatices对象、KismetMathLibraryKismetSystemLibrary都是蓝图函数库。

接下来我们将新建一个蓝图函数库,实现CanSeeActor()函数,该函数根据给定Actor的位置和目标Actor,判断给定Actor能否看见目标Actor(有障碍物就看不见)。

首先,新建一个基于BlueprintFunctionLibrary类的DodgeballFunctionLibrary类,并声明CanSeeActor()

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "DodgeballFunctionLibrary.generated.h"

/**
 * 给Dodgeball项目使用的工具函数集合
 */
UCLASS()
class DODGEBALL_API UDodgeballFunctionLibrary : public UBlueprintFunctionLibrary
{
	GENERATED_BODY()

public:
	// 在所给位置中能否看见所给Actor
	static bool CanSeeActor(const UWorld* World, FVector Location, const AActor* TargetActor, TArray<const AActor*> IgnoreActors = TArray<const AActor*>());
};

然后在源文件实现就行了:

#include "DodgeballFunctionLibrary.h"
#include "Engine/World.h"
#include "CollisionQueryParams.h"
#include "DrawDebugHelpers.h"

bool UDodgeballFunctionLibrary::CanSeeActor(const UWorld* World, FVector Location, const AActor* TargetActor, TArray<const AActor*> IgnoreActors)
{
	if (TargetActor == nullptr)
	{
		return false;
	}

	// 射线检测
	FHitResult HitResult;
	FVector Start = Location;
	FVector End = TargetActor->GetActorLocation();
	ECollisionChannel Channel = ECC_GameTraceChannel1;
	FCollisionQueryParams QueryParams;
	QueryParams.AddIgnoredActors(IgnoreActors);
	World->LineTraceSingleByChannel(HitResult, Start, End, Channel, QueryParams);

	// Debug: 可视化射线检测
	DrawDebugLine(World, Start, End, FColor::Red);
	return !HitResult.bBlockingHit;
}

Actor组件

Actor是在UE5中创建逻辑的主要方式,一个Actor可以包含几个Actor组件。Actor组件是可以添加到Actor中的对象,并且可以负责多种功能,例如负责库存系统、或者提供飞行能力。Actor组件必须始终属于并存储于Actor中,这个Actor被称为这些组件的所有者(Owner)。

常用的Actor组件有如下种类:

  • 纯代码Actor组件:在Actor内部作为自己的类,有自己的属性和方法,既可以和Owner交互,也可以被其他Actor交互。
  • 网格体组件:用于渲染多种类型的网格体对象(如静态网格体,骨骼网格体等)。
  • 碰撞组件:用于接收和生成碰撞事件。
  • 摄像机组件

在项目中添加逻辑时,如果可以将其封装在单独的组件中,从而实现松耦合,那就应该这样做。接下来我们创建一个负责处理Actor生命值的HealthComponent

创建Actor组件

首先,创建一个基于ActorComponent类的C++类HealthComponent

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "HealthComponent.generated.h"


UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class DODGEBALL_API UHealthComponent : public UActorComponent
{
	GENERATED_BODY()

protected:
	UPROPERTY(EditDefaultsOnly, Category = Health)
	float Health = 100.0f;

public:
	// Sets default values for this component's properties
	UHealthComponent();

	// Called every frame
	virtual void TickComponent(float DeltaTime, ELevelTick TickType,
	                           FActorComponentTickFunction* ThisTickFunction) override;

	void LoseHealth(float Amount);

protected:
	// Called when the game starts
	virtual void BeginPlay() override;
};

接下来实现LoseHealth()逻辑:

void UHealthComponent::LoseHealth(float Amount)
{
	Health -= Amount;

	// 生命值低于0就执行OnDeath()
	if (Health <= 0.f)
	{
		Health = 0.f;
        // todo
	}
}

集成Actor组件

然后就能将其集成到想要拥有生命值的角色了,例如玩家类:

// .h
	UHealthComponent* HealthComponent;

// 构造函数
{
    ...
    // 初始化Health组件
	HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("Health Component"));
}

访问Actor组件

接下来在躲避球类中通过玩家Actor来访问该Actor组件,用于扣除接触到的玩家血量:

void ADodgeballProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp,
                                 FVector ImpulseNormal, const FHitResult& HitResult)
{
	// 击中玩家后的逻辑:
	// 1. 获取它的HealthComponent, 进行扣血
	// 2. 销毁自身
	ADodgeballCharacter* Player = Cast<ADodgeballCharacter>(OtherActor);
	if (Player != nullptr)
	{
		UHealthComponent* HealthComponent = Player->FindComponentByClass<UHealthComponent>();
		if (HealthComponent != nullptr)
		{
			HealthComponent->LoseHealth(Damage);
		}
		Destroy();
	}
}

其中,FindComponentByClass<>()将返回该Actor类中特定Actor组件的指针,如果没有这个Actor组件,会返回nullptr。此外,GetComponents()将返回该Actor中所有Actor组件的列表。

接口

上面的LoseHealth()还没写完,由于每个Owner死亡时的处理可能不同,我们需要让它调用Owner自己的OnDeath()回调,可以通过接口(Interface)实现。接口是包含函数集合的类,如果对象实现该接口,则该函数功能集合必须被对象实现。

蓝图本地事件

通过对UFUNCTION()宏添加BlueprintNativeEvent标记,函数可以转换为蓝图本地事件。蓝图本地事件就是,在C++中实现默认函数体,然后在蓝图中可选重载。

蓝图本地事件的声明如下:

UFUNCTION(BlueprintNativeEvent)
void MyEvent();
virtual void MyEvent_Implementation();

其中,第一个函数是蓝图签名,它允许我们在蓝图中重写事件,而第二个函数是C++签名,它允许我们在C++中重写事件,第二行不能随意改名,格式必须是第一行函数名_Implementation()

此外,除了BlueprintNativeEvent标记外,还有如下两个标记:

  • BlueprintCallable:C++ 中实现函数,蓝图调用;
  • BlueprintImplementableEvent:蓝图中实现函数,C++ 声明和调用;

声明接口

接下来就能声明OnDeath()接口了,首先创建一个基于Unreal Interface类的HealthInterface类,然后声明接口:

#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"

#include "HealthInterface.generated.h"

// This class does not need to be modified.
UINTERFACE(MinimalAPI)
class UHealthInterface : public UInterface
{
	GENERATED_BODY()
};

/**
 * HealthComponent相关接口
 */
class DODGEBALL_API IHealthInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	// 人物生命值 <= 0后调用的回调
	UFUNCTION(BlueprintNativeEvent, Category = Health)
	void OnDeath();
	virtual void OnDeath_Implementation() = 0;
};

然后在玩家类上实现接口:

// .h
class ADodgeballCharacter : public ACharacter, public IHealthInterface
{
    //...
    virtual void OnDeath_Implementation() override;
}
// .cpp
void ADodgeballCharacter::OnDeath_Implementation()
{
    // 死亡就退出游戏
	UKismetSystemLibrary::QuitGame(this, nullptr, EQuitPreference::Quit, true);
}

最后在HealthComponent中调用所属Actor的OnDeath()接口:

void UHealthComponent::LoseHealth(float Amount)
{
	Health -= Amount;

	// 生命值低于0就执行OnDeath()
	if (Health <= 0.f)
	{
		Health = 0.f;
		// 看看该组件所属对象是否实现了OnDeath()
		if (GetOwner()->Implements<UHealthInterface>())
		{
			IHealthInterface::Execute_OnDeath(GetOwner());
		}
	}
}

其中,先调用Implements<UHealthInterface>()查看所属Actor是否实现了此接口,然后就能让接口执行函数了,它的格式是接口::Execute_函数名(GetOwner(), 参数列表)

参考资料

  • 中文翻译:《UE5 C++ 游戏开发完全学习教程》

  • 英文原版:《Elevating Game Experiences with UE5》