1. サイトトップ
  2. ブログ
  3. Unreal Engine
  4. UE5
  5. 【UE5】UnrealC++でプレイヤーとAIの移動処理を作成してみた

【UE5】UnrealC++でプレイヤーとAIの移動処理を作成してみた

はじめに

こんにちは 情熱開発部・プログラム課の辻野です!

今年は3年ぶりに東京ゲームショウがオフラインで開催されましたね。
久しぶりに参加した方も多いのではないでしょうか?

公開されたゲームの中にはUnreal Engine 5(以下、UE5)で制作されたものもあったりと、UE5の普及を機にUnreal Engineを始めてみようと思っている方もいると思います。

本記事では取っ掛かりとして学ぶことが多い、プレイヤーとAIの移動処理の作成方法を解説していこうと思います。

※使用しているバージョンはUE5.0.3です。

概要

今回はブループリントではなくUnreal C++を主軸にプレイヤーとAIの移動処理を実装します。

  1. プレイヤーの作成(移動と入力処理の実装)
  2. AIの作成
  3. カスタマイズされたパス移動の作成

上記のように進め、最終的に入力によって移動するプレイヤー、それに障害物を避けながら追従するAIを作成します。

以下は実装のサンプルです。

プレイヤーの作成

入力された方向に移動するプレイヤーを作成します。

移動処理の実装

まずはポーンの移動を制御するコンポーネントです。

今回は入力した方向に移動するシンプルなものなので、
FloatingPawnMovementを継承して作成します。

FloatingPawnMovementはThirdPersonテンプレート等に使われているCharacterMovementと違い、ジャンプや泳ぎ等が存在しない入力方向に進むだけのコンポーネントです。

ついでに進行方向をわかりやすくするためにその方向に回転するようにします。

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/FloatingPawnMovement.h"
#include "MyFloatingPawnMovement.generated.h"

// 入力方向に移動し、徐々にその方向に振り向かせる
UCLASS(meta = (BlueprintSpawnableComponent))
class INTERFACETEST_API UMyFloatingPawnMovement : public UFloatingPawnMovement
{
	GENERATED_BODY()

public:
	virtual void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

protected:
	virtual void BeginPlay() override;

	// 振り向き速度
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float rotationSpeed = 5.0f;

	// 衝突による回転や移動を防ぐために初期値を保存しておく
	FRotator startRotation;
	FVector startPosition;
};
#include "MyFloatingPawnMovement.h"

void UMyFloatingPawnMovement::BeginPlay()
{
	// 衝突時の移動と回転を制限するため開始時の状態を保存
	startPosition = PawnOwner->GetActorLocation();
	startRotation = PawnOwner->GetActorRotation();
}

void UMyFloatingPawnMovement::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	// FloatingMovementのTick ポーンの移動処理
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	// ポーンを入力方向に時間をかけて振り向かせる
	auto movement = PawnOwner->GetLastMovementInputVector();

	// 入力がある時だけ回転
	if (!movement.IsZero())
	{
		auto currentRot = PawnOwner->GetActorRotation();
		auto targetRot = movement.Rotation();
		PawnOwner->SetActorRotation(FMath::RInterpTo(currentRot, targetRot, DeltaTime, rotationSpeed));
	}

	// 衝突時のZ軸移動とY軸回転の制限
	auto currentPosition = PawnOwner->GetActorLocation();
	if (currentPosition.Z != startPosition.Z)
	{
		PawnOwner->SetActorLocation(FVector(currentPosition.X, currentPosition.Y, startPosition.Z));
	}

	auto currentRotation = PawnOwner->GetActorRotation();
	if (currentRotation.Pitch != startRotation.Pitch)
	{
		PawnOwner->SetActorRotation(FRotator(startRotation.Pitch, currentRotation.Yaw, currentRotation.Roll));
	}
}

Tickに移動・回転・衝突時のコンストレイントを実装しています。
RInterpToはDeltaTimeを考慮して回転の補間を行ってくれるのでお勧めです。

プレイヤーの移動処理として作成しましたが、今回はAIで動かすポーンにも同じものをアタッチします。

PlayerControllerの作成

次はキー入力によってポーンを操作するクラスです。

入力イベントはポーンからでも登録できますが、PlayerControllerとして分けておくと後述するAIControllerへの切り替えが簡単になります。

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "MyPawn.h"
#include "MyPlayerController.generated.h"


UCLASS()
class INTERFACETEST_API AMyPlayerController : public APlayerController
{
	GENERATED_BODY()

protected:
	virtual void BeginPlay() override;

	// 入力イベントに移動関数を登録
	virtual void SetupInputComponent() override;

	// 入力によってポーンを移動
	virtual void MoveForward(float value);
	virtual void MoveRight(float value);
};
#include "MyPlayerController.h"

void AMyPlayerController::BeginPlay() 
{
	Super::BeginPlay();
}

void AMyPlayerController::SetupInputComponent()
{
	Super::SetupInputComponent();

	InputComponent->BindAxis(
		"MoveForward",
		this,
		&AMyPlayerController::MoveForward);

	InputComponent->BindAxis(
		"MoveRight",
		this,
		&AMyPlayerController::MoveRight);
}

void AMyPlayerController::MoveForward(float value)
{
	GetPawn()->AddMovementInput(FVector::ForwardVector * value);
}

void AMyPlayerController::MoveRight(float value)
{
	GetPawn()->AddMovementInput(FVector::RightVector * value);
}

AddMovementInput()は入力を保存するだけで実際の移動はMovementComponentのTickで行われています。

これでプレイヤーに必要な機能は作成できました。

AIの作成

プレイヤーに障害物をよけながら追従するAIを作成します。

ロジックの作成

UnrealEngineでAIを作成するには、いくつかの手法がありますが、
BehaviorTree
・Blackboard
AIController
今回はこれらを用いて実装します。

BehaviorTree

AIのロジックをツリー構造的に実行する機能です。
詳しくは下記の記事を見ていただけるとわかりやすいと思います。
【UE4】UE4でNPCのAIを作ってみた!

こちらはC++で実装するメリットはあまりないのでブループリントで実装しました。

プレイヤーを追いかけ、十分に近づくと1秒間動作を停止するAIになっています。

Blackboard

BehaviorTreeで扱うパラメータを保持しておくクラスです。

今回は追跡するアクターをTargetActorとして保持するだけの簡素なものになりました。

AIController

BehaviorTreeとBlackboardの紐づけや、値の指定を行うクラスです。
AIのマネージャー的な役割を担います。

#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "MyAIController.generated.h"

UCLASS()
class INTERFACETEST_API AMyAIController : public AAIController
{
	GENERATED_BODY()

protected:
	virtual void BeginPlay() override;
};
#include "MyAIController.h"
#include <BehaviorTree/BlackboardComponent.h>
#include <Kismet/GameplayStatics.h>

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

	// BlackboardにプレイヤーをTaragetActorとして登録
	auto pBlackBoard = GetBlackboardComponent();
	if (pBlackBoard)
	{
		pBlackBoard->SetValueAsObject(TEXT("TargetActor"), UGameplayStatics::GetPlayerPawn(this, 0));
	}
}

BeginPlayで追跡対象のアクタをTaragetActorとしてBlackboardに保存しています。

動作の確認

さてプレイヤーとAI両方が作成できたのでフィールドに配置してみます。
青がプレイヤーで赤がAIのポーンとなっていて、最大速度は900、加速度は400に設定されています。
またフィールドには経路探索が行われていることがわかりやすいように障害物を配置しました。

それでは実行してみましょう。

経路探索自体は動いていますが、意図した挙動にはなっていませんね。
AIで動かしてるポーンが常に最高速度になっています、また回転もしていません。

カスタマイズされたパス移動の作成

AIで動作しているポーンの挙動がおかしかったのは、BehaviorTreeで指定したMove Toが原因です。
Unreal Engineが提供している経路探索系のタスクはAddInputMovementでポーンを移動させるのではなく座標を指定して移動させるからです。

このままだとプレイヤーとAIで動作が異なってしまうので、AddInputMovementを使用するタスクを作成します。

CustomMoveToの作成

タスクの作成方法は基本UBTTaskNodeを継承して作成しますが、
今回はTargetActorを参照したいので、Blackboard関連の機能が実装されているBTTask_BlackboardBaseを継承します。

#pragma once

#include "CoreMinimal.h"
#include <BehaviorTree/Tasks/BTTask_BlackboardBase.h>
#include <NavigationSystem.h>
#include "BT_CustomMoveTo.generated.h"

// 経路探索を行いPawnのAddInputMovementで移動させる
UCLASS()
class INTERFACETEST_API UBT_CustomMoveTo : public UBTTask_BlackboardBase
{
	GENERATED_BODY()
public:
	UBT_CustomMoveTo();

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

protected:
	virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;

	virtual FVector CalcMovement(const APawn* pOwner);

	APawn* pTarget;

	UNavigationSystemV1* navSys;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (ClampMin = "0.0", UIMin = "0.0"))
	float acceptableRadius = 50.0f;
};
#include "BT_CustomMoveTo.h"
#include <AIController.h>
#include <NavigationPath.h>
#include <BehaviorTree/BlackboardComponent.h>
#include <BehaviorTree/Blackboard/BlackboardKeyType_Object.h>
#include <VisualLogger/VisualLogger.h>

UBT_CustomMoveTo::UBT_CustomMoveTo() :Super()
{
	NodeName = TEXT("CustomMoveTo");
  bNotifyTick = true;
	navSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());

	// ブラックボードから渡されるターゲットのクラスをPawnに限定
	BlackboardKey.AddObjectFilter(this, GET_MEMBER_NAME_CHECKED(UBT_CustomMoveTo, BlackboardKey), APawn::StaticClass());
}

EBTNodeResult::Type UBT_CustomMoveTo::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
  // Targetを取得
	auto pBlackboard = OwnerComp.GetBlackboardComponent();;
	auto pKeyValue = pBlackboard->GetValue<UBlackboardKeyType_Object>(BlackboardKey.GetSelectedKeyID());
	pTarget = Cast<APawn>(pKeyValue);

 // Tickで移動処理するのでInProgressを返す
	return EBTNodeResult::InProgress;
}

void UBT_CustomMoveTo::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    auto pController = OwnerComp.GetAIOwner();
	auto pOwner = pController->GetPawn();

	// ターゲットに到達しているのかの判定
	auto distance = pOwner->GetActorLocation() - pTarget->GetActorLocation();
	if (distance.SquaredLength() > acceptableRadius * acceptableRadius)
	{
		auto movement = CalcMovement(pOwner);

		if (!movement.IsZero())
		{
			pOwner->AddMovementInput(movement);
		}
	}
	else
	{
		// タスクの正常終了を通知
		FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
	}

    return;
}

FVector UBT_CustomMoveTo::CalcMovement(const APawn* pOwner)
{
	// NavigationMeshを用いて移動経路を探索
	auto pPath = navSys->FindPathToActorSynchronously(GetWorld(), pOwner->GetActorLocation(), pTarget);
	if (!pPath) return FVector::Zero();

	auto& pathPoints = pPath->PathPoints;

	if (pathPoints.Num() >= 2)
	{
		// 自身の座標から初めの地点への方向を返す
		auto dir = pathPoints[1] - pathPoints[0];
		dir.Normalize();
		return dir;
	}
	else
	{
		return FVector::Zero();
	}
}

ExecuteTaskがタスクが開始された際に一度だけ実行される関数になっています。
TickTaskはbNotifyTickがtrueかつタスクの状態がInProgressの場合毎フレーム実行される関数です。

TickTask内で経路探索とポーンの移動を行っており、ターゲットに追いついた場合にタスクの終了を行っています。

動作確認

作成したCustomMoveToとMoveToを入れ替えてみたものになります。

プレイヤーのポーンと同じように加速を行っていますね。

最後に

今回はUnreal C++をベースにポーンの移動処理を実装してみました。
ブループリントと比較するとドキュメントが少ないので色々大変ですが、それぞれの強みがあるのでバランスよく使えるようになりたいですね。
処理を共通化できると機能拡張やデバッグもしやすいと思うので参考になれば幸いです 。


【免責事項】

本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。