[Unity] UnityEditorで自前AIシステムを作った話

  • 61
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

mixiグループアドベントカレンダー4日目になりました!

mixiにインターンとして参加させていただいております, kamasuです。
よろしくお願いします!

社内ではモンスターストライクのクライアント開発を頑張っていますが,
今回はあえて個人で開発しているUnityの話をしたいと思います。

タイトル通り, UnityEditorでAIシステムを作った話をします!

どのようなAIシステムか

AIと言っても色々ありますが, 今回はいわゆるロジカルなAI(ボードゲームのCPUなど)ではなく
条件文によって行動を選択するようなアクションゲームのAIを作りました。

通常時はうろうろしてて
normal.gif

こっちを見つけたりする感じ
chase_2-compressor.gif

要件

このゲームは、一つ前のゲームの続編となっており,
随所で前作の反省を活かす、というのがひとつのテーマとなっていました。

前作でAIシステムを実装した際, 反省すべき点が色々と見つかったので、
今回はそれを踏まえて要件を設定しました。

1.オンライン対応

前作もオンライン対応のゲームだったのですが,
本作もソロプレイ・マルチプレイの2モードに対応します。

そもそもAIロジックをサーバー・クライアントのどちらに入れるか決めなくてなはりません。

サーバー側にAIロジックを入れると,

  • ソロプレイ用にクライアントにもAIロジックを書かなくてはならない
  • ソロとマルチで挙動が変わってしまう可能性がある
  • 1つの敵AIを作るのに, 実装コスト/デバッグコストが2倍になる
  • ゲームロジックがサーバーとクライアントに分散してしまう

というデメリットがあり
かといって、クライアントのうち一人がマスターとなって全敵AIの計算を担当する方式だと

  • 敵の攻撃の当たり判定などで, 誤差が生じる可能性がある
  • マスターが急に落ちた時などの対応が大変

といったデメリットがあります。

今回は多くの敵のAIを作りたいので実装のコストはできるだけ最小限にしたいところです。
そこで今回はクライアントがマスターになる仕様で, デメリットを最小限に抑えるというのが要件となりました。

2.バグを少なくしたい

AIのデバッグは結構大変です。
詳しくは後述しますが, "状態"と"遷移"の数に デバッグの労力は大きく左右されますので,
できるだけそこを抑えれるような設計を目指しました。

3.再利用性を高くしたい

雑魚キャラからボスまで、たくさんの敵が登場します。
それらのAIを効率良く開発するには, アクション単位や思考単位で既存のAIコンポーネントを再利用できると効果的です。

4.非プログラマでも編集できるように

リリース後運用の必要のあるサービスは, ゲーム性もさることながら バランス調整がかなり大切です。
AI自体もバランスに関わってくるので、非プログラマの運用担当者にも簡単に追加/編集のできる設計が好ましいです。

5.とはいえ、特殊な処理もたくさん必要

非プログラマにも編集できるGUIベースのAIシステムは, 単調でつまらないものになりがちです。
共通化できる汎用処理はできるだけ使いまわしつつ,
敵ごとに特徴的な動きや 攻撃ができるようにコードベースでの処理も入れられる設計を目指します。

ステートマシンと木構造

前回のAIシステムが上手くいかなかったのは、
基本構造がステートマシンをベースにしていたからではないかと思いました。

そこで今回は木構造をベースにAIシステムを作ろうと思い立ちました。

※設計に当たってBehaviourTreeというAIシステムを参考にしました。
http://www.slideshare.net/sindharta/behaviour-treeingriffon?ref=http://developer.aiming-inc.com/study/griffon-behaviour-tree/

ステートマシンのデメリット

まずはステートマシンについて, ざっくりどのようなデメリットがあったのかを説明します。

前回のAIシステムがベースにしていたステートマシンというのは, 各ステート同士を遷移(トランジション)によって結合していく構造です。
AIではありませんが, Unity標準装備の「Mechanim」と呼ばれるモーション管理システムはその分かりやすい例です。

モーション管理システム「Mechanim」
15_i4B.png

上図では,どのステートがどのどこに繋がっているか一目瞭然で、
たとえば Jump -> In Air -> Land -> Walk という流れが非常によくわかります。

この直感的な見やすさと理解のしやすさが最大の利点だと思うのですが、
ステートが増えていくと、逆にかなり見にくくなってしまいます。
10 Animation.png

ステートが増えるとトランジション(矢印)の数が爆発的に増えていることがわかります。
全体の構造がわかりにくくなりますし、トランジションすべてに遷移条件を設定する必要がありますので
そのすべての遷移条件が正しいかをチェックしないといけません。

さらにもう一つ, "状態"の欠点があります。
たとえば敵AIに↓のようなステートがあったとします
スクリーンショット 2015-12-02 12.32.10.png

Chaseステートは「どのプレイヤーを追いかけるか」という情報を必要とするとします。
RandomWalkステート中に発見したプレイヤーの情報を, どこかに保存しておき
Chaseステートではその情報を使って「どのプレイヤーを追いかけるか」を判断する
というのが, まず思いつく方法だと思います。

しかしたとえば間違ってこんな繋ぎ方をすると、
Chaseステートでどのプレイヤーを追いかけていいのかわからず、エラーが出てしまいますね
スクリーンショット 2015-12-02 12.32.15.png

繋ぎ方さえ間違えなければいいんですが, 実行時にしかエラーは出ないので 十分間違える余地はあります。
また、バグに直面した時には ステート本体に問題があるのか/繋ぎ方に問題があるのかを確認しなければならず,
これはいちいちプログラマが確認しなければなりません。

また、「どのステートから遷移してきたか」という"状態"に依存している このような設計は
オンライン対応する際にも問題となってきます。

木構造のAIシステム「TreeAI」

さて、ステートマシンベースのAIシステムには前述のような弱点があるわけですが
それを克服するべく設計したのが, 木構造ベースの「TreeAI」となります。

構造

構造はこのようになります。
スクリーンショット 2015-12-02 12.47.05.png

TreeAIはノードの集合によってできています。
末端に位置する子を持たないノードが"アクション"というノードで、ステートマシンにおける「ステート」に対応しています。
それ以外の色のついたノードは(基本的に)どのアクションを選択するかを計算する部分で,
ステートマシンにおける「トランジション」のような働きをしています。

TreeAIが動作しているところです。
treeAI_9.gif

TreeAIの基本アルゴリズム

単純なツリーを作ってみました。
treeAI_10.gif

一番上のノードを"ルート"と呼び, 毎回検索時はルートから検索を始めます。
ルートが自分の子を返し, その子はさらに子を返し ... という風に再起的に子を検索した結果,
末端の「Action」ノードを取得して実行します。
Actionノードの実行が終わったら またルートからの検索を開始します。

今回のルートノードに設定されているのは最も基本的なノードの一つである「Sequence」というノードで、
順番に子を返すという機能を持っています。
Sequenceは初回コール時には1番めの子を返し,次は2番めを返し, 返せる子がなくなったらnullを返します。
そしてその次のコールからはまた1番めの子を返します。

ルートノードから再起的に繰り返した結果, nullが帰ってきた場合は再度検索を行い,
Actionノードの実行が完了すると、次のActionノードをルートから検索し, 実行します。

簡略化したNodeクラスの定義だけ載せておきます。

Node.cs
public class Node {
    public Node[] children;

    public virtual Node Next();
    public virtual void Execute( System.Action callback );

    #if UNITY_EDITOR
    public virtual string Check();
    #endif
}

Next関数は 子ノードのうちひとつを選び出し, その子ノードのNext()を返す関数です。
ノードの種類によって取り出し方が違うので、サブクラスでオーバーライドするようになっています。

たとえば、Sequenceではこのような実装になっています。

Node.cs
public override Node Next (){
    while ( nowIndex < children.Length ){
        var next = children[nowIndex].Next();
        nowIndex ++;
        if (next != null){
            return next;
        }
    }
    nowIndex = 0;
    return null;
}

例外的にActionノードは自分自身を返します。

Action.cs
public override Node Next(){
    return this;
}

入れ子構造にするとこうなります
treeAI_11.gif

TreeAIの使い方

ノードには「Sequence」「Action」のほかにも「Trigger」「Decorator」「Selector」などのノードがあり、
それらを組み合わせてAIを構築していきます。

Trigger

たとえば先ほどのIdle , RandomWalk , Chaseの関係をTreeAIで表現すると、このようになります。
スクリーンショット 2015-12-03 13.21.56.png

SearchPlayerはトリガータイプのノードの一種で, 子を2つだけ持ち,ノード検索時には必ず真下のノードを返します。
そして、下位のアクションが実行中に常にトリガー条件をチェックしており、その条件を満たした瞬間に現在のアクションを中断し,
左下ノードから検索したアクションを実行します。
この例では, RandomWalkを実行中にプレイヤーを見つけたら、ChaseTargetに遷移するようになっています。

トリガーが監視しているのは下位アクションのみなので, たとえばこうすれば
PlayAnimation(Idleモーションを再生するアクション)の間にプレイヤーを見つけても, ChaseTargetに遷移するようにできます。

スクリーンショット 2015-12-02 13.47.06.png

Selector

Selectorは子ノードのうちひとつを選び出すノードタイプです。

たとえばRandomノードは, ランダムに子要素からひとつを選び出します。
treeAI_15.gif

Picker

Pickerは, オブジェクトを拾って下位ノードに渡す働きをするノードタイプです。

たとえばPickupNearestは最も近いプレイヤーオブジェクトを拾い、下位ノードに渡します。
スクリーンショット 2015-12-03 14.17.19.png

この例では, 一番近いプレイヤーを追いかけるAIを示しています。

Decorator

Decoratorは, if文のようなノードタイプです。
条件を満たす場合は子ノードを渡し、満たさない場合はnullを返します。

たとえばPlayerIsOnGroundは, 対象となるプレイヤーが地面についているかどうかを判定します。
スクリーンショット 2015-12-03 14.13.39.png

上図では、対象プレイヤーが地面に着いていれば, 攻撃をする
そうでなければランダムに歩く という動作を示しています。

ちなみに#2のNaturalというノードはSelectorタイプのノードで,
if-elseのような働きをします。
(子ノードを左から順に検査して, nullでないものを返す)

ほかにもあるのですが,
ドキュメントみたいになってしまうのでこの辺りにしておきます!

静的チェック

このTreeAIの大きな利点のひとつに、実行前にある程度AIの妥当性をチェックできることが挙げられます。

たとえば、下のツリーでは、ひとつノードが赤くなっています。
スクリーンショット 2015-12-03 13.53.51.png

左上のエラーメッセージには「Sequenceは最低1つの子ノードが必要」とあります。
Sequenceは子ノードを順番に見ていく仕様ですので、確かに繋ぎ方がおかしいことがわかります。

修正すると、エラーは消えます。
スクリーンショット 2015-12-03 13.55.50.png

このような実行前のチェックがあることで、
AIの構成ミスををかなり回避することができます。

また、このTreeAIは、非プログラマの運用者が作成することを前提としているので
事前のエラーチェックで、大幅にデバッグコストを下げられます。

どのようなチェックがあるのかをざっくりと紹介します。

引数のチェック

「ステートマシン」での例に、こんなものがありました。
スクリーンショット 2015-12-02 12.32.15.png

IdleステートからいきなりChaseに遷移すると、
どのプレイヤーを追っていいのかわからず、"実行時に"エラーになるというものです。

TreeAIで同じような繋ぎ方のミスをした場合
スクリーンショット 2015-12-03 14.28.06.png
ChaseTargetでエラーが検知されています。

エラーメッセージにはPlayerが提供されていないと表示されています。

ChaseTargetをよく見ると, このように「In:Player」と表示されています。
スクリーンショット 2015-12-03 13.26.43.png
これは上位ノードにPlayerオブジェクトを拾うノードを必要とする ということを示しています。

ChaseTargetの上位にPickerなどのPlayerオブジェクトを拾うノードをはさんでみます
スクリーンショット 2015-12-03 14.28.24.png
エラーが消えました。

PickupNearestには「Out:Player」の表示があります。
スクリーンショット 2015-12-03 14.28.29.png

これはPlayerを拾って下位ノードに受け渡していることを示しています。
In表示があるノードを使う場合は, その上位にOut表示のあるノードを挟まないと事前にエラーが出るので,
引数を必要とするアクションを安全に実行できます。

繋ぎ方のチェック

スクリーンショット 2015-12-03 22.38.24.png

末端であるべきアクションノードの下にアクションノードを繋いでしまっています。

コード的にはさきほどのNodeクラスに実装されていたCheckをオーバーライドしています。

Action.cs
public override string Check(){
    if (children.Length != 0){
        return "#"+nodeId + ": Action cannot have children.";
    }else{
        return base.Check();
    }
}

エラーがなければnullを、エラーがあればエラー文字列を返すようにすることで、
最終的にエディター画面にエラーメッセージを出力しています。

パラメータチェック

ノードにはそれぞれパラメータがあります。

スクリーンショット 2015-12-03 23.02.24.png

Typeはパラメータではありませんが、
TimeOutとSpeedはパラメーターです。

この二つは小数を受け取るためのパラメータなのですが、ここでもチェックが行われています。
空だったり、数値が入っていなかったりとかですね。
スクリーンショット 2015-12-03 23.04.43.png

小数パラメータクラスの全文です。

FloatParameter
public class FloatParameter : NodeParameter{
    public override string Check ( ParameterValue param )
    {
        try {
            float.Parse(param.Text);
            return base.Check (param);
        }catch{}
        return "not float value";
    }
    #if UNITY_EDITOR
    public override ParameterValue Draw ( float nowY , EditableNode mNode , EnemyComponent enemy , ParameterValue parameter )
    {
        base.Draw (nowY , mNode , enemy , parameter);
        return new ParameterConst(GUI.TextField( new Rect(96,nowY,100,20) , parameter.Text ));
    }
    #endif
}

ベースクラスで共通部分の処理はできるだけ済まして、
パラメーターの特徴の記述に集中できるようにしてあります。

また、Editorでの表示も, このクラス内に記述することで
できるだけEditor用のコードと実際のコードをまとめて見やすくしています。

さて、このパラメーターチェックはNodeクラスのCheck内で呼ばれています。

Node.cs
public virtual string Check(){
    var paramCheck = GetParameters ().Map<Tuple<ParameterLabel,string>> ( p => {
        var instance = TreeAI.Internal.NodeParameter.Get(p);
        return new Tuple<ParameterLabel,string> (p, instance.Check ( GetParam(p) ));
    }).Filter (p => p.Item2 != null);

    if (paramCheck.Count != 0) {
        return "#" + nodeId + ": Parameter Invalid (" + paramCheck.Map (t => t.Item1.ToString() + " is " + t.Item2).Join (",") + ")";
    } else {
        return null;
    }
}

少し読みづらいですが,
instance.Check ( GetParam(p) )の部分で, 各パラメータそれぞれにCheckを呼んでいます

TreeAIの利点

各アクションへの道筋がただ一つのみ

ステートマシンとは違い, 各アクションへの道筋が唯一ひとつに定まっていることが最大の利点です。
これにより、爆発的なトランジションの増加を防ぐことができますし
あるアクションでバグが起きた場合は, そのアクションか、その上位に位置しているノードのどこかで起きたバグに限定されます。
前回のアクションとは完全に独立しているので, 「どのステートから遷移してきたのか?」ということを考えずに済みます。

安全

細かい部分にもエラーチェックを入れられるので,
運用者がプログラマではなくでもある程度安全にAIの構築ができます。

GUIエディタとコードを混ぜながら書ける

TreeAIでは、ノードタイプはGUI上で選択できます。
スクリーンショット 2015-12-04 10.01.54.png

このノードタイプはコードから自動で検出され リスト化されるので,
たとえばこのようにActionを継承したクラスを書いて

Actions.cs
public class Test : Action{
    public override ListX<ParameterLabel> GetParameters ()
    {
        return new ListX<ParameterLabel> ( ParameterLabel.Duration , ParameterLabel.AttackType );
    }
    public override TargetLabel[] RequiredTargets {
        get {
            return new TargetLabel[]{ TargetLabel.Player };
        }
    }
    protected override void ExecuteStart( ) {
        //do something..
    }
}

コンパイルを待てば, 出てきます!
スクリーンショット 2015-12-04 10.18.12.png

そして, TestクラスのGetParametersやRequireTargetsもそれぞれ反映されていることがわかります。
スクリーンショット 2015-12-04 10.20.47.png

(ほとんど)状態を持たない

毎回ルートから検索するので, ほとんど状態を持ちません。
ツリーが唯一持つ状態は, Sequenceのインデックス(いまどこの子を再生しているか)のみです。
Sequenceでの状態は局所的なものなので, オンライン対応の際に特に共有していなくても
プレイには影響がありません。

オンライン対応

TreeAIでは,ひとりがマスターとなって敵キャラの思考を担当し、他のユーザーには
計算結果である モーション状態, 位置, 向き のみを送信しています。
マスター以外のユーザーはTreeAIは動作していない状態になります。

ただ、攻撃の当たり判定などには, 当たり判定を厳密化する必要があります。
たとえば, 敵の攻撃のタイミングでローリング回避を行ったのに攻撃をうけてしまった とかを防ぐには
敵の攻撃の判定だけはマスターではなく, 実際に攻撃を受ける人がおこなう必要があります。

今回のゲームでは仕様上, 1体の敵が全体に攻撃することがないので
攻撃をうける判定をするのは, 敵に狙われているプレイヤー自身がすればいいことになります。

そこで、TreeAIには DelegateRootというアクションを用意しました。
これは別のツリーへプッシュ的に移れるものです。

SearchPlayerトリガーが反応したらChase用のツリーに移動します。
スクリーンショット 2015-12-03 23.23.33.png

移動先のツリー(exitアクションでもとのツリーに戻る)
スクリーンショット 2015-12-03 23.26.03.png

このとき, DelegateRootに渡したプレイヤーにAIのマスターを"譲る"ことができます。
ある敵が, 別のひとを追いかけ始めたりしたタイミングで, マスターをそのユーザーに譲ることで
追いかけられているユーザー自身がその後の判定を行うことができます。

そのユーザーはまた別のユーザーにマスターを譲り,
いろいろな人でマスターを渡しあいながら処理を行います。

おわりに

長くなりました....
ここまで読んでくださりありがとうございます。
(長いくせに最後まとめきれてない感が...

個人的には, Unityは非常に拡張性が高く, C#もかなりよく設計された汎用性の高い言語だと思っています。
なにより、思ったものが簡潔に素早くかけるのはすごく気分がいいですね。

皆様も是非、UnityEditorなど試してみてはいかがでしょうか!

明日はtacke_jpさんがPostgresについて語ってくださります!
おたのしみに!!!

この投稿は mixiグループ Advent Calendar 20154日目の記事です。