mixiグループアドベントカレンダー4日目になりました!
mixiにインターンとして参加させていただいております, kamasuです。
よろしくお願いします!
社内ではモンスターストライクのクライアント開発を頑張っていますが,
今回はあえて個人で開発しているUnityの話をしたいと思います。
タイトル通り, UnityEditorでAIシステムを作った話をします!
#どのようなAIシステムか
AIと言っても色々ありますが, 今回はいわゆるロジカルなAI(ボードゲームのCPUなど)ではなく
条件文によって行動を選択するようなアクションゲームのAIを作りました。
#要件
このゲームは、一つ前のゲームの続編となっており,
随所で前作の反省を活かす、というのがひとつのテーマとなっていました。
前作で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」と呼ばれるモーション管理システムはその分かりやすい例です。
上図では,どのステートがどのどこに繋がっているか一目瞭然で、
たとえば Jump -> In Air -> Land -> Walk という流れが非常によくわかります。
この直感的な見やすさと理解のしやすさが最大の利点だと思うのですが、
ステートが増えていくと、逆にかなり見にくくなってしまいます。
ステートが増えるとトランジション(矢印)の数が爆発的に増えていることがわかります。
全体の構造がわかりにくくなりますし、トランジションすべてに遷移条件を設定する必要がありますので
そのすべての遷移条件が正しいかをチェックしないといけません。
さらにもう一つ, "状態"の欠点があります。
たとえば敵AIに↓のようなステートがあったとします
Chaseステートは「どのプレイヤーを追いかけるか」という情報を必要とするとします。
RandomWalkステート中に発見したプレイヤーの情報を, どこかに保存しておき
Chaseステートではその情報を使って「どのプレイヤーを追いかけるか」を判断する
というのが, まず思いつく方法だと思います。
しかしたとえば間違ってこんな繋ぎ方をすると、
Chaseステートでどのプレイヤーを追いかけていいのかわからず、エラーが出てしまいますね
繋ぎ方さえ間違えなければいいんですが, 実行時にしかエラーは出ないので 十分間違える余地はあります。
また、バグに直面した時には ステート本体に問題があるのか/繋ぎ方に問題があるのかを確認しなければならず,
これはいちいちプログラマが確認しなければなりません。
また、「どのステートから遷移してきたか」という"状態"に依存している このような設計は
オンライン対応する際にも問題となってきます。
木構造のAIシステム「TreeAI」
さて、ステートマシンベースのAIシステムには前述のような弱点があるわけですが
それを克服するべく設計したのが, 木構造ベースの「TreeAI」となります。
構造
TreeAIはノードの集合によってできています。
末端に位置する子を持たないノードが"アクション"というノードで、ステートマシンにおける「ステート」に対応しています。
それ以外の色のついたノードは(基本的に)どのアクションを選択するかを計算する部分で,
ステートマシンにおける「トランジション」のような働きをしています。
TreeAIの基本アルゴリズム
一番上のノードを"ルート"と呼び, 毎回検索時はルートから検索を始めます。
ルートが自分の子を返し, その子はさらに子を返し ... という風に再起的に子を検索した結果,
末端の「Action」ノードを取得して実行します。
Actionノードの実行が終わったら またルートからの検索を開始します。
今回のルートノードに設定されているのは最も基本的なノードの一つである「Sequence」というノードで、
順番に子を返すという機能を持っています。
Sequenceは初回コール時には1番めの子を返し,次は2番めを返し, 返せる子がなくなったらnullを返します。
そしてその次のコールからはまた1番めの子を返します。
ルートノードから再起的に繰り返した結果, nullが帰ってきた場合は再度検索を行い,
Actionノードの実行が完了すると、次のActionノードをルートから検索し, 実行します。
簡略化したNodeクラスの定義だけ載せておきます。
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ではこのような実装になっています。
public override Node Next (){
while ( nowIndex < children.Length ){
var next = children[nowIndex].Next();
nowIndex ++;
if (next != null){
return next;
}
}
nowIndex = 0;
return null;
}
例外的にActionノードは自分自身を返します。
public override Node Next(){
return this;
}
TreeAIの使い方
ノードには「Sequence」「Action」のほかにも「Trigger」「Decorator」「Selector」などのノードがあり、
それらを組み合わせてAIを構築していきます。
Trigger
たとえば先ほどのIdle , RandomWalk , Chaseの関係をTreeAIで表現すると、このようになります。
SearchPlayerはトリガータイプのノードの一種で, 子を2つだけ持ち,ノード検索時には必ず真下のノードを返します。
そして、下位のアクションが実行中に常にトリガー条件をチェックしており、その条件を満たした瞬間に現在のアクションを中断し,
左下ノードから検索したアクションを実行します。
この例では, RandomWalkを実行中にプレイヤーを見つけたら、ChaseTargetに遷移するようになっています。
トリガーが監視しているのは下位アクションのみなので, たとえばこうすれば
PlayAnimation(Idleモーションを再生するアクション)の間にプレイヤーを見つけても, ChaseTargetに遷移するようにできます。
Selector
Selectorは子ノードのうちひとつを選び出すノードタイプです。
たとえばRandomノードは, ランダムに子要素からひとつを選び出します。
Picker
Pickerは, オブジェクトを拾って下位ノードに渡す働きをするノードタイプです。
たとえばPickupNearestは最も近いプレイヤーオブジェクトを拾い、下位ノードに渡します。
この例では, 一番近いプレイヤーを追いかけるAIを示しています。
Decorator
Decoratorは, if文のようなノードタイプです。
条件を満たす場合は子ノードを渡し、満たさない場合はnullを返します。
たとえばPlayerIsOnGroundは, 対象となるプレイヤーが地面についているかどうかを判定します。
上図では、対象プレイヤーが地面に着いていれば, 攻撃をする
そうでなければランダムに歩く という動作を示しています。
ちなみに#2のNaturalというノードはSelectorタイプのノードで,
if-elseのような働きをします。
(子ノードを左から順に検査して, nullでないものを返す)
ほかにもあるのですが,
ドキュメントみたいになってしまうのでこの辺りにしておきます!
#静的チェック
このTreeAIの大きな利点のひとつに、実行前にある程度AIの妥当性をチェックできることが挙げられます。
左上のエラーメッセージには「Sequenceは最低1つの子ノードが必要」とあります。
Sequenceは子ノードを順番に見ていく仕様ですので、確かに繋ぎ方がおかしいことがわかります。
このような実行前のチェックがあることで、
AIの構成ミスををかなり回避することができます。
また、このTreeAIは、非プログラマの運用者が作成することを前提としているので
事前のエラーチェックで、大幅にデバッグコストを下げられます。
どのようなチェックがあるのかをざっくりと紹介します。
引数のチェック
IdleステートからいきなりChaseに遷移すると、
どのプレイヤーを追っていいのかわからず、"実行時に"エラーになるというものです。
TreeAIで同じような繋ぎ方のミスをした場合
ChaseTargetでエラーが検知されています。
エラーメッセージにはPlayerが提供されていないと表示されています。
ChaseTargetをよく見ると, このように「In:Player」と表示されています。
これは上位ノードにPlayerオブジェクトを拾うノードを必要とする ということを示しています。
ChaseTargetの上位にPickerなどのPlayerオブジェクトを拾うノードをはさんでみます
エラーが消えました。
PickupNearestには「Out:Player」の表示があります。
これはPlayerを拾って下位ノードに受け渡していることを示しています。
In表示があるノードを使う場合は, その上位にOut表示のあるノードを挟まないと事前にエラーが出るので,
引数を必要とするアクションを安全に実行できます。
繋ぎ方のチェック
末端であるべきアクションノードの下にアクションノードを繋いでしまっています。
コード的にはさきほどのNodeクラスに実装されていたCheckをオーバーライドしています。
public override string Check(){
if (children.Length != 0){
return "#"+nodeId + ": Action cannot have children.";
}else{
return base.Check();
}
}
エラーがなければnullを、エラーがあればエラー文字列を返すようにすることで、
最終的にエディター画面にエラーメッセージを出力しています。
パラメータチェック
ノードにはそれぞれパラメータがあります。
Typeはパラメータではありませんが、
TimeOutとSpeedはパラメーターです。
この二つは小数を受け取るためのパラメータなのですが、ここでもチェックが行われています。
空だったり、数値が入っていなかったりとかですね。
小数パラメータクラスの全文です。
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内で呼ばれています。
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エディタとコードを混ぜながら書ける
このノードタイプはコードから自動で検出され リスト化されるので,
たとえばこのようにActionを継承したクラスを書いて
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..
}
}
そして, TestクラスのGetParametersやRequireTargetsもそれぞれ反映されていることがわかります。
(ほとんど)状態を持たない
毎回ルートから検索するので, ほとんど状態を持ちません。
ツリーが唯一持つ状態は, Sequenceのインデックス(いまどこの子を再生しているか)のみです。
Sequenceでの状態は局所的なものなので, オンライン対応の際に特に共有していなくても
プレイには影響がありません。
オンライン対応
TreeAIでは,ひとりがマスターとなって敵キャラの思考を担当し、他のユーザーには
計算結果である モーション状態, 位置, 向き のみを送信しています。
マスター以外のユーザーはTreeAIは動作していない状態になります。
ただ、攻撃の当たり判定などには, 当たり判定を厳密化する必要があります。
たとえば, 敵の攻撃のタイミングでローリング回避を行ったのに攻撃をうけてしまった とかを防ぐには
敵の攻撃の判定だけはマスターではなく, 実際に攻撃を受ける人がおこなう必要があります。
今回のゲームでは仕様上, 1体の敵が全体に攻撃することがないので
攻撃をうける判定をするのは, 敵に狙われているプレイヤー自身がすればいいことになります。
そこで、TreeAIには DelegateRootというアクションを用意しました。
これは別のツリーへプッシュ的に移れるものです。
SearchPlayerトリガーが反応したらChase用のツリーに移動します。
このとき, DelegateRootに渡したプレイヤーにAIのマスターを"譲る"ことができます。
ある敵が, 別のひとを追いかけ始めたりしたタイミングで, マスターをそのユーザーに譲ることで
追いかけられているユーザー自身がその後の判定を行うことができます。
そのユーザーはまた別のユーザーにマスターを譲り,
いろいろな人でマスターを渡しあいながら処理を行います。
おわりに
長くなりました....
ここまで読んでくださりありがとうございます。
(長いくせに最後まとめきれてない感が...
個人的には, Unityは非常に拡張性が高く, C#もかなりよく設計された汎用性の高い言語だと思っています。
なにより、思ったものが簡潔に素早くかけるのはすごく気分がいいですね。
皆様も是非、UnityEditorなど試してみてはいかがでしょうか!
明日はtacke_jpさんがPostgresについて語ってくださります!
おたのしみに!!!