前回の記事の Singlecast Dynamic Delegate に Blueprint イベントをバインドする方法を応用(悪用?)して、なんちゃってステートマシンを作ってみた。
ステートマシンというと…
ステートマシンというと、シンプルなものから大仰なものまで色々なパターンを漠然と指してしまうので、今回の目的を小さく絞っておく。
上図のような「enum で定義された複数個のステート(実質、整数のインデックス)に応じて、関数を呼び分けること」を目的とする。
これ、ステートの数が多くなると、Switch が妙に長くなり、イベントグラフがみるみるスパゲティになっていって厄介なので、何とかしたいと思っている人も多いと思う。
まずは、完成品から
使用したアセット: Combat Systems - Constructor (2020年2月の無料コンテンツ)
このロボは以下のようなステート遷移で動いている:
-
Idleステート
- その場で待機。Idle モーションをする。
- 1秒たったら、次のステート(Walk)に遷移する。
-
Walkステート
- ひたすらまっすぐ歩く。Blendspace 1Dで作った歩きモーションをする。
- ロボの前方にある下向きコーン状の何かが、床から外れたらブレーキをかけ停止する。
- 速度がゼロになったら、次のステート(Turn)へ遷移する。
-
Turnステート
- 愚直に左に90度回る。左に90度しか回れない。
- 2秒かけて、左に90度回り終わったら、次のステート(Idle)に遷移する。
- 以下、繰り返し(♩)
これらのステート遷移を、Unreal C++ 作ったなんちゃってステートマシンを使って実装している。
Blueprint のようす
コンポーネント
State Binder というのが、なんちゃってステートマシンの役割を担う。(ソースコードなどは後述)
Unreal C++ 製、Actor Component の派生、UStateBinderComponent
。
イベントグラフ: Event BeginPlay
- 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
State Binder の Update
を呼び出している。
この Update
が、現在のステートに応じたカスタムイベント(上記 BeginEvent BindStates でバインドされたもの)を呼び出している。
State Binder (UStateBinderComponent) は Tick しない。どうせ Tick するはずのアクタから明示的に呼びだしてもらっている。(こういうやり方でチビチビ Tick の数を削っている)
イベントグラフ/ステートイベント: Idle
-
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;
}
以上です。
自分的には、今後非常に便利に使える気がしている。