SPARKCREATIVE Tech Blog

https://www.spark-creative.jp/

【UE5】StateTreeをC++で実装しよう!

こんにちは!
エンジニアの竹野です!
今回はUE5から出たStateTreeという新しいプラグインC++にて使用していこうという感じです。
実装内容としてはキャラ制御のランダム徘徊発見追尾をテストで行います!

作業環境

windows 10
visual studio 2022
・UnrealEngine 5.2.1

プロジェクト設定

・名前     StateTreeTest
・テンプレート サードパーソン
・BPかC++   C++


詳細

まず初めにStateTreeの軽い詳細なのですが、StateTreeとはステート(状態)を遷移させることができる新機能です。
Matrix DemoのMassAIに使用されていて、少しずつ注目が集まっている機能で、
主にキャラの制御レベル遷移などで使用されているみたいです。

今回主に設定していくのはStateTreeのStateEvaluatorsTasksTransitions
Stateは状態、EvaluatorsはStateTree内で使用するパラメータの登録、TasksはStateで実行する内容、Transitiosは次のStateへ遷移する際の条件という感じです。



前準備

まずStateTreeの前にその他の設定を行っていきます。
最初にStateTreeを設定するCharacterクラスです。

TestEnemy.h

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "TestEnemy.generated.h"

UCLASS()
class STATETREETEST_API ATestEnemy : public ACharacter
{
	GENERATED_BODY()

public:
	// Sets default values for this character's properties
	ATestEnemy();

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

	// Called every frame
	virtual void Tick(float DeltaTime) override;

	// Called to bind functionality to input
	virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

public:
        // 視界を設定できるコンポーネント
	UPROPERTY(VisibleAnywhere, Category = "AI")
		class UPawnSensingComponent* PawnSensingComp;
};

TestEnemy.cpp

#include "TestEnemy.h"
#include "Perception/PawnSensingComponent.h"
#include "Kismet/KismetSystemLibrary.h"

// Sets default values
ATestEnemy::ATestEnemy()
{
 	// Set this character to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;

	PawnSensingComp = CreateDefaultSubobject<UPawnSensingComponent>(TEXT("PawnSensingComp"));
	// 視野
	PawnSensingComp->SetPeripheralVisionAngle(30.f);
	// 見える範囲
	PawnSensingComp->SightRadius = 2000;
}

// Called when the game starts or when spawned
void ATestEnemy::BeginPlay()
{
	Super::BeginPlay();
	
}

// Called every frame
void ATestEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	ActorLocation = GetActorLocation();
}

// Called to bind functionality to input
void ATestEnemy::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

}

こちらではキャラクターにUPawnSensingComponentという視界を設定できるコンポーネントに、
視界範囲を設定しています。キャラの設定はこれでOKです。



次にNavMeshをレベルに配置してください!
追跡や徘徊時に必要になります。




StateTreeはプラグインなのでデフォルトではないです
なのでプラグイン追加でGamePlayStateTreeとStateTreeを追加してください。



キャラのBPを選択してコンポーネント追加にて、StateTreeComponentがあるので追加してください。


これで前準備は完了です。
次にStateTreeを作成していきます。


StateTree

コンテンツ内を右クリックでAI欄にStateTreeがでてるのでクリック、
スキーマの選択という画面が出るので、StateTreeComponentを選択してください!


次にキャラBPにStateTreeComponentを追加したと思うのですがそちらに作成したStateTreeを設定します。


C++コード処理

ここから処理を組んでいきます!
まず処理を組むに当たって必要な情報がありません…(自身の情報、プレイヤー情報などなど)

なのでまず必要な情報を追加していきます。
まずエディターのStateTreeの中にあるContext Actor ClassをStateTreeで操作するキャラクターBPを設定します。


そして他に欲しい情報を扱いたい場合にStateTreeEvaluatorsでパラメータをセットしていきます!
StateTreeEvaluatorBlueprintBaseを継承したクラスを作成します。

STE_Parameters.h

#include "CoreMinimal.h"
#include "Blueprint/StateTreeEvaluatorBlueprintBase.h"
#include "StateTreeEvaluatorBase.h"
#include "STE_Parameters.generated.h"

// プレイヤークラス
class AStateTreeTestCharacter;

UCLASS()
class STATETREETEST_API USTE_Parameters : public UStateTreeEvaluatorBlueprintBase
{
	GENERATED_BODY()

public:
	virtual void TreeStart(FStateTreeExecutionContext& Context)override;
	virtual void TreeStop(FStateTreeExecutionContext& Context)override;
	virtual void Tick(FStateTreeExecutionContext& Context, const float DeltaTime)override;

	// プレイヤークラス
	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = OutPut)
		AStateTreeTestCharacter* Player = nullptr;
	// AIController
	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = OutPut)
		AController* AIC = nullptr;
};

STE_Parameters.cpp

#include "STE_Parameters.h"
#include "StateTreeExecutionContext.h"
#include "Kismet/GameplayStatics.h"
// プレイヤークラス
#include "StateTreeTest/StateTreeTestCharacter.h"

void USTE_Parameters::TreeStart(FStateTreeExecutionContext& Context)
{
	Super::TreeStart(Context);

	// 親取得(操作されるPawn)
	APawn* Owner = Cast<APawn>(Context.GetOwner());
	if (IsValid(Owner))
	{
		// AIControllerを取得しEvaluatorに登録
		AIC = Owner->GetController();
	}
}

void USTE_Parameters::TreeStop(FStateTreeExecutionContext& Context)
{
	Super::TreeStop(Context);
}

void USTE_Parameters::Tick(FStateTreeExecutionContext& Context, const float DeltaTime)
{
	Super::Tick(Context, DeltaTime);

	TSubclassOf<AStateTreeTestCharacter>findClass;
	findClass = AStateTreeTestCharacter::StaticClass();
	TArray<AActor*>Actors;
	UGameplayStatics::GetAllActorsOfClass(GetWorld(), findClass, Actors);
	AStateTreeTestCharacter* player = nullptr;
	if (Actors.Num())
	{
		// プレイヤークラス情報を取得
		Player = Cast<AStateTreeTestCharacter>(Actors[0]);
	}
}

StateTree側に戻っていただいて、Evaluatorsに先ほど作成したクラスを選択して
徘徊、追尾、発見などに必要な、
プレイヤー情報AIControllerをEvaluatorに登録できました。
これによりStateTree内で登録した情報を自由に使うことができます。(BehaviorTreeを使用したキャラの行動制御でいうBlackBoardの役割)


これから状態の処理を書いていきます。
EditorのStateTreeからStateを追加してもらって、まず徘徊の処理(名前はRoamingにしました)を作成したいので、
StateTreeTaskBluePrintBaseを継承してもらったクラスを作成し、ランダム徘徊の処理を書きます。

STT_Roaming.h

#include "CoreMinimal.h"
#include "Blueprint/StateTreeTaskBlueprintBase.h"
#include "STT_Roaming.generated.h"

class UNavigationSystemV1;

/**
 * 
 */
UCLASS()
class STATETREETEST_API USTT_Roaming : public UStateTreeTaskBlueprintBase
{
	GENERATED_BODY()
	

public:
	virtual EStateTreeRunStatus Tick(FStateTreeExecutionContext& Context, const float DeltaTime)override;

	// タスク系
	virtual EStateTreeRunStatus EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition);
	virtual void ExitState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition)override;
	bool CheckRotation(APawn* owner, const float DeltaTime);

	UPROPERTY(EditAnywhere, Category = Parameter, meta = (ToolTip = "Test"))
		AController* AIC = nullptr;

	UPROPERTY(EditAnywhere, Category = Parameter, meta = (ToolTip = "Test"))
		float Radius = 0.f;

	UPROPERTY(EditAnywhere, Category = Parameter, meta = (ToolTip = "Test"))
		float TurnSpeed = 1.f;

	FVector RandomLocation = FVector();
	FRotator Rot = FRotator();
	UNavigationSystemV1* navV1 = nullptr;
};

STT_Roaming.cpp

// Fill out your copyright notice in the Description page of Project Settings.

#include "StateTreeTest/StateTree/Task/STT_Roaming.h"
#include "StateTreeExecutionContext.h"
#include "StateTreeTest/StateTreeTestCharacter.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"
#include "NavigationSystem.h"
#include "NavigationPath.h"
#include "AIController.h"
#include "Kismet/KismetMathLibrary.h"

EStateTreeRunStatus USTT_Roaming::EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition)
{
	Super::EnterState(Context, Transition);

	// タスクに入った瞬間にランダム位置と向きの情報を持っておく
	FNavLocation navLoc = FNavLocation();
	AActor* owner = Cast<AActor>(Context.GetOwner());
	UNavigationSystemBase* nav = owner->GetWorld()->GetNavigationSystem();

	navV1 = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
	navV1->GetRandomPointInNavigableRadius(owner->GetActorLocation(), Radius, navLoc);
	RandomLocation = navLoc.Location;

	Rot = UKismetMathLibrary::FindLookAtRotation(owner->GetActorLocation(), RandomLocation);

	return EStateTreeRunStatus::Running;
}

void USTT_Roaming::ExitState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition)
{
	Super::ExitState(Context, Transition);

	AIC->StopMovement();
	RandomLocation = FVector();
}

EStateTreeRunStatus USTT_Roaming::Tick(FStateTreeExecutionContext& Context, const float DeltaTime)
{
	if (bHasTick)
	{
		return ReceiveTick(DeltaTime);
	}

	APawn* owner = Cast<APawn>(Context.GetOwner());
	const float Dist = FVector::Distance(owner->GetActorLocation(), RandomLocation);

	// ターンが完了するまで徘徊させない
	if (!CheckRotation(owner, DeltaTime))
	{
		return EStateTreeRunStatus::Running;
	}

	// ランダム位置に近づいたらSucceededにする
	// ランダム位置と全く一緒にしたら移動量の誤差などでうまく機能しないので、
	// すこしあいまいな100くらいで設定している
	if (Dist < 100)
	{
		return EStateTreeRunStatus::Succeeded;
	}
	else
	{
		// ランダム位置へ向かわせる
		AAIController* AIC2 = Cast<AAIController>(AIC);
		UAIBlueprintHelperLibrary::SimpleMoveToLocation(AIC, RandomLocation);

		return EStateTreeRunStatus::Running;
	}
}

//  ターン処理
bool USTT_Roaming::CheckRotation(APawn* owner, const float DeltaTime)
{
	FRotator NewRot = FMath::RInterpTo(owner->GetActorRotation(), Rot, DeltaTime, TurnSpeed);
	NewRot = FRotator(owner->GetActorRotation().Pitch, NewRot.Yaw, owner->GetActorRotation().Roll);
	owner->SetActorRotation(NewRot);
	float YawGap = owner->GetActorRotation().Yaw < Rot.Yaw ? Rot.Yaw - owner->GetActorRotation().Yaw : owner->GetActorRotation().Yaw - Rot.Yaw;

	// 目標の向きとほとんど差が無くなったらtrue
	return YawGap < 1.f ? true : false;
}

NavMeshをレベルに配置し、GetRandomPointInNavigableRadius関数でランダムな位置を取得することが可能です。
ただその処理だけだとランダム位置にたどり着く、すぐ次のランダム方向へ向かうといった気持ちの悪い徘徊なので、
CheckRotationという関数で次のランダム位置にゆっくり向きを合わせるということをしています。


次にプレイヤーを発見するという処理を組んでいきます。
プレイヤーが視界に入る→別ステートへ移行、という形にしたいのでTransitionsに使用するものを作成します。
StateTreeConditionBlueprintBaseを継承したクラスを作り、こちらは条件を組み、
次のステートに移行してOKならtrue、まだステート遷移しなくていいならfalseを返すといった仕組みです。


STC_FindActor.h

#include "CoreMinimal.h"
#include "Blueprint/StateTreeConditionBlueprintBase.h"
#include "STC_FindActor.generated.h"

/**
 * 
 */
UCLASS()
class STATETREETEST_API USTC_FindActor : public UStateTreeConditionBlueprintBase
{
	GENERATED_BODY()

protected:
	virtual bool TestCondition(FStateTreeExecutionContext& Context) const override;
	

public:
	UPROPERTY(EditAnywhere, Category = Parameter)
		AActor* Owner = nullptr;

	UPROPERTY(EditAnywhere, Category = Parameter)
		AActor* TargetActor = nullptr;

};

STC_FindActor.cpp

#include "StateTreeTest/StateTree/Condition/STC_FindActor.h"
#include "Perception/PawnSensingComponent.h"

bool USTC_FindActor::TestCondition(FStateTreeExecutionContext& Context)const
{
	APawn* target = Cast<APawn>(TargetActor);
	UPawnSensingComponent* PSC = nullptr;

	for (auto comp : Owner->GetComponents())
	{
		if (comp->GetClass() == UPawnSensingComponent::StaticClass())
		{
			// キャラに追加したPawnSensingComponentを取得
			PSC = Cast<UPawnSensingComponent>(comp);
		}
	}

	if (IsValid(PSC))
	{
		// 発見
		if (PSC->CouldSeePawn(target))
		{
			return  true;
		}
	}

	return false;
}

あらかじめキャラに追加したPawnSensingComponentで視界を決めているので、
あとはCouldSeePawnにて目標のPawnをセットしてあげれば簡単に実装出来ます。


これでランダムに徘徊してからプレイヤーを発見する!というところまでできました。
なので一度EditorのRoamingステートを確認しましょう!

Tasksには作成したSTT_Roaming、TransitionsのConditionsにはSTC_FindActorを入れ、
それぞれ必要なパラメータはSTE_Parametersで保管しているパラメータをセットしてください。
(デフォルトではBindと表示されている箇所からパラメータを選択できます)

TransitionsのTriggerは、今回視界判定の処理なのでOnTickで毎フレームチェックします。
Transition Toには次に遷移するステートを選択します。(次は追跡させたいのでMoveを選択してます)



あとは追跡のタスクを実装するので、
StateTreeTaskBluePrintBaseを継承してもらったクラスを作成します。


STT_Movement.h

#include "CoreMinimal.h"
#include "StateTreeTaskBase.h"
#include "Blueprint/StateTreeTaskBlueprintBase.h"
#include "STT_Movement.generated.h"

class AStateTreeTestCharacter;

/**
 * 
 */
UCLASS()
class STATETREETEST_API USTT_Movement: public UStateTreeTaskBlueprintBase
{
	GENERATED_BODY()
	
public:
	virtual EStateTreeRunStatus Tick(FStateTreeExecutionContext& Context, const float DeltaTime)override;

	// タスク系
	virtual EStateTreeRunStatus EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition);
	virtual void ExitState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition)override;

	UPROPERTY(EditAnywhere, Category = Parameter, meta = (ToolTip = "Test"))
		AStateTreeTestCharacter* Player = nullptr;

	UPROPERTY(EditAnywhere, Category = Parameter, meta = (ToolTip = "Test"))
		AController* AIC = nullptr;
};


STT_Movement.cpp

#include "GameFramework/Actor.h"
#include "StateTreeExecutionContext.h"
#include "NavigationSystem.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"
#include "StateTreeTest/StateTreeTestCharacter.h"
#include "Kismet/GameplayStatics.h"

EStateTreeRunStatus USTT_Movement::Tick(FStateTreeExecutionContext& Context, const float DeltaTime)
{
	if (bHasTick)
	{
		return ReceiveTick(DeltaTime);
	}

	if (IsValid(Player) && IsValid(AIC))
	{

		// 追跡
		UAIBlueprintHelperLibrary::SimpleMoveToActor(AIC, Player);
	}

	return EStateTreeRunStatus::Running;
}

// タスク開始時の処理
EStateTreeRunStatus USTT_Movement::EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition)
{
	Super::EnterState(Context, Transition);

	return EStateTreeRunStatus::Running;
}

// タスク終了時の処理
void USTT_Movement::ExitState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition)
{
	Super::ExitState(Context, Transition);

	// タスクが終わっても追いかけ続けてしまうためタスク終了時に追跡を中止
	AIC->StopMovement();
}



そしてEditorのMoveというステートのTasksに作成したSTT_Movementを入れ、パラメータをセット、
追跡のステートが完成しました!



↓StateTreeの全体↓



最後に

これにて、徘徊、発見、追跡の完成です!
StateTreeこれから主流になる可能性も考えられるので、慣れておいて損はないと思います!
ではではこれにて~
ありがとうございました!