2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【UE4】Singlecast Dynamic Delegateで、なんちゃってステートマシンを作ってみた

Last updated at Posted at 2020-07-05

前回の記事の Singlecast Dynamic Delegate に Blueprint イベントをバインドする方法を応用(悪用?)して、なんちゃってステートマシンを作ってみた。

ステートマシンというと…

ステートマシンというと、シンプルなものから大仰なものまで色々なパターンを漠然と指してしまうので、今回の目的を小さく絞っておく。

image.png

上図のような「enum で定義された複数個のステート(実質、整数のインデックス)に応じて、関数を呼び分けること」を目的とする。
これ、ステートの数が多くなると、Switch が妙に長くなり、イベントグラフがみるみるスパゲティになっていって厄介なので、何とかしたいと思っている人も多いと思う。

まずは、完成品から

20200704_2245_8fps.gif

使用したアセット: Combat Systems - Constructor (2020年2月の無料コンテンツ)

このロボは以下のようなステート遷移で動いている:

  • Idleステート
    • その場で待機。Idle モーションをする。
    • 1秒たったら、次のステート(Walk)に遷移する。
  • Walkステート
    • ひたすらまっすぐ歩く。Blendspace 1Dで作った歩きモーションをする。
    • ロボの前方にある下向きコーン状の何かが、床から外れたらブレーキをかけ停止する。
    • 速度がゼロになったら、次のステート(Turn)へ遷移する。
  • Turnステート
    • 愚直に左に90度回る。左に90度しか回れない。
    • 2秒かけて、左に90度回り終わったら、次のステート(Idle)に遷移する。
    • 以下、繰り返し(♩)

これらのステート遷移を、Unreal C++ 作ったなんちゃってステートマシンを使って実装している。

Blueprint のようす

コンポーネント

image.png

State Binder というのが、なんちゃってステートマシンの役割を担う。(ソースコードなどは後述)
Unreal C++ 製、Actor Component の派生、UStateBinderComponent

イベントグラフ: Event BeginPlay

image.png

  • BeginEvent BindStates: Event BeginPlay から呼び出されるカスタムイベント。ただ単にスパゲティ化を避けるためだけに別イベントにわけた。
  • Sequence Then 0: State Binder を総ステート数を指定して初期化。内部では、指定されたステートの数だけ Singlecast Dynamic Delegate を用意する。
  • Sequence Then 1: State Binder に、Idleステート用のカスタムイベントをバインドする。2つバインドしているのは、ステート切り替え直後に呼び出される OnEnter デリゲートと、そのステート中毎フレーム呼び出される OnUpdate デリゲートがあるから。
    • このほかに、ステート切り替えで終了を迎えるステートに送られる OnExit デリゲートも用意したが、今回は使っていない。
  • Sequence Then 2: Walkステート用のカスタムイベントのバインド。
  • Sequence Then 3: Turnステート用のカスタムイベントのバインド。

イベントグラフ: Event Tick

image.png

State Binder の Update を呼び出している。
この Update が、現在のステートに応じたカスタムイベント(上記 BeginEvent BindStates でバインドされたもの)を呼び出している。

State Binder (UStateBinderComponent) は Tick しない。どうせ Tick するはずのアクタから明示的に呼びだしてもらっている。(こういうやり方でチビチビ Tick の数を削っている)

イベントグラフ/ステートイベント: Idle

image.png

  • OnEnter Idle: 他のステートからこのステートに遷移した直後、または初回の Update 呼び出しで呼び出される。
    • ステートの経過時間をカウントする変数の初期化。
    • アニメーションの更新。Blend 1D に Speed = 0.0 を指定して、Idle モーションを再生させる。
  • OnUpdate Idle: このステートの毎フレームの処理。単純にステートの経過時間が1秒を超えるのを待ってから、Request Next State で次のステートの Enum 値(≒整数値)を指定している。

イベントグラフ/ステートイベント: Walk

仕組みは Idle ステートと同じなので、イベントグラフのスクショは割愛。

  • OnEnter Walk: 移動速度を 0.0 に初期化.
  • OnUpdate Walk: 下向きコーン状の何かの下のコリジョンを Sphere Trace By Channel でチェック。床がまだあるなら加速、床がないならブレーキ。速度がゼロ以下になったら、次のステート(Turn)へ。

イベントグラフ/ステートイベント: Turn

仕組みは Idle ステートと同じなので、イベントグラフのスクショは割愛。

  • OnEnter Turn: ステート経過時間のゼロクリア。左に90度回転後の角度を算出して変数に保存。その場回転アニメーションを指定。
  • OnUpdate Turn: 2秒かけてアクタのワールド回転角度を左に90度回す (EaseInOutを使用)。2秒たったら、次のステート(Idle)へ。

UStateBinderComponent (C++)

前回の記事 ではデリゲート変数がむき出しで、Blueprint からも好き放題触ることができたが、今回は「配列で管理する必要があったこと」「バインド用の関数を用意したこと」で、UPROPERTY なしの private 変数として作ることができた。
これなら迂闊な事態は発生しないだろう。

UStateBinderComponent.h

ちょっと長いのは、ステート値の int32 版と uint8 版の両方を用意しているから。
※int32 だけだと、Blueprint で Enum 値を渡すときに ToInteger 的なノードが一個挟まって邪魔だったのです。

#pragma once

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

DECLARE_LOG_CATEGORY_EXTERN(LogStateBinder, Log, All);

DECLARE_DYNAMIC_DELEGATE(FStateBinderEnterSignature);
DECLARE_DYNAMIC_DELEGATE_OneParam(FStateBinderUpdateSignature, float, InDeltaSeconds);
DECLARE_DYNAMIC_DELEGATE(FStateBinderExitSignature);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class STATEMACHINE01_API UStateBinderComponent : public UActorComponent
{
	GENERATED_BODY()

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

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

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

public: // [RINDERON] blueprint callable interfaces

	// Reserves InNumStates items in state delegate array
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void Initialize(int32 InNumStates);

	// Binds enter state event (integer index version).
	// If you don't bind, just no event will be called.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void BindEnterEventByIntegerIndex(int32 InStateIndex, const FStateBinderEnterSignature& InEnterEvent);

	// Binds update state event (integer index version). (this will be called every time you call Update())
	// If you don't bind, just no event will be called.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void BindUpdateEventByIntegerIndex(int32 InStateIndex, const FStateBinderUpdateSignature& InUpdateEvent);

	// Binds exit state event (integer index version).
	// If you don't bind, just no event will be called.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void BindExitEventByIntegerIndex(int32 InStateIndex, const FStateBinderExitSignature& InExitEvent);

	// Call this function instead of TickComponent, because this component will not tick.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void Update(float InDeltaSeconds);

	// Requests next state.
	// Note that changing state is performed in Update(), not here.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		bool RequestNextStateByIntegerIndex(int32 InNextStateIndex);

	// Stops updates and exit current state.
	// Note that Update() is still running.
	// If you want to PAUSE, you just stop calling Update().
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void Exit() { RequestNextStateByIntegerIndex(InvalidStateIndex); }

	// Returns current state index integer index version
	UFUNCTION(BlueprintPure, Category = "State Binder")
		int32 GetCurrentStateIndexByIntegerIndex() const { return CurrentStateIndex; }

	// Returns number of states
	UFUNCTION(BlueprintPure, Category = "State Binder")
		int32 GetNumStates() const { return NumStates; }

	// Checks if current state index equals to InStateIndex
	UFUNCTION(BlueprintPure, Category = "State Binder")
		bool IsStateIndexEqualsByIntegerIndex(int32 InStateIndex) const { return CurrentStateIndex == InStateIndex; }

	// Returns (InStateIndex>= 0 && InStateIndex < NumStates);
	UFUNCTION(BlueprintPure, Category = "State Binder")
		bool IsValidStateIndexByIntegerIndex(int32 InStateIndex) const { return InStateIndex >= 0 && InStateIndex < NumStates; }

public: // [RINDERON] Byte index version for compatiblility with enum.

		// Binds enter state event (byte (enum) index version).
	// If you don't bind, just no event will be called.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void BindEnterEvent(uint8 InStateIndex, const FStateBinderEnterSignature& InEnterEvent) { BindEnterEventByIntegerIndex((int32)InStateIndex, InEnterEvent); }

	// Binds update state event (byte (enum) index version). (this will be called every time you call Update())
	// If you don't bind, just no event will be called.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void BindUpdateEvent(uint8 InStateIndex, const FStateBinderUpdateSignature& InUpdateEvent) { BindUpdateEventByIntegerIndex((int32)InStateIndex, InUpdateEvent); }

	// Binds exit state event (byte (enum) index version).
	// If you don't bind, just no event will be called.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		void BindExitEvent(uint8 InStateIndex, const FStateBinderExitSignature& InExitEvent) { BindExitEventByIntegerIndex((int32)InStateIndex, InExitEvent); }

	// Requests next state (byte (enum) index version).
	// Note that changing state is performed in Update(), not here.
	UFUNCTION(BlueprintCallable, Category = "State Binder")
		bool RequestNextState(uint8 InNextStateIndex) { return RequestNextStateByIntegerIndex((int32)InNextStateIndex); }

	// Returns current state index (byte (enum) index version).
	UFUNCTION(BlueprintPure, Category = "State Binder")
		uint8 GetCurrentStateIndex() const { return (uint8)GetCurrentStateIndexByIntegerIndex(); }

	// Checks if current state index equals to InStateIndex (byte (enumn) index version).
	UFUNCTION(BlueprintPure, Category = "State Binder")
		bool IsStateIndexEquals(uint8 InStateIndex) const { return IsStateIndexEqualsByIntegerIndex((int32)InStateIndex); }

	// Returns (CurrentStateIndex >= 0 && CurrentStateIndex < NumStates);
	UFUNCTION(BlueprintPure, Category = "State Binder")
		bool IsValidStateIndex(int32 InStateIndex) const { return IsValidStateIndexByIntegerIndex((int32)InStateIndex); }

private: // [RINDERON] internal properties

	static const int32 InvalidStateIndex = -1;

	int32 NumStates = 0;
	int32 CurrentStateIndex = InvalidStateIndex; // initial value must be invalid.
	int32 NextStateIndex = 0; // set zero to fire changing to state 0 at the top of Update().

	TArray<FStateBinderEnterSignature> EnterDelegateArray;
	TArray<FStateBinderUpdateSignature> UpdateDelegateArray;
	TArray<FStateBinderExitSignature> ExitDelegateArray;
};

UStateBinderComponent.cpp

ちょっとした要点としては、ステートの切り替えを RequestNextState() 呼び出し内で処理せずに、Update() の冒頭で行っていることだろうか。
RequestNextState() が Tick の流れで呼び出されるとは限らないので(※)。

※Tick の流れで呼び出されるとは限らない
コリジョンヒット時に呼び出されたり、アニメーションのNotify経由で呼び出されたり、Game Mode や Game State から呼び出されたり。
UStateBinderComponent::Update() の実行開始~ステートイベントの処理~Update() に処理が戻ってくるまでの間 「以外に」 ステートの変更が発生すると、想定外の動作を引き起こすことが多々ある。これは UE4 に限らず、すべてのゲームプログラムでよくある問題。

#include "StateBinderComponent.h"

DEFINE_LOG_CATEGORY(LogStateBinder);

// Sets default values for this component's properties
UStateBinderComponent::UStateBinderComponent()
{
	// Set this component to be initialized when the game starts, and to be ticked every frame.  You can turn these features
	// off to improve performance if you don't need them.
	// [RIDNERON] PrimaryComponentTick.bCanEverTick = true;
	/* [RINDERON] */ PrimaryComponentTick.bCanEverTick = false; // this component will not tick! call Update() manually.
}

// BeginPlay() は無改変なので割愛
// TickComponent() も無改変なので割愛

////////////////////////////////////////////////////////////////////////////////
// [RINDERON]

void UStateBinderComponent::Initialize(int32 InNumStates)
{
	if (InNumStates > 0)
	{
		EnterDelegateArray.SetNum(InNumStates);
		UpdateDelegateArray.SetNum(InNumStates);
		ExitDelegateArray.SetNum(InNumStates);

		NumStates = InNumStates;
		CurrentStateIndex = InvalidStateIndex; // initial value must be invalid.
		NextStateIndex = 0; // set zero to fire changing to state 0 at the top of Update().
	}
	else
	{
		EnterDelegateArray.Empty();
		UpdateDelegateArray.Empty();
		ExitDelegateArray.Empty();

		NumStates = 0;
		CurrentStateIndex = InvalidStateIndex;
		NextStateIndex = InvalidStateIndex;
	}
}

void UStateBinderComponent::BindEnterEventByIntegerIndex(int32 InStateIndex, const FStateBinderEnterSignature& InEnterEvent)
{
	if (EnterDelegateArray.IsValidIndex(InStateIndex))
	{
		EnterDelegateArray[InStateIndex] = InEnterEvent;
	}
}

void UStateBinderComponent::BindUpdateEventByIntegerIndex(int32 InStateIndex, const FStateBinderUpdateSignature& InUpdateEvent)
{
	if (UpdateDelegateArray.IsValidIndex(InStateIndex))
	{
		UpdateDelegateArray[InStateIndex] = InUpdateEvent;
	}
}

void UStateBinderComponent::BindExitEventByIntegerIndex(int32 InStateIndex, const FStateBinderExitSignature& InExitEvent)
{
	if (ExitDelegateArray.IsValidIndex(InStateIndex))
	{
		ExitDelegateArray[InStateIndex] = InExitEvent;
	}
}

void UStateBinderComponent::Update(float InDeltaSeconds)
{
	// change state
	if (CurrentStateIndex != NextStateIndex)
	{
		// exit current state
		if (ExitDelegateArray.IsValidIndex(CurrentStateIndex))
		{
			const FStateBinderExitSignature& onExitState = ExitDelegateArray[CurrentStateIndex];
			onExitState.ExecuteIfBound();
		}

		// enter next state
		if (UpdateDelegateArray.IsValidIndex(NextStateIndex))
		{
			FStateBinderEnterSignature& onEnterState = EnterDelegateArray[NextStateIndex];
			onEnterState.ExecuteIfBound();
		}

		CurrentStateIndex = NextStateIndex;
	}

	// update state
	if (UpdateDelegateArray.IsValidIndex(CurrentStateIndex))
	{
		FStateBinderUpdateSignature& onUpdateState = UpdateDelegateArray[CurrentStateIndex];
		onUpdateState.ExecuteIfBound(InDeltaSeconds);
	}
}

bool UStateBinderComponent::RequestNextStateByIntegerIndex(int32 InNextStateIndex)
{
	if (InNextStateIndex >= 0 && InNextStateIndex < NumStates && CurrentStateIndex != InNextStateIndex)
	{
		NextStateIndex = InNextStateIndex;
		return true;
	}
	return false;
}

以上です。

自分的には、今後非常に便利に使える気がしている。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?