はじめに
Unit6にはビヘイビア(Behavior)というパッケージがあるそうです。
この記事作成時の Behavior のバージョンは 1.0.8 です
簡単に言うと、グラフUIでノードを組み合わせてローコード・ノーコードで非エンジニアでも敵の思考ルーチンを組める仕組みです。
本来は「巡回中に敵を見つけたら追いかける」のようなリアルタイムなアクションゲームを想定したものですが、これをターン制バトルの思考ルーチンにも応用できないかと思って試してみました。
具体的にどこが本来の用途と違うのか、何を目的にしてるかというと、以下の通りです。
- GameObject, MonoBehaviour に依存しないデータ駆動の処理をしたい
- コルーチンのようにフレームをまたがず、即座に結果を得たい
- 任意のタイミングで処理の開始して単一の結果を得たい
本記事では、Unity Behavior 1.0.8 を活用して、即時実行型の思考ルーチンを実装してみた結果を紹介します。
簡単な例として、ターン制バトルにおける ターゲット選択 → スキル使用 の流れを 1フレーム内で完結する形 で実装してみました。
本記事ではビヘイビアについて以下の用語を用いています
- (ビヘイビア)グラフ Behavior Graph
- 上の画像のような画面のこと、思考ルーチンの全体像
- ノード
- グラフ上で矢印でつないだりできるパーツのこと、自作のものはカスタムノードと呼びます
- Blackboard
- 各ノードに設定できる「変数」一覧を管理するオブジェクト
なぜビヘイビアを使うのか?
ChatGPTに聞きながらこれ実装してたんですが、なんだか類似品が多くてハルシネーションしまくるんですよね。なので主に末尾に上げた記事などを参考にさせていただきました。
まだまだ新しい機能のようですが、とりあえずUnity公式ってことですし、サードパーティー製のを色々試す前にこれを使いこなしてみようと思いました。
ところで、恥ずかしながら実は最近まで知らなかったんですけど、似たようなノーコード・ローコードシステムとして Visual Scripting ってのもあるようですね。
実際試すところまでは出来てないのですが、ちらっと見た限りでは、かなり複雑なことができそうで、実はこっちの方が自分の目的にも合ってるんじゃないかと悩みました。
ただ、複雑なデータの取り扱いができる反面、非エンジニアにとっつきにくいのでは という印象を受けました。特に思考ルーチンのような 条件分岐 や 逐次処理 など処理の流れを強く意識するものには、ビヘイビアの方が直感的なわかりやすさを感じました。
カスタムイベントの購読・発火 ができるのもビヘイビアの利点でしょう。
Unity が歴史の古い Visual Scripting とは別に新たに出してきたからには、ビヘイビアにはビヘイビアの良さがあるはずです。それに賭けてみようと思いました。
実装
方針
とりあえず簡単な例として以下のような仕様を考えます
データ仕様
- 各ユニットはSP(スキルポイント)ステータスを持つ(※SPはターン進行などで増加するが本記事では省略)
- 各ユニットは無条件で使える「通常攻撃」と SP消費が必要な「アクティブスキル」を持つ
- 各ユニットはHate(敵からの狙われやすさ)ステータスを持つ
作成する思考ルーチン
- スクリプトから ReadyEvent が発火されたら以下を開始する
- 敵から もっとも Hate 値の高い敵をターゲットとして選択 する
- SPが十分あれば「アクティブスキル」を、そうでなければ「通常攻撃」する
- ターゲットと使用するアクションを UseSkillEvent として発火する
コード:カスタムイベント
グラフ内コンテキストメニューの Create New > Action で Category:Event で作るノードは罠
グラフ中で待ち受けたり発火できるカスタムイベントの作り方
-
まずグラフ上でBlackboardウィンドウを開き、右の✙ボタンで Events > Create new event channel typeを選び、フォームに入力してひな形を作成します。
-
ひな形クラスができると、プロジェクトウィンドウのコンテキストメニューでCreate > Behavior Event Channels から1で作成したクラスのScriptableObjectが作れるので、作る。
-
Blackboard にも New Xxx みたいな変数が登録されるが、各ノードがScriptableObjectを直接参照できるので消してもよい。(コンテキストメニュー > Delete)
処理の開始を通知するイベント
#if UNITY_EDITOR
[CreateAssetMenu(menuName = "Behavior/Event Channels/ReadyEvent")]
#endif
[Serializable, GeneratePropertyBag]
[EventChannelDescription(name: "ReadyEvent", message: "Parameters ready", category: "Events", id: "93523a98158147aa19282a52eeae44ca")]
public partial class ReadyEvent : EventChannelBase
{
/// ひな形のままなので省略
}
Add > Events > Wait for Event Message で待ち受けノードを生成し、Event Channel のリンクボタンを押して Link Variable を開く。
[Assets] タブに切り替えるとScriptableObjectが選べるので、ReadyEventを選択。
処理の結果を通知するイベント
#if UNITY_EDITOR
[CreateAssetMenu(menuName = "Behavior/Event Channels/UseSkill")]
#endif
[Serializable, GeneratePropertyBag]
[EventChannelDescription(name: "UseSkill", message: "Use [Skill] with [target]", category: "Events", id: "abb25798ff9750110f41634a41bb75ef")]
public partial class UseSkillEvent : EventChannelBase
{
/// ひな形のままなので省略
}
Add > Events > Send Event Message で発火ノードを生成し、Event Channel のリンクボタンを押して Link Variable を開く。
[Assets] タブに切り替えるとScriptableObjectが選べるので、ReadyEventを選択。
コード:カスタムアクション
アクション とはノードのうちでも、Blackboard変数に対して変更を行うような「処理」を行うパーツです。
カスタムアクションを作るには、ビヘイビアグラフ上でコンテキストメニュー(右クリック)を出し、Create New > Action から作成します。ビヘイビアグラフのフォームで入力していくとひな形ができるので OnUpdateメソッドにちょっとコードを書き足すだけ です。
もっと詳しく知りたい方は末尾の参考urlをどうぞ。
/// <summary>
/// ヘイト値に応じたスコアを生成する
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(name: "ScoresByHate", story: "Init [TargetScores] by Hate for [data]", category: "Battle/Action", id: "20a9adb00831baae24db2de58936bf51")]
public partial class ScoresByHateAction : Action
{
[SerializeReference] public BlackboardVariable<BattleSituation> data;
[SerializeReference] public BlackboardVariable<List<float>> TargetScores;
protected override Status OnStart()
{
return Status.Running;
}
protected override Status OnUpdate()
{
TargetScores.Value = data.Value.CreateScoresForOpponents(u => u.hate / 100f);
Debug.Log($"Scores: {TargetScores.Value.Select(s => s.ToString("F3")).JoinString(",")}");
return Status.Success;
}
protected override void OnEnd()
{
}
}
/// <summary>
/// 最も高いスコアを持つターゲットを選択する
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(name: "SelectMainTarget", story: "Choose highest from [scores] for [target]", category: "Battle/Action", id: "4079601d1496bb61d9ce88740d783407")]
public partial class SelectMainTargetAction : Action
{
[SerializeReference] public BlackboardVariable<List<float>> Scores;
[SerializeReference] public BlackboardVariable<int> Target;
protected override Status OnStart()
{
return Status.Running;
}
protected override Status OnUpdate()
{
var scores = Scores.Value;
var sMax = float.MinValue;
var iMax = -1;
int i = 0;
foreach ( var s in scores )
{
if (sMax >= s) continue;
sMax = s;
iMax = i++;
}
Target.Value = iMax;
Debug.Log($"SelectMainTargetAction, idx={iMax}, sMax={sMax}");
return Status.Success;
}
protected override void OnEnd()
{
}
}
余談ですが、ビヘイビアグラフではノードを矢印でつなぐ以外に、このようにアクションをリスト状に連結することもできます。
アクションノードを別のアクションノードの真下あたりにドラッグ&ドロップすると連結できます。効果は矢印で結ぶのと違わないはずですが、コンパクトに収まってグラフ内での配置がしやすくなるので、おすすめです。
コード:カスタムコンディション(条件)
グラフ内コンテキストメニューの Create New で作る Category:Conditional 系ノードは罠
真偽値での条件分岐(いわゆるif文)で使うカスタム条件の作り方
- まずグラフ上でコンテキストメニューを出し、 Add > Flow > Conditon > Conditional Branch で組み込み済みの条件分岐ノードを作成します。
- [Branch on]ノードを選択するとグラフ内のInspectorウィンドウに色々設定項目が表示されます。
- [Assign Condition]ボタンを押し、Create New Conditon から色々フォームに入力していけばアクションノードと同じようなひな形が作成されます。
- 再度 [Assign Condition]ボタンを押して、3で作ったクラスを選択します。
[Serializable, Unity.Properties.GeneratePropertyBag]
[Condition(name: "HasEnoughSP", story: "[data] has enough SP for [Skill]", category: "Conditions", id: "1c866408766c3f40244c76a4aabab8e8")]
public partial class HasEnoughSpCondition : Condition
{
[SerializeReference] public BlackboardVariable<Skill> Skill;
[SerializeReference] public BlackboardVariable<BattleSituation> data;
public override bool IsTrue()
{
var skill = data.Value[Skill.Value];
var actor = data.Actor;
Debug.Log($"{Skill.Value} require={skill.requireSp}, has={actor.sp}");
return actor.sp >= skill.requireSp;
}
public override void OnStart()
{
}
public override void OnEnd()
{
}
}
書き足したのは IsTrue だけ。
コード:各種データモデル
[Serializable]
public class UnitData
{
public int hp;
public int sp;
public int hate;
}
[Serializable]
public class SkillData
{
public int id;
public int requireSp;
}
サンプルのため、最低限のフィールドのみ定義しています。
グラフ内で扱えるEnumを作る場合はグラフ上でBlackboardウィンドウを開き、右の✙ボタンで Enumeration > Create new enum typeを選び、フォームに入力して作成します。
[BlackboardEnum]
public enum Skill
{
BasicAttack,
ActiveSkill1,
ActiveSkill2
}
本来ビヘイビアグラフではEnumはステートマシンに使うのですが、今回はこのEnumを スキル識別子 のように使ってみました。
本当は、ユニット識別子としても使えたらよかったのですが、 Enum のリストは扱えないので断念。
コード:集約モデル
バトルで使用するデータの集約オブジェクトです。このクラスは普通にプロジェクトビューでスクリプトを追加して作りました。
本来ならば、今回のサンプルには MonoBehavior は一切必要ないのですが、 Behaviorの欠点として(※個人の見解です)、Blackboardおよびノードで 扱える変数型に制限 があって、埋め込み型やGameObject,MonoBehaviour以外の カスタムな型は使えない ためです。
MonoBehaviourを継承した自作コンポーネントは扱えるので、必要なデータを取得するためのとっかかりとしてこのような集約オブジェクトを用意しました。
/// <summary>
/// (ビヘイビア用)バトル全体の集約モデル
/// </summary>
public class BattleSituation : MonoBehaviour
{
/// 現在の行動ユニットのステータス
[SerializeField] private UnitData status;
public UnitData Actor => status;
/// 現在の行動ユニットのスキル
[SerializeField] private List<SkillData> skills = new List<SkillData>();
/// 自分を除く仲間のステータス
[SerializeField] private List<UnitData> fellows = new List<UnitData>();
/// 敵のステータス
[SerializeField] private List<UnitData> opponents = new List<UnitData>();
public SkillData this[Skill skillId] => skills[(int)skillId];
public UnitData this[int unitId] => (unitId < 0) ? fellows[- unitId - 1] : opponents[unitId];
public List<float> CreateScoresForOpponents(Func<UnitData, float> func)
{
Debug.Log($"Opponents = {opponents.Count}");
return opponents.Select(x => x != null ? func(x) : float.NaN).ToList();
}
}
このクラスは自分/味方/敵のステータスと、現在行動中のユニットのステータス、スキルを持っていて、skillId, unitId で個別に取得できます。
前述のとおり、カスタム型を扱えないので、unitId は負の値で味方、0以上の値で敵を指すように工夫してます。
CreateScoresForOpponents メソッドは前述したカスタムアクションノードから利用するためのユーティリティメソッドです。
グラフ全体の構築
前項までにあげたクラスと、既存のノードを組み合わせて冒頭の画像のようなビヘイビアグラフを作りました。
- Blackboard変数
- Self : 既定のオブジェクト(不使用)
- mainTarget: 選択した攻撃対象のユニットID
- targetScores: ユニットごとの攻撃対象候補としてのスコアのリスト
- Now: 集約モデルオブジェクト
- 開始時の処理
- On Start :規定のエントリポイント
- ReadyEvent を待機し、必要なパラメータが揃ったら処理を開始
- ターゲット選択
- targetScores を Hate(ヘイト値)で初期化
- その中で最も高いターゲットを mainTarget に設定
- 状況に応じた行動決定
- Now(現在のバトル状況)を基に、ActiveSkill1 を使うための SP が十分かを判定
- True(十分ある)なら ActiveSkill1 を mainTarget に対して発動
- False(足りない)なら BasicAttack を mainTarget に対して実行
コード:呼び出し側
呼び出し側のサンプル実装です。
public class GraphRunner : MonoBehaviour
{
public BattleSituation situation;
public BehaviorGraphAgent agent;
public ReadyEvent readyEvent;
public UseSkillEvent useSkillEvent;
private bool _running;
/// フレーム更新を利用して進める
public void RunGraph()
{
useSkillEvent.Event += OnUseSkillEvent;
// TODO: ここでBattleSituationの値(行動ユニットなど)を設定
_running = true;
agent.Start();
readyEvent.SendEventMessage();
}
/// 同一フレーム内で結果を待つ
public void RunImmediate()
{
useSkillEvent.Event += OnUseSkillEvent;
// TODO: ここでBattleSituationの値(行動ユニットなど)を設定
_running = true;
agent.Start();
readyEvent.SendEventMessage();
int n = 0;
while (_running && n++ < 1000)
{
agent.Update();
}
}
private void OnUseSkillEvent(Skill skill, int target)
{
Debug.Log($"Use {skill} with #{target} as main target.");
_running = false;
useSkillEvent.Event -= OnUseSkillEvent;
}
void Update()
{
if (_running)
{
Debug.Log("GraphRunner update");
// フレームごとにビヘイビアツリー更新
// ※コンポーネントとしてアタッチしてるなら不要
agent.Update();
}
}
}
BattleSituationとBehaviourGraphAgentフィールドにはそれぞれシーン上のオブジェクトを、ReadyEventとUseSkillEventにはScriptableObjectをセットして使います。
BehaviorGraphAgent がビヘイビアグラフの主体と言えるオブジェクトですが、Updateメソッドがpublicになっているので、これを明示的に呼べばフレーム更新を待たずに処理を進めることができます。
ただし、グラフに終了条件がないと無限ループになってプログラム自体がハングしてしまう ので、安全のため1000回で強制終了させるようにしてます。
人間が認識できる程度であればよっぽど複雑なフローを組んだとしても、100回もかからないと思うので大丈夫でしょう。というか、そんな複雑な処理は ビヘイビアグラフを使わなかったとしても、処理落ち発生させる代物 と思われます。ロジック自体を見直しましょう。
また、どうしてもそんな複雑な処理が必要になった場合は、間を持たせる画面演出を表示しながら、フレーム更新に合わせて処理を段階的に進められる のもビヘイビアの利点ですね。
実行結果 (デバッグログ)
[14:21:03] Opponents = 3
[14:21:03] Scores: 0.100,0.200,0.000
[14:21:03] SelectMainTargetAction, idx=1, sMax=0.2
[14:21:03] ActiveSkill1 require=1, has=2
[14:21:03] Use ActiveSkill1 with #1 as main target.
- 敵のスコアを計算
- 最大スコアの敵 (idx=1) を選択
- スキル使用判定
- 受け取ったスキル発動イベントの内容
ちなみに、上記はRunImmediateメソッドを呼んだ場合のログです。 RunGraphメソッドを呼ぶと間に数回フレーム更新のログが挟まることを確認できます。
まとめ
Unity Behavior を使ってHate値の高い敵を選び、SPが十分ならアクティブスキルを、そうでないなら通常攻撃を行う簡単な思考ルーチンを組むことができた。
イベントをつかえb指定したタイミングで即座に結果が欲しい場合にも使えることがわかったので、ターン制バトルシステムなどにも使えそう。
ビヘイビアには特定の変数型しか扱えないが、工夫次第でなんとかできると思われる。
作り込めば、 非エンジニアが複雑な敵の思考ルーチンをノーコードで組むことができる ようになるとの期待を持った。
参考記事