Arbor: State Diagram Editorを試す - 縦STGの敵機を作ってみる

ケットシーウェアさんのArbor: State Diagram Editorというアセットを試食してみたのでその記録。

前置き

Unityで2.5D縦STGを作ろうという実験を半年以上超スローペースで続けているのですが、以前からずーっと困っているのが敵キャラの挙動です。単純な地上敵ならパス+スクリプトで弾を適当に撃つ、ぐらいで済むわけですが、中型機やボスキャラのように手の込んだものが必要となるとスクリプト直書きはちょっときついし増産にも向かない、ということで、ビジュアルスクリプティングでどうにかならんものか、とアレコレしていました。

Arborの前にはド定番のPlayMakerでいくつか作ってみたのですが、

  • ステートにぶら下げるアクションがデフォルトで凄く揃ってる
  • アクションのパラメータを変数で与えられるので外部パラメタの導入もしやすい

その一方で、

  • 全部アクションで書いちゃうと冗長になって後から何やってたのか読めなく
  • ありもののアクションの組み合わせばかりだとステート遷移が増えがちで見通しがドンドン悪くなる
  • かといって新規にアクションを作るのも面倒(独自のお作法が多い)

ということでちょっと使いにくいと感じていたところでした。

ちょうどそこにキャンペーンでArborが試せるということで、試食してみた次第です。

……本当は8月中に上げるはずだったんですが……

導入

アセットストアで購入してインポート。難しい事はありませんが、更新時は必ずArborのフォルダを削除してからインポートするようにと指示があります。
また、1.7.4での話ですがPro Draw call Optimizer Lightと一部クラスが名前衝突していてコンパイルが通らなくなっていました。
Pro Draw call Optimizer Lightを削除すれば問題なし。

試食

下ごしらえ

まず敵機を用意します。

f:id:dnasoftwares:20150905112240p:plain

どこにでもある中型機です。DoGA-L3で組み立ててPLAY Animation Importer for Unityでインポートしました。doga.jp

Unity5にまだ対応してないので、Unity4.6でインポート→スケール調整→まとめてunitypackageに→Unity5に持ち込み と作業します。

で、コンポーネントをペタペタと。オレオレコンポーネントがありますが本筋ではないので下記説明程度で。

f:id:dnasoftwares:20150905112544j:plain

敵機の基本的な動作は専用コンポーネントに分離。Arborで動かすのはあくまで移動と攻撃だけに絞ります。

ステートマシンをつくる

よくある中型機として、出現→攻撃→逃走 の流れを作ります。今回は攻撃1種だけでは単純すぎるのでライフの残量をみて発狂フェイズに移行するようにしました。
先に完成図を挙げます。

f:id:dnasoftwares:20150905125129j:plain

「開始ステート」になっている左上の状態からスタートして、コード内で状態遷移が発生したら矢印の先に進みます。
ステートごとに1つ以上の「挙動」を追加できます。挙動1つ=MonoBehavior1つ みたいなノリです。実際はStateBehaviorというクラスの派生クラスになります。
スクリプトの作成は普通のコード作成同様にプロジェクトビューで。プロジェクトビューの右クリックメニュー、「Create」の内側に「Arbor」が増えており、そこからStateBehaviorの雛形を新規作成できます。
開始ステートはAppearとあるように出現処理です。EnemyMid2Appearというコードをひっつけています。
独自のコントローラを使ってるのでアレですが説明を入れてるのでそういう処理をしていると適当に理解いただければと思います。

EnemyMid2Appear.cs
using UnityEngine;
using System.Collections;
using Arbor;
using DG.Tweening;

public class EnemyMid2Appear : StateBehaviour
{
    //次のステート(エディタで設定する)
    public StateLink NextState;

    //コントローラへの参照
    private EnemyController _ctrl;
    // Use this for initialization
    void Start()
    {
    }

    // Use this for enter state
    public override void OnStateBegin()
    {
        transform.eulerAngles=new Vector3(0,0,0);
        _ctrl = GetComponent<EnemyController>();
        _ctrl.LocalVelocity = new Vector3(0, 0, -58.0f); //この機体に画面下に向けて降りてくるベクトルを設定
        StartCoroutine(StateThread()); //以下コルーチン任せ
    }

    IEnumerator StateThread()
    {
        while (transform.localPosition.z>=80.0f) //この機体が画面上1/4ぐらいのラインに下ってくるまで
            yield return new WaitForFixedUpdate(); //待機
        Transition(NextState); // 次ステートへ遷移
    }

    // Use this for exit state
    public override void OnStateEnd()
    {

    }

    // Update is called once per frame
    void Update()
    {

    }
}

ジャンプ先のステートを格納するpublic StateLink NextState;と実際に遷移を実行するTransition(NextState);がキモかと。
StateLinkの変数を定義するとArbor Editorにリンクとして表示され、次のステートへD&Dの操作で矢印を引けるようになります。

普通にStartCoroutineが呼べるのでかなり書きやすいです。

図で飛び先となっているLoopステートの挙動はこちら。

EnemyMid2Loop.cs
using UnityEngine;
using System.Collections;
using Arbor;
using DG.Tweening;

public class EnemyMid2Loop : StateBehaviour
{
    public StateLink GoMadState;    //発狂モード突入時の飛び先(エディタで設定)
    private EnemyController _ctrl;  //コントローラへの参照
    public GameObject[] Firepoint;  //敵弾の発射地点を示すGameObject(エディタで設定)
    public GameObject BulletObject; //敵弾として吐き出すGameObject(エディタで設定)
    [Range(0,100)]
    public float GoMadLifePercent=30.0f; //発狂モードに突入するライフ%値(0~100)

    private Coroutine _cFireController;

    // Use this for initialization
    void Start () {

    }

    // Use this for enter state
    public override void OnStateBegin() {
        _ctrl = GetComponent<EnemyController>();

        DOTween.To(() => _ctrl.LocalVelocity.z, x => _ctrl.LocalVelocity.z = x,
            0, 1.0f); //前ステートでかかっていたzベクトル(縦方向速度)を1秒かけて0にする
        _cFireController = StartCoroutine(FireControllerThread()); //攻撃制御開始
    }

    private IEnumerator FireControllerThread()
    {
        yield return new WaitForSeconds(0.4f); //最初の1セットの前に0.4秒待つ
        while(true)
        {
            Transform player = GameEnvironment.GetNearestPlayer(transform.position); //自分から近い方のプレイヤーの場所を得る
            for (int i=0;i<3;i++)
            {
                //FirePointそれぞれから……
                foreach(GameObject fp in Firepoint)
                {
                    //プレイヤーに向けて3Way弾を撃つ
                    float angle = DNASoftwares.Unity.MathEx.AimProjected
                        (fp.transform.position, player.position); //近い方のプレイヤーの角度を得る
                    _ctrl.CreateBulletAbsAngle(BulletObject, fp.transform.position, angle, 110); 
                    _ctrl.CreateBulletAbsAngle(BulletObject, fp.transform.position, angle-22.5f, 110);
                    _ctrl.CreateBulletAbsAngle(BulletObject, fp.transform.position, angle+22.5f, 110);
                }
                for (int j = 0; j < 8; j++) yield return new WaitForFixedUpdate(); //8フレームおき
            }
            yield return new WaitForSeconds(1.0f); //1セット撃ったら1秒待つ
        }
    }
    
    // Use this for exit state
    public override void OnStateEnd() {
        //遷移時には今の攻撃スレッドを停止させる
        StopAllCoroutines();
    }
	
    // Update is called once per frame
    void Update () {
        //ライフがしきい値を超えたら(_ctrl,LifePercentは自分の残ライフを百分率で返す)
        if (_ctrl.LifePercent <= GoMadLifePercent)
        {
            //発狂ステートへ
            Transition(GoMadState);
        }
    }

    // このステートの間動き続ける
    void FixedUpdate()
    {
        //横方向にゆらゆら動く動作
        _ctrl.LocalVelocity.x = Mathf.Sin(_ctrl.TimeElapsed/2.0f*Mathf.PI)*10.0f;
    }
}

FixedUpdateも書けば普通に動いてくれます。MonoBehaviorとまったく同じ挙動です。
また、StateLinkに限らずpublic変数はインスペクタ同様に表示・編集できますしアトリビュートもバッチリです。新しい事を覚える必要がほとんどないので取り組みやすいと思います。

発狂後の方はやってること自体は弾撃ってるだけなので本筋と外れてしまうので省略。

常駐ステートで割り込みをかける

一定時間で逃げる、という処理は色々書き方もあるかと思いますが、今回は「常駐ステート」を使ってみることにしました。

図の右下の緑色のステートが「常駐ステート」で、このステートの挙動は文字通り他のステートと別に常駐して動作します。
ここにタイマーを仕込んでおけば出現から一定時間(ここではSeconnds=12なので12秒)経過すると指定ステートに割り込みで遷移してくれるという手はずです。

あくまで今のステートに割り込む形ですので、常駐ステートの先も並列実行になるわけではありません。

逃げる挙動はAppearの逆でしかないので省略。

動かしてみる

実際に上図ステートマシンを動かした様子がこちらのツイートにあります。
オレンジ色のステートがそのとき動いているステートです。


所感

ということで非常に取り組みやすいなと思いました。デフォルトで付属してくる挙動スクリプトの量はPlayMakerには遠く及びませんが、自力でコードが書けるなら大した問題ではありませんし、
MonoBehaviorと同じノリでコードが書けますのでむしろ取り組みやすいと思います。
ステートをまたいだ変数の扱いなどが悩むところですが、専用にコンポーネントを作ればなんとかなるかと思います。RequireComponent属性で紐付けるとかすれば確実でしょう。

今回は手抜きして状態遷移とアクションを全部1スクリプトに押し込んでしまいましたが、状態遷移に関わるスクリプトと、実際になにか行動を起こすためのスクリプトは分割すると使い回しが効いていいと思います。

あと申し上げたいこととしては、チュートリアルが全編動画になっているのが個人的にはツラいです。動画だと自分のペースで進められないのと、Arborの場合はエディタの説明やら常駐ステートの説明などが動画の中に埋まっているため引用とかしづらい……(この記事書くのに困った)
ただリファレンスマニュアルは別途しっかりまとまっているので1回通してしまえば後の苦労は無いかと思います。

定価$95のPlayMakerと比べてArborなら定価$19.99とほぼ1/5のお値段で必要十分な機能が揃えられるので、自分でコードが書ける上で状態遷移の部分をスッキリさせたいという向きには十分にオススメできると思います。