Stateを使った実験の流れの管理
この記事では,UnityでVRなどを使った心理実験を行う際におすすめの方法を実装を交えて紹介します.
実装にはStateパターンを使用します.Stateパターンとは,あるオブジェクトに関する状態(State)やその振る舞いを表現できるデザインパターンの一種です.実験を行う時の流れをそれぞれStateオブジェクトとして実装することで,不具合が無く正確に動作し,さらに再実験・追実験がしやすい実験系を組むことができます.

実験の設定
今回紹介する実装は基本的にどんな流れの実験にも適用できるのですが,簡単のためによく見かける流れを例として設定してみます.実験は1から4をN回繰り返すとします.
実験参加者が待機する(タスク前待機)
3秒間カウントする
実験参加者が何らかのタスクを10秒間行う
タスク終了後にまた待機する(タスク後待機)
基本構造
以下の4つをStateクラスのオブジェクトとして実装します.Stateの遷移はStateMachineというクラスで管理することにします.具体的な遷移条件(いつ・どこから・どこに)はTransitionというクラスとその継承クラスが保持しており,StateMachineはTransitionの状態に応じて遷移を実行します.
外部からの操作により2に遷移する
3秒間待機した後3に遷移する(待機中はカウントを表示する)
10秒間待機した後4に遷移する
外部からの操作により1に遷移する(遷移時に実験の試行数を1増やす)

サンプルコード(基幹)
State.cs
using System;
using UnityEngine;
using UnityEngine.Events;
[Serializable]
public class State
{
/// <summary>
/// このStateを管理するクラス
/// </summary>
private StateMachine m_stateMachine;
[Header("状態の名前")]
[SerializeField]
private string m_label = "New State";
public string Label => m_label;
public override string ToString() => Label;
#region Action
[Header("このStateに入った時に呼ばれる")]
public UnityEvent<State> OnStateEnter = new UnityEvent<State>();
[Header("このStateにいる間常に呼ばれる")]
public UnityEvent<State> OnStateUpdate = new UnityEvent<State>();
[Header("このStateから出た時に呼ばれる")]
public UnityEvent<State> OnStateExit = new UnityEvent<State>();
#endregion
#region Time Management
[Header("遷移からの経過時間")]
[SerializeField]
private float m_currentTime = 0f;
/// <summary>
/// 遷移からの経過時間
/// </summary>
public float CurrentTime => m_currentTime;
#endregion
/// <summary>
/// このStateを管理するStateMachineを設定する
/// </summary>
public void SetStateMachine(StateMachine stateMachine)
{
m_stateMachine = stateMachine;
}
public void OnEnter()
{
OnStateEnter.Invoke(this);
m_currentTime = 0f;
}
public void OnUpdate(float deltaTime = 0.02f)
{
OnStateUpdate.Invoke(this);
// カウントダウンを進める
m_currentTime += deltaTime;
}
public void OnExit()
{
OnStateExit.Invoke(this);
m_currentTime = 0f;
}
}
StateMachine.cs
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class StateMachine
{
[Header("現在のStateの番号")]
[SerializeField]
private int m_currentStateIndex;
/// <summary>
/// 現在のStateの番号
/// </summary>
public int CurrentStateIndex
{
get => m_currentStateIndex;
set => SetCurrentState(value);
}
/// <summary>
/// 現在のState
/// </summary>
public State CurrentState => States[m_currentStateIndex];
/// <summary>
/// このStateMachineのStateの集合
/// </summary>
public List<State> States;
[SerializeReference]
public List<Transition> Transitions;
public void InitializeStates()
{
for (int i = 0; i < States.Count; i++)
{
States[i].SetStateMachine(this);
}
}
/// <summary>
/// 現在のStateのOnUpdateを毎フレーム呼び続ける
/// </summary>
public void Update(float deltaTime=0.02f)
{
CurrentState.OnUpdate(deltaTime);
for (int i=0; i<Transitions.Count; i++)
{
if (Transitions[i].GetStateFrom() == CurrentState.Label && Transitions[i].IsTriggered(CurrentState))
{
SetCurrentState(Transitions[i].GetStateTo());
break;
}
}
}
public void SetCurrentState(int index)
{
// m_currentStateIndexが[0, m_states.Count)に収まるように代入する
if (index < 0)
{
index = States.Count - 1;
}
else if (States.Count <= index)
{
index = 0;
}
// 現在のStateをExitする
States[m_currentStateIndex].OnExit();
// 現在のStateを更新してEnterする
m_currentStateIndex = index;
States[m_currentStateIndex].OnEnter();
}
public void SetCurrentState(State state)
{
for (int i = 0;i < States.Count; i++)
{
if (States[i] == state)
{
SetCurrentState(i);
return;
}
}
Debug.LogWarning($"StateMachine: This StateMachine does not have {state.GetType().ToString()}!");
}
public void SetCurrentState(string label)
{
for (int i = 0; i < States.Count; i++)
{
if (States[i].Label == label)
{
SetCurrentState(i);
return;
}
}
Debug.LogWarning($"StateMachine: This StateMachine does not have any states whose label is {label}!");
}
/// <summary>
/// IMGUIを実装する
/// </summary>
/// <param name="windowId"></param>
public void ShowGUI(int windowId)
{
// 遷移用ボタンの表示
GUILayout.Label($"Current State: {CurrentState}");
GUILayout.BeginHorizontal();
for (int i=0; i<States.Count;i++)
{
if (GUILayout.Button($"{States[i]}"))
{
SetCurrentState(i);
}
// 3つごとに改行する
if (i % 3 == 2)
{
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
}
}
GUILayout.EndHorizontal();
// 時間の表示
GUILayout.Label($"Time: {CurrentState.CurrentTime:0.00}");
}
}
StateMachineクラスはStateオブジェクトとそれらの間の遷移を管理します.遷移条件は以下のTransitionクラスによって決められます.
Transition.cs
public abstract class Transition : MonoBehaviour
{
/// <summary>
/// このTransitionが遷移条件を満たしている
/// </summary>
/// <returns></returns>
public abstract bool IsTriggered(State state);
/// <summary>
/// 遷移元のState
/// </summary>
/// <returns></returns>
public abstract string GetStateFrom();
/// <summary>
/// 遷移先のState
/// </summary>
/// <returns></returns>
public abstract string GetStateTo();
}
Transitionクラスはいつ・どこから・どこに遷移が起きるかをそれぞれIsTriggered(),GetStateFrom(),GetStateTo()として実装します.遷移条件はさまざまな種類が考えられるので,まずは抽象クラスにとどめておくことにします.後で継承クラスを作って具体的な遷移条件を決めます.
Experiment.cs
using UnityEngine;
public class Experiment : MonoBehaviour
{
public StateMachine StateMachine;
private void OnValidate()
{
StateMachine.InitializeStates();
}
private void FixedUpdate()
{
StateMachine.Update(Time.fixedDeltaTime);
}
private int m_windowId = 0;
private Rect m_windowRect = new Rect(0, 0, 200, 300);
private void OnGUI()
{
m_windowRect = GUI.Window(m_windowId, m_windowRect, (id) =>
{
StateMachine.ShowGUI(id);
GUI.DragWindow();
}, name);
}
}
Experiment.csはStateMachineを保持して毎フレーム更新処理を行います.StateMachineの状態はStateMachine.ShowGUI()関数を介して表示していますが,基本的な書き方は過去にご紹介したものです.
インスペクタの見た目はこんな感じです.「実験の設定」より4つのStateにそれぞれEntry,Count Down,Trial,End of Trialとラベルを付けています.今回は奮発して日本語のヘッダもつけてみました.

また,プレイモードに入ると以下のようなGUIが出てきます.現在のStateや時間の確認ができるほか,ボタンをクリックするとそのStateに即座に遷移することができます.

サンプルコード(遷移条件)
次に,遷移条件を表すTransitionクラスを具体化していきます.この記事では時間経過に応じた遷移TimeTransitionと,数値の大小に応じた遷移CountTransitionを実装してみます.CountTransitionは試行を繰り返した回数を数えるのに使えます.
TimeTransition.cs
public class TimeTransition : Transition
{
[SerializeField]
private string m_stateFrom;
[SerializeField]
private string m_stateTo;
[SerializeField]
private float m_timeMax = 10f;
public override string GetStateFrom() => m_stateFrom;
public override string GetStateTo() => m_stateTo;
public override bool IsTriggered(State s) => GetRemainedTime(s) <= 0f;
/// <summary>
/// 残り時間を取得する
/// </summary>
/// <param name="s"></param>
public float GetRemainedTime(State s) => m_timeMax - s.CurrentTime;
}
CountTransition.cs
public class CountTransition : Transition
{
[SerializeField]
private string m_stateFrom;
[SerializeField]
private string m_stateTo;
[SerializeField]
private int m_count;
public int Count => m_count;
[SerializeField]
private int m_countMax = 10;
public override string GetStateFrom() => m_stateFrom;
public override string GetStateTo() => m_stateTo;
public override bool IsTriggered(State s) => m_countMax <= m_count;
// 今回の場合は試行回数を数えたいので,
// 1つずつ足していく方法以外でm_countの値を変更することは
// できないようにした.すると不具合が減る.
public void Incliment() => m_count++;
}
イベントの登録
今回の実装では,サンプルコードに加えていくつかのイベントを登録する必要があります.逆に言えば,根幹の実装を頻繁に書き換える必要が無いとも言えます.まずCount DownのStateのために,カウントダウンの際に残り時間を表示するスクリプトを用意します.こちらはTextMeshProをインポートする必要があります.TextMeshProコンポーネントをアタッチしたゲームオブジェクトにTimeKeeperコンポーネントもアタッチします.
TimeKeeper.cs
using UnityEngine;
public class TimeKeeper : MonoBehaviour
{
[SerializeField]
private TMPro.TextMeshPro m_label;
[SerializeField]
private TimeTransition m_timeTransition;
public void SetRemainedTime(State state)
{
var time = m_timeTransition.GetRemainedTime(state);
m_label.text = $"{Mathf.Ceil(time):0} sec";
}
}
そして,CountDownのStateのイベントにTimeKeeperのメソッドを設定しておきます.

ここまででプレイモードを起動し,Count DownのStateに遷移すると,カウントダウンが始まります.

次にTransitionを設定します.適当なゲームオブジェクトにTimeTransitionやCountTransitionをアタッチして,それらをExperimentのTransitionsに追加します.

さらに,これらのTransitionにそれぞれ設定を書き込んでいきます.例えば,今回の実験の流れでは以下のようなStateがあります.
1. 実験参加者が何らかのタスクを10秒間行う
この時のTimeTransitionはこのようになります.

どこから:Trial
どこに:End of Trial
CountTransitionは以下のようになります.Countが2に達した後,実験終了を表すStateに遷移する必要があるので,ここでは新しくEnd of ExperimentというStateを定めることにしました.なお,Countは手動で足しこむ必要がありますが,これはEnd of TrialのStateにイベントとして登録することにします.

どこから:End of Trial
どこに:End of Experiment

仕上げとして,現在どのStateにいるかわかるように適当なゲームオブジェクト(「Label …」)を置き,これらの有効・非有効をそれぞれのStateのOnStateEnterとOnStateExitで切り替えるようにしてみました.皆さんの実験ではここを各自でカスタマイズしてもらいます.ほとんどの場合はGameObject.SetActive()するか,Component.enabled = true/falseすればいいと思います.


ここまでで「実験の設定」の流れは全て実装できました.なお実装の途中でEnd of ExperimentのStateを追加したのを5. と追加しました.
Entry 実験参加者が待機する(タスク前待機)
Count Down 3秒間カウントする
Trial 実験参加者が何らかのタスクを10秒間行う
End of Trialタスク終了後にまた待機する(タスク後待機)
End of Experiment 1から4をN回繰り返した後,実験を終了する

サンプルコード(遷移条件・補遺)
ここまでの解説で載せなかったものの,よく使われそうな遷移方法のサンプルコードを置いておきます(随時更新).
InputTransition.cs
using UnityEngine;
public class InputTransition : Transition
{
[SerializeField]
private string m_stateFrom;
[SerializeField]
private string m_stateTo;
[SerializeField]
private KeyCode m_key;
public override string GetStateFrom() => m_stateFrom;
public override string GetStateTo() => m_stateTo;
public override bool IsTriggered(State s) => Input.GetKey(m_key);
}
キー入力で遷移を行います.今回の実装ではExperimentオブジェクトがFixedUpdateでStateMachineの更新を行っているため,Input.GetKeyDown()ではなくInput.GetKey()でキー入力を取得しています.それぞれ入力された瞬間に発火するものと,入力された間断続的に発火するものですが,遷移はIsTriggered()がtrueになった一瞬で完了するので今回は後者でも良いのです.
追記:実験条件ごとの場合分け
多くの実験では複数の条件を定めて対照実験を行うと思います.ほとんどの実験は条件間で実験の流れ自体が変わることはないと思いますが,この場合は条件ごとにStateを作成するのはお勧めしません.というのも,場合ごとにほとんど同じ条件のStateを複製したり,Transitionを設定したりするのは手間がかかるからです.手間のかかる作業はミスやバグのリスクに直結します.ではどうするかというと,Stateとは別に実験条件を表すクラスを設計するのが良いでしょう.以下の記事のサンプルコードが参考になると思います.
具体的には,実験条件をConditionというenumやintで表現し,Conditionの変更を即座に反映させるようsetterを作成します.その後,ボタン押下でConditionの値を変更できるGUIを作成します.Stateパターンとの連携については,条件ごとに出し入れしたいゲームオブジェクト全てに共通の親オブジェクトを定め,TrialのStateのOnStateEnter()とOnStateExit()で親オブジェクトの有効/非有効を切り替えるのが最もスムーズな実装だと思います.
または,実験条件を表現するために別のStateMachineを用意するというのも手です.こちらは実験の流れ管理のために書いたコードがそのまま流用できるので美しいですね.GUIでStateを変更できるので,わざわざTransitionを設定する必要はありません.
public class ExperimentWithCondition : MonoBehaviour
{
/// <summary>
/// 実験の流れを管理するStateMachine
/// </summary>
public StateMachine Flow;
/// <summary>
/// 実験の条件を管理するStateMachine
/// </summary>
public StateMachine Condition;
private void OnValidate()
{
Flow.InitializeStates();
Condition.InitializeStates();
}
private void FixedUpdate()
{
Flow.Update(Time.fixedDeltaTime);
Condition.Update(Time.fixedDeltaTime);
}
private int m_windowId = 0;
private Rect m_windowRect = new Rect(0, 0, 200, 300);
private void OnGUI()
{
m_windowRect = GUI.Window(m_windowId, m_windowRect, (id) =>
{
Flow.ShowGUI(id);
GUILayout.Label(""); // 改行
Condition.ShowGUI(id);
GUI.DragWindow();
}, name);
}
}

