e.blog

主にUnity/UE周りのことについてまとめていきます

MenuItemでVR Supportedを切り替える

概要

やってることはただのMenuItemのEditorスクリプトですが。
UNETなどネットワーク対応のコンテンツを作っていると、複数のエディタを立ち上げてネットワーク経由でのやり取りをデバッグしたくなることが多々あります。

(ちなみに、同一プロジェクトを複数エディタで機動する方法がテラシュールブログさんで紹介されています。これを使うとネットワーク対応のコンテンツのデバッグが捗ります)

tsubakit1.hateblo.jp

ただ、VRコンテンツを制作しているとたんにエディタで同一プロジェクトを起動しただけではうまく行きません。
というのも、HMDを利用できるのはひとつのアプリだけなので、あとから再生したエディタのほうにHMDが奪われてしまい、最初に再生していたエディタは強制的に再生がストップしてしまいます。

なので、Player SettingsのVirtual Reality Supportedのチェックをオフにすれば問題は解消されますが、そのオン・オフが地味にめんどい。
ということで、今回のスクリプトを書くに至ったわけです。

これをEditorフォルダにおけば、あとはToolsメニューからオン・オフが手軽に行えるようになります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

[InitializeOnLoad]
public static class VirtualRealitySupportedMenu
{
    private const string MENU_NAME = "Tools/VirtualSupported";

    static VirtualRealitySupportedMenu()
    {
        EditorApplication.update += () =>
        {
            ImplToggleSupported(PlayerSettings.virtualRealitySupported);
        };
    }

    [MenuItem(MENU_NAME)]
    static void ToggleSupported()
    {
        ImplToggleSupported(!PlayerSettings.virtualRealitySupported);
    }

    static void ImplToggleSupported(bool enabled)
    {
        // Switch VR enable setting.
        PlayerSettings.virtualRealitySupported = enabled;

        // Set checkmark to menu item.
        Menu.SetChecked(MENU_NAME, enabled);
    }
}

VR空間内をデバッグできるようにマウスで移動する

概要

VR開発をしていると、HMDをかぶるのが意外と手間になります。
そこで、マウスで位置や回転を制御できると便利です。

ということで、カメラに適用しておくとさっと動かせるスクリプトを残しておきます。
こんな感じ↓

using UnityEngine;
using System.Collections;

/// <summary>
/// マウスでカメラの位置、回転を制御する
/// </summary>
public class MouseCameraController : MonoBehaviour
{
    enum DragType
    {
        Move,
        Rotate,
    }

    private bool _isDragging = false;
    private Vector3 _prevPos = Vector3.zero;
    private DragType _currenType;
    private float _speedLimit = 100f;
    private Quaternion _originalRot;

    private float _x = 0f;
    private float _y = 0f;

    [SerializeField][Range(0f, 10f)]
    private float _moveSpeed = 5f;

    [SerializeField][Range(0f, 10f)]
    private float _rotateSpeed = 5f;

    [SerializeField]
    private Transform _controlTarget;
    private Transform ControlTarget
    {
        get
        {
            if (!_controlTarget)
            {
                _controlTarget = transform;
            }

            return _controlTarget;
        }
    }


    #region MonoBehaviour
    void Start()
    {
        _originalRot = ControlTarget.rotation;
    }
    
    void Update()
    {
        float wheelval = Input.GetAxis("Mouse ScrollWheel");

        Vector3 pos = ControlTarget.position;
        pos += ControlTarget.forward * wheelval * 2f;
        ControlTarget.position = pos;

        if (Input.GetMouseButtonDown(0))
        {
            OnMouseDown(DragType.Move);
        }

        if (Input.GetMouseButtonDown(1))
        {
            OnMouseDown(DragType.Rotate);
        }

        if (Input.GetMouseButtonUp(0) || Input.GetMouseButtonUp(1))
        {
            OnMouseUp();
        }

        OnMouseMove();
    }
    #endregion


    void OnMouseDown(DragType type)
    {
        _isDragging = true;
        _currenType = type;
        _prevPos = Input.mousePosition;
    }

    void OnMouseUp()
    {
        _isDragging = false;
    }

    void OnMouseMove()
    {
        if (!_isDragging)
        {
            return;
        }

        Vector3 delta = Input.mousePosition - _prevPos;
        _prevPos = Input.mousePosition;

        switch (_currenType)
        {
            case DragType.Move:
                delta *= (_moveSpeed / _speedLimit);

                Vector3 pos = ControlTarget.position;
                pos += ControlTarget.up * -delta.y;
                pos += ControlTarget.right * delta.x;

                ControlTarget.position = pos;

                return;

            case DragType.Rotate:
                delta *= (_rotateSpeed / _speedLimit);

                _x += delta.x;
                if (_x <= -180)
                {
                    _x += 360;
                }
                else if (_x > 180)
                {
                    _x -= 360;
                }

                _y -= delta.y;
                _y = Mathf.Clamp(_y, -85f, 85f);

                ControlTarget.rotation = _originalRot * Quaternion.Euler(_y, _x, 0f);

                return;
        }

    }
}

VRモードをオフにして起動するバッチファイルを作る

概要

VRコンテンツのネットワーク対応をしていると、ビルドしてexeファイルとエディタのふたつでデバッグするときがあるんですが、VRモードを双方ともオンにしているとあとから起動したほうが終了されてしまいます。
HMDはひとつのアプリでしか使えない)

ただ、毎回VRサポートをオフにしたりオンにしたり、とかしているのはだいぶめんどくさいので、なんとかできないかなと調べました。
結論から言うと、コマンドライン引数でオフにすることができたのでそれを簡単にするためのバッチファイル作成用スクリプトを書きました。(大層なもんじゃないけど)

VRモードをオフにする

Unityから書き出したexeファイルに、コマンドライン引数で-vrmodeを指定するとどのVRモードかを選択することができます。
ここで、-vrmode Noneを与えるとVRモードをオフにすることができます。
(これを利用するには多分、Build settingsのVR SupportedでNoneを追加しておく必要があるはず)

バッチファイルを自動生成する

さて、コマンドライン引数で与えてVRモードをオフにすることができるのが分かりましたが、毎回それを実行するのもそれはそれで面倒です。
あくまでデバッグ目的のものなので、簡単なバッチファイルを生成して簡単にしておくといいかなと思ったので、そのスクリプトです。

コード

using UnityEditor;
using UnityEditor.Callbacks;
using System.Collections;
using System.IO;

public static class CreateBatchFile
{
    [PostProcessBuild(100)]
    public static void OnPostProcessBuild(BuildTarget target, string path)
    {
        string directory = Path.GetDirectoryName(path);
        string filePath = Path.Combine(directory, "launchWithoutHMD.bat");

        string exeName = Path.GetFileName(path);
        using (StreamWriter writer = File.CreateText(filePath))
        {
            writer.Write("start ./" + exeName + " -vrmode None\nexit /B");
        }
    }
}

こんな感じでPostProcess用のスクリプトを書いて、Editorフォルダに突っ込んでおけば自動的にバッチファイルを生成してくれます。

しかし、VR開発していてネットワーク対応させようとすると1台のPCでやるのはだいぶきびしい感じですね( ;´Д`)
(VR ReadyなノートPCがほしい・・)

カスタムエディタを使ってシーン内にハンドルを表示する

概要

スクリプトをアタッチして、インスペクタからVector3型の値を操作する、というケースはままあるでしょう。
インスペクタから設定する場合、XYZそれぞれの値を手入力で入力して設定していくことになりますが、位置の調整だったり、回転だったり、といったものに利用する値の場合、どうしても細かい数値で調整する必要があります。

すると問題になるのが、「微調整のやりづらさ」。
しかも位置合わせの目的でVector3を用いている場合は、いちいち実行したりして確認する必要があってとてもめんどくさい作業になりがちです。

今回はそんなケースで利用できるカスタムエディタの仕組みについて書きます。

f:id:edo_m18:20161130134028p:plain
↑左の小さいCubeに見えるものはカスタムエディタの機能で、位置を分かりやすく表示しているだけで、GameObjectではありません。

f:id:edo_m18:20161130134031p:plain
↑インスペクタの表記。PositionとRotationを設定する、という想定。

位置と回転を視覚的に表現する

カスタムエディタの中で、以下の機能を利用することで画像のような機能を提供することができるようになります。

  • Handles.PositionHandle
  • Handles.RotationHandle

利用するには以下のように記述します。

var newPos = Handles.PositionHandle(pos, rot);
var posChanges = EditorGUI.EndChangeCheck();

ちなみに上記の処理はOnSceneGUIメソッド内で実行します。

コード自体はシンプルなので、すべて見てもらったほうが分かりやすいと思います。

コード

using UnityEngine;
using System.Collections;

[ExecuteInEditMode]
public class ViewTest : MonoBehaviour
{
    [SerializeField]
    Vector3 _position;

    [SerializeField]
    Vector3 _rotation;

    public Vector3 Position{ get { return _position; } set { _position = value; } }
    public Quaternion Rotation { get { return Quaternion.Euler(_rotation); } set { _rotation = value.eulerAngles; } }
}
using UnityEngine;
using UnityEditor;
using System.Collections;

[CustomEditor(typeof(ViewTest))]
public class ViewTestEditor : Editor
{
    protected virtual void OnSceneGUI()
    {
        var script = target as ViewTest;

        var pos = script.Position;
        var rot = script.Rotation;

        EditorGUI.BeginChangeCheck();
        var newPos = Handles.PositionHandle(pos, rot);
        var posChanges = EditorGUI.EndChangeCheck();

        EditorGUI.BeginChangeCheck();
        var newRot = Handles.RotationHandle(rot, pos);
        var rotChanges = EditorGUI.EndChangeCheck();

        Handles.CubeCap(0, pos, rot, 0.2f);

        if (posChanges || rotChanges)
        {
            if (posChanges)
            {
                script.Position = newPos;
            }
            if (rotChanges)
            {
                script.Rotation = newRot;
            }
        }
    }
}

Standard Assetsの「GlassStainedBumpDistort」シェーダを覗いてみた

概要

「Standard Assets」に含まれている「GlassStainedBumpDistort」を覗いてみました。
どういうシェーダかというと、以下のように、オブジェクトの背面を歪ませる効果を実現するものです。

準備するのは歪ませるための法線を持たせたBumpMap用のテクスチャだけなので比較的簡単に利用できます。
(実装もそんなに複雑ではないので色々参考になりそう)

頂点シェーダ

まずは頂点シェーダ。

v2f vert (appdata_t v)
{
    v2f o;
    o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
    #if UNITY_UV_STARTS_AT_TOP
    float scale = -1.0;
    #else
    float scale = 1.0;
    #endif
    o.uvgrab.xy = (float2(o.vertex.x, o.vertex.y*scale) + o.vertex.w) * 0.5;
    o.uvgrab.zw = o.vertex.zw;
    o.uvbump = TRANSFORM_TEX( v.texcoord, _BumpMap );
    o.uvmain = TRANSFORM_TEX( v.texcoord, _MainTex );
    UNITY_TRANSFER_FOG(o,o.vertex);
    return o;
}

UNITY_UV_STARTS_AT_TOPは、プラットフォームごとに異なるテクスチャ座標系を適切に取り扱うために利用します。
ドキュメントから引用すると以下になります。

Render Texture の座標

垂直方向のテクスチャ座標の表現方法は、Direct3DOpenGL のプラットフォームで異なります。

  • Direct3D、Metal、コンソールでは最上部が 0 の座標位置となり、下方向に行くにしたがって増加します。
  • OpenGLOpenGL ES では、最下部が 0 の座標位置となり、上方向に行くにしたがって増加します。

ほとんどの場合に影響はありませんが、レンダーテクスチャ に対してレンダリングする場合は影響があります。この場合、Unity は OpenGL 以外でレンダリングするときに意図的にレンダリングを上下逆に反転するので、プラットフォーム間のルールは同じままです。シェーダーで処理する必要のある一般的な例は、イメージエフェクトと、UV空間のレンダリングです。

docs.unity3d.com

テクスチャ座標を計算

このシェーダでは、前のパスでレンダリングされた結果をキャプチャし、それを利用して後ろを「透過させているように」見せているため、オブジェクトの後ろを表すテクスチャから色をフェッチしないとなりません。
そのため、オブジェクトのある位置を元に、「後ろの映像のテクスチャ」のUV座標を求める必要があります。

それを行っているのが以下の処理です。

o.uvgrab.xy = (float2(o.vertex.x, o.vertex.y*scale) + o.vertex.w) * 0.5;

w は同次座標系で利用するものですね。それを足して、さらに0.5倍しています。
最初これはなにをしているのだろうと思ったのですが、前述のように、オブジェクトの後ろ側のテクスチャの色を適切にフェッチするために正規化している、というわけです。

具体的には、クリップ座標系に変換された時点の x, y の値は -w ~ w の範囲に変換されます。つまり、それに対して w を足すということは 0 ~ 2w の範囲に変換することと同義です。そしてそれを半分( * 0.5 )することで 0 ~ w の範囲にします。

そしてフラグメントシェーダのタイミングでその値をさらに w で割ることで、結果的に 0 ~ 1 の範囲に変換している、というわけです。

詳細は後述しますが、続くフラグメントシェーダでは以下のように色をフェッチしています。

half4 col = tex2Dproj( _GrabTexture, UNITY_PROJ_COORD(i.uvgrab));

こちらの射影テクスチャリングの記事を読むとイメージしやすいかもしれません。(射影空間からテクスチャ座標に変換)

NOTE:
tex2Dprojは、該当オブジェクトにテクスチャを投影するような形でテクセルをフェッチします。
つまり、同次座標系で見た場合に、該当のテクセルがどうなるか、を計算しているわけです。

具体的には、以下のように自前で計算することでも同じ結果を得ることができます。

float2 uv = i.uvgrab.xy / i.uvgrab.w;
half4 col = tex2D(_GrabTexture, uv);

要は、Z方向の膨らみを正規化することで2D平面(ディスプレイ)のどの位置に、該当オブジェクトのピクセルがくるのか、を計算しているわけですね。

フラグメントシェーダ

続くフラグメントシェーダ。法線(ノーマルマップ)からフェッチするUVのオフセットを計算し、キャプチャしたテクスチャから色をフェッチしています。

half4 frag (v2f i) : SV_Target
{
    // calculate perturbed coordinates
    half2 bump = UnpackNormal(tex2D( _BumpMap, i.uvbump )).rg; // we could optimize this by just reading the x & y without reconstructing the Z
    float2 offset = bump * _BumpAmt * _GrabTexture_TexelSize.xy;
    i.uvgrab.xy = offset * i.uvgrab.z + i.uvgrab.xy;
    
    half4 col = tex2Dproj( _GrabTexture, UNITY_PROJ_COORD(i.uvgrab));
    half4 tint = tex2D(_MainTex, i.uvmain);
    col *= tint;
    UNITY_APPLY_FOG(i.fogCoord, col);
    return col;
}

ノーマルマップからフェッチするUV座標にオフセットを適用しています。これが歪みを実現している箇所ですね。

そしてtex2Dprojを使っているのは、このシェーダが適用されたオブジェクトに、キャプチャした映像のテクスチャを「投影している」と考えるとイメージしやすいかと思います。
なので頂点シェーダでテクスチャ座標を計算していたんですね。

前に書いた以下の記事も参考になるかもしれないので貼っておきます。

ゴール駆動型エージェントの実装(実装編)

概要

ゴール駆動型エージェントの実装(概念編)」の続編です。
こちらでは、前回書いた概念を元に、実際にサンプルプロジェクトで実装した内容を解説していこうと思います。

※ 前回も書きましたが、あくまで書籍を読んで実際に自分が実装したものの解説です。そのため、理論など勘違い・間違いを含んでいる可能性が多分にあるため、あくまで実装の一例として御覧ください。

はじめに

さて、実際に実装について書いていく前に、イメージしやすくするための前提を書こうと思います。

まずはコードから読みたいよ! って人は下に書いてある実装解説から読んでください。

ゴール駆動型はStateパターンに似ている

実際に実装してみて感じたのは、いわゆるデザインパターンで言うところの「Stateパターン」にとても似ているな、ということ。

Stateパターンてなんぞ? という人はWikipediaなどを参考にしてもらいたいですが、ざっくり言うと、「State(状態)」をひとつのクラスとして定義し、そのインスタンスを差し替えることで状態による振る舞いの違いを表現する、というものです。

個人的なイメージはカートリッジを差し替えるとプログラムが変わる、みたいな感じですね。
(Aというカートリッジを挿すと、Aボタンはジャンプだけど、Bというカートリッジを挿すと同じAボタンでもしゃがむ、とか変化する)

「ゴール」が持つメソッドは3つ

どのあたりが似ていると感じたかと言うと、StateパターンではEnterExecuteExitという3つのメソッドを実装し、「とある状態に入ったとき」「とある状態にある間中」「とある状態から抜けるとき」を表します。
新しい状態クラスを作って、それぞれのメソッドをオーバーライドすることで状態が変化する際と「とある状態にある間中」に実行される内容を柔軟に切り替える/実行することができます。

そして、ゴール駆動型のGoalクラスも似たような3つのメソッドを持ちます。

  • Activate
  • Process
  • Terminate

の3つです。

基本的なイメージはそれぞれEnterExecuteExitと同様です。

ただ一点異なる点としては、Stateパターンの場合はEnterが呼ばれるのは状態が変化した際の1度きりなのに対し、ゴール駆動型ではActivateが(ゴールによっては)何度も呼ばれる可能性がある、という点です。

Activate

アクティブ化。ゴールが非アクティブ状態から実行された場合に起動される。前提処理やサブゴールのプランニングなど、ゴール実行開始時に必要な処理を行います。

Process

ゴールのメイン処理。
例えば「プレイヤーに近づく」というゴールの場合、ゲームループ中に毎フレームごとにProcessは実行され、その都度、プレイヤーの位置の補足と追従を実行したり、といったことを行います。

Terminate

ゴール「終了」時の後処理。あくまで終了時で、ゴールが成功したか失敗したかに関わらず、必ず実行されます。
Activate時に準備した内容を破棄したりなど、後処理が必要な場合にオーバーライドします。

ゴールは「失敗」することがある

Stateパターンの「状態」は、まさに状態そのものでそれに失敗も成功もありません。
状態が変化したら振る舞いがどうなるか、だけを定義します。

一方、ゴール駆動型のGoalクラスでは、内容は振る舞いを定義していますが、目的は振る舞いではなく、あくまでゴールに到達することです。
つまり、ゴールに向かうために取った行動が「振る舞い」として見えているに過ぎません。

そして大事な点として、ゴールは「失敗する」可能性がある、ということです。
具体例を上げてみると、「とある地点まで移動する」というゴールがあったとしましょう。
なにもない真っ平らな状態なら失敗することはありません。しかし、ほぼすべてのゲームにおいてそんな状況はあり得ません。

実際のゲームであれば道があったり、壁があったり障害物があったり。
なにかしら「とある地点」までの間に、到達不可能になる要因があるものです。

さて、実際にAIに「この地点まで行け」とゴールを設定して、一歩を踏み出したとしましょう。
最初は順調にその地点に向かって歩いています。

ところが途中で扉が閉まるなどして、どうやっても目的地にたどり着けないことが検知された場合どうでしょうか。
もはやそのゴールは達成不可能です。

これが「失敗することもある」ということの理由です。

図にすると下のようなイメージです。

ゴールを階層的に定義したイメージ図

f:id:edo_m18:20161126112722p:plain

(図での)最終ゴールである「直接攻撃」に失敗した場合は、その前の「プレイヤーに近づく」に戻る

f:id:edo_m18:20161126113413p:plain

失敗したら再プランニング

個人的な感想を書くと、ゴール駆動型の実装を読んだときに「なんてよくできているんだ」と思いました。

ゴールは失敗する前提になっていて、対象のゴールが失敗したら、より上位のゴール(親ゴール)に処理が戻されます。(思い出してください。ゴールはサブゴールの集合です)

そして親ゴールでもし再プランニングすることができるならここで再プランニングされます。
(例えば目的地まで移動するゴールが失敗した場合は、別の候補から別の目的地を決める、など)

もし親ゴールが再プランニングできない場合はさらに親ゴールに処理が戻ります。
そしてこのゴールの失敗の連鎖が続き、ルートゴールまで戻されると、ルートゴールは(前回書いたように)Brainクラスが担当しています。

Brainクラスは現在のAIの置かれている状況を把握し、プランナークラスからプランをもらってゴールを決定している中枢的存在です。
つまりここまで処理が戻る、ということはAIの状況になにかしらの変化が起きたことを意味しています。
結果として、Brainクラスは現在の状況からさらに最適なゴールを見つけ出すために動き出します。

これを繰り返すことで、まるでAIを、人が操作しているかのように柔軟な挙動を取らせることができる、というわけなのです。

前述の図を用いて表すと、例えばプレイヤーに直接攻撃が失敗した場合、遠距離攻撃を模索する、というような場合は以下のようになります。

f:id:edo_m18:20161126114112p:plain

ゴールは常に評価、更新される

先ほどからゴールの失敗やプランニング、という言葉を何度も使いましたが、これらはいつ、どこで行われるのでしょうか?
答えを先に書いてしまうと、「毎フレーム」です。

このあたりもStateパターンと似ていると思っている部分ですが、各ゴールは毎フレームごとに評価されます。
そのメソッドが前述したProcessメソッドです。

Processメソッドは毎フレーム(UnityのUpdateメソッドとほぼ同じタイミング)で呼び出され、常にゴールの状態を評価します。
そして「ゴールが失敗した」と判断すると、ゴールを「失敗状態」にし、親に処理を戻します。
そして次のループ処理の中で親ゴールが評価され、その後は前述のフローを辿って行く、という流れです。

ゴールは常に変化する

さて、感の言い方であればピンときたかもしれませんが、ゴールが常に評価される、ということは場合によってはゴールが突然変わる場合もありえます。
というか、なにかしらゲームをやっていることを想像してもらえば分かると思いますが、人の操作は常に変化していきます。
アクションゲームであれば、突然出てきた敵を迎撃するために立ち止まる、という選択を取るかもしれません。
RPGなどのコマンド式のゲームであっても、次のターンにはこうしようと思っていたが敵からの思わぬ攻撃のためにやむなく回復を優先した、なんてこともあるでしょう。

つまり、ゴールは常に変化し、状況に応じて柔軟に変化しなければならない、ということです。
むしろ柔軟に変化しないならそれはAIとは呼べませんね。

こうした、ゴールベースで行動を定義し、かつファジーな判断でリアルタイムにゴールを変更していくことで、AIはまるで生きているかのように振る舞うことができるのです。

f:id:edo_m18:20161126115847p:plain

また、ゴール自体が差し替えられずとも、現在実行しているゴールに差し込まれる形で別のゴールが設定されることもあります。

f:id:edo_m18:20161126120418p:plain

ゴールは状態を持つ

上で「失敗状態にし」と書きましたが、ゴールも「状態」を持ちます。
Stateパターンに似ていると書いたのに、ゴール自体にも「状態」があると書くと混乱するかもしれませんが、今目的としているゴールがどんな状態か、が確認できることはとても有用です。(Stateパターンの状態とは意味が異なるので注意してください)

前述のように、ゴールが達成困難になった場合は「失敗」と見なす必要があり、そうした「状態」が必要になってきます。

ゴールが持つ状態は4つ

ゴールクラスは inactive, active, completed, failed の4つの状態を持ちます。

inactive

非アクティブ状態。まだゴールが開始されていない状態です。
もし非アクティブ状態のゴールが「実行されるべき」状態となった場合は、前述の Activate メソッドを呼び出し、アクティブな状態に変化させます。

active

前述の inactive から、現在処理中を示すアクティブな状態へ変化したものが active です。
ゴール未達でかつ、まだ達成できる見込みがある場合は常に active 状態となります。

completed

完了状態。ゴールしたことを親ゴールに伝えます。

failed

ゴール失敗。前述の例で言えば「目的地に到達不可能になった状態」ですね。
失敗した場合は親ゴールにそれが伝わり、ゴールを再プランニングします。(前述の通りです)

実装解説

さて、だいぶ長々と前提について書きましたが、ここからは実際のコードを参考に、今回の実装内容を解説していきたいと思います。

ゴールクラスを実装する

まずはコードを見てもらったほうが早いと思うので、コードから。

namespace AI
{
    /// <summary>
    /// ゴールの状態
    /// </summary>
    public enum Status
    {
        Inactive,
        Active,
        Completed,
        Failed,
    }

    /// <summary>
    /// ゴールインターフェース
    /// </summary>
    public interface IGoal
    {
        bool IsInactive { get; }
        bool IsActive { get; }
        bool IsCompleted { get; }
        bool HasFailed { get; }

        void Activate();
        Status Process();
        void Terminate();
        void AddSubgoal(IGoal subgoal);
    }

    /// <summary>
    /// ゴールの基底クラス
    /// </summary>
    public class Goal<T> : IGoal where T : AIBase
    {
        protected T _owner;

        /// <summary>
        /// 非アクティブか
        /// </summary>
        public bool IsInactive { get { return _status == Status.Inactive; } }

        /// <summary>
        /// アクティブか
        /// </summary>
        public bool IsActive { get { return _status == Status.Active; } }

        /// <summary>
        /// 完了済か
        /// </summary>
        public bool IsCompleted { get { return _status == Status.Completed; } }

        /// <summary>
        /// ゴール失敗か
        /// </summary>
        public bool HasFailed { get { return _status == Status.Failed; } }

        /// <summary>
        /// 現在のステータス
        /// </summary>
        internal Status _status = Status.Inactive;

        public Goal(T owner)
        {
            _owner = owner;
        }


        /// <summary>
        /// 非アクティブならアクティブ状態に移行する
        /// </summary>
        internal void ActivateIfInactive()
        {
            if (IsInactive)
            {
                Activate();
            }
        }

        /// <summary>
        /// 失敗している場合はアクティブ化を試みる
        /// </summary>
        protected void ReactivateIfFailed()
        {
            if (HasFailed)
            {
                _status = Status.Inactive;
            }
        }

        /// <summary>
        /// アクティベイト処理
        /// </summary>
        public virtual void Activate()
        {
            Debug.Log("Start " + this);

            _status = Status.Active;
        }

        public virtual Status Process()
        {
            ActivateIfInactive();
            return _status;
        } 

        /// <summary>
        /// ゴールの後処理
        /// 成功/失敗に関わらず実行される
        /// </summary>
        public virtual void Terminate()
        {
            // do nothing.
        }

        public virtual void AddSubgoal(IGoal subgoal)
        {
            // do nothing.
        }
    }
}

ゴールの基底クラスです。
ベースとなる処理と、状態のプロパティのみ宣言しています。(つまりなにもしないゴール)
各ゴールについてはこれを継承して、実際の処理を記述していきます。

次に、サブゴールをまとめる親ゴールです。

CompositeGoal

サブゴールを束ねるゴールもまた、Goalクラスを継承したものになっています。
名称からも分かる通り、このゴールは「Compositeパターン」を利用して実装しています。

どんなものかをWikipediaから引用させてもらうと、

Composite パターン(コンポジット・パターン)とは、GoF(Gang of Four; 4人のギャングたち)によって定義された デザインパターンの1つである。「構造に関するパターン」に属する。Composite パターンを用いるとディレクトリとファイルなどのような、木構造を伴う再帰的なデータ構造を表すことができる。

ということになります。ファイルとフォルダ、というのは分かりやすい例でしょう。

Compositeパターンを簡単なクラス図にすると以下のようになります。

f:id:edo_m18:20161126111449p:plain

namespace AI
{
    /// <summary>
    /// サブゴールを持つゴール
    /// </summary>
    public class CompositeGoal<T> : Goal<T> where T : AIBase
    {
        /// <summary>
        /// サブゴールのリスト
        /// </summary>
        protected List<IGoal> _subgoals = new List<IGoal>();

        // コンストラクタ
        public CompositeGoal(T owner) : base(owner) { }

        #region Override

        public override Status Process()
        {
            ActivateIfInactive();
            return ProcessSubgoals();
        }

        /// <summary>
        /// サブゴールを追加
        /// </summary>
        /// <param name="subgoal"></param>
        public override void AddSubgoal(IGoal subgoal)
        {
            if (_subgoals.Contains(subgoal))
            {
                return;
            }

            _subgoals.Add(subgoal);
        }

        #endregion

        /// <summary>
        /// すべてのサブゴールを終了させ、クリアする
        /// </summary>
        protected void RemoveAllSubgoals()
        {
            foreach (var goal in _subgoals)
            {
                goal.Terminate();
            }

            _subgoals.Clear();
        }

        /// <summary>
        /// サブゴールを評価する
        /// </summary>
        /// <returns></returns>
        internal virtual Status ProcessSubgoals()
        {
            // サブゴールリストの中で完了 or 失敗のゴールをすべて終了させ、リストから削除する
            while (_subgoals.Count > 0 &&
                  (_subgoals[0].IsCompleted || _subgoals[0].HasFailed))
            {
                _subgoals[0].Terminate();
                _subgoals.RemoveAt(0);
            }

            // サブゴールがなくなったら完了。
            if (_subgoals.Count == 0)
            {
                _status = Status.Completed;
                return _status;
            }

            var firstGoal = _subgoals[0];

            // 残っているサブゴールの最前のゴールを評価する
            var subgoalStatus = firstGoal.Process();

            // 最前のゴールが完了していて、かつまだサブゴールが残っている場合は処理を継続する
            if ((subgoalStatus == Status.Completed) &&
                _subgoals.Count > 1)
            {
                _status = Status.Active;
                return _status;
            }

            return _status;
        } 
    }
}

サブゴールを束ねるゴールの定義は以上です。
基本的な処理はサブゴールを評価し、それが完了しているのか失敗しているのかのチェックと、サブゴールすべてが完了したら完了状態になる、というだけのシンプルなものです。

実際の挙動に関しては、目的に応じてこれらふたつのゴールのどちらかを継承してゴール派生クラスを作成していきます。(例えば、目的地に行くゴールであればGoalクラスを継承して、Processメソッド内で移動処理を記述する、という具合です)

全体を統括するBrainクラス

前回書いた通り、Brainクラスは全体のゴールを統括する役割を持っています。が、継承元はCompositeGoalです。

namespace AI
{
    /// <summary>
    /// ゴールを統括するルートゴール
    /// </summary>
    public class Brain<T> : CompositeGoal<T> where T : AIBase
    {
        // プランナー
        private IPlanner _planner;

        // 現在選択されているプラン
        private PlanBase _currentPlan;

        // 短期記憶しているオブジェクトを保持
        private List<Memory> _shortMemories = new List<Memory>();

        // 長期記憶しているオブジェクトを保持
        private List<Memory> _longMemories = new List<Memory>();

        /// <summary>
        /// 記憶しているすべてのオブジェクトを返す
        /// </summary>
        private List<Memory> AllMemories
        {
            get
            {
                List<Memory> allMemories = new List<Memory>();
                allMemories.AddRange(_shortMemories);
                allMemories.AddRange(_longMemories);
                return allMemories;
            }
        }

        #region Constructor

        // コンストラクタ
        public Brain(T owner) : base(owner)
        {
            // NOTE:
            // 今回は簡単のためプランナーを直接生成しているが、
            // DI的に設定したほうが汎用性は高い
            _planner = new CharaPlanner(owner);
        }

        #endregion


        #region Public members

        /// <summary>
        /// プランを記憶に保持
        /// </summary>
        /// <param name="planObject"></param>
        public void Memorize(PlanObject planObject)
        {
            // 重複しているプランは追加しない
            if (HasMemory(planObject))
            {
                return;
            }

            _shortMemories.Add(MakeMemory(planObject));
        }

        /// <summary>
        /// メモリコントロール
        /// すでに達成したプランなど、記憶から消すべきオブジェクトをリストから削除する
        /// </summary>
        public void MemoryControl()
        {
            var targets = from m in _shortMemories
                          where m.Target != null
                          select m;

            var newList = targets.ToList();
            _shortMemories = newList;
        }

        #endregion


        #region Private members

        /// <summary>
        /// プランリストから最適なプランを評価、取得する
        /// </summary>
        /// <returns></returns>
        PlanBase EvaluatePlans()
        {
            List<PlanBase> plans = EnumeratePlans();
            return _planner.Evaluate(plans);
        }

        /// <summary>
        /// 短期・長期記憶双方に保持しているプランを列挙する
        /// </summary>
        /// <returns></returns>
        List<PlanBase> EnumeratePlans()
        {
            var plans = new List<PlanBase>();
            foreach (var m in AllMemories)
            {
                plans.Add(m.Plan);
            }
            return plans;
        }

        /// <summary>
        /// プランに応じてゴールを選択する
        /// </summary>
        /// <param name="plan"></param>
        /// <returns></returns>
        IGoal GetGoalByPlan(PlanBase plan)
        {
            switch (plan.GoalType)
            {
                // あたりを探し回る
                case GoalType.Wander:
                {
                    return new GoalWander<T>(_owner);
                }

                // エネルギー/パワーを得る
                case GoalType.GetEnergy:
                case GoalType.GetPower:
                {
                    var memory = FindMemory(plan);
                    return new GoalGetItem<T>(_owner, memory.Target);
                }

                // 敵を攻撃
                case GoalType.Attack:
                {
                    var memory = FindMemory(plan);
                    return new GoalAttackTarget<T>(_owner, memory.Target);
                }
            }
            
            return new Goal<T>(_owner);
        }

        /// <summary>
        /// 選択中のプランからプランを変更する
        /// </summary>
        /// <param name="newPlan"></param>
        void ChangePlan(PlanBase newPlan)
        {
            Debug.Log("Change plan to " + newPlan);

            _currentPlan = newPlan;
            RemoveAllSubgoals();

            var goal = GetGoalByPlan(newPlan);
            AddSubgoal(goal);
        }

        /// <summary>
        /// プランオブジェクトからメモリオブジェクトを生成する
        /// </summary>
        Memory MakeMemory(PlanObject planObject)
        {
            var memory = new Memory(planObject);
            return memory;
        }

        /// <summary>
        /// 対象プランから記憶オブジェクトを検索
        /// </summary>
        Memory FindMemory(PlanBase plan)
        {
            return AllMemories.Find(m => m.Plan == plan);
        }

        /// <summary>
        /// 記憶にあるプランか
        /// </summary>
        bool HasMemory(PlanObject planObject)
        {
            var memory = AllMemories.Find(m => m.Plan == planObject.Plan);
            return memory != null;
        }

        /// <summary>
        /// 記憶にあるメモリか
        /// </summary>
        bool HasMemory(Memory memory)
        {
            var storeMem = AllMemories.Find(m => m == memory);
            return storeMem != null;
        }

        #endregion


        #region Override Goal class

        public override void Activate()
        {
            base.Activate();

            RemoveAllSubgoals();

            // なにもないときにあたりを歩き回るプランを設定しておく
            var memory = new Memory();
            memory.Plan = new PlanWander();

            _longMemories.Add(memory);
        }

        public override Status Process()
        {
            ActivateIfInactive();

            PlanBase selectedPlan = EvaluatePlans();
            bool needsChangePlan = (selectedPlan != null) && (_currentPlan != selectedPlan);
            if (needsChangePlan)
            {
                ChangePlan(selectedPlan);
            }

            return ProcessSubgoals();
        }

        public override void Terminate()
        {
            base.Terminate();
        }

        #endregion
    }
}

Brainクラスはその名の通り、AIの挙動を決定する大事な役割を担います。
保持しているAIBase(Owner)の情報と、現在記憶している状態を元に、行動すべき内容を決定します。

短期記憶と長期記憶

プランニング対象となるオブジェクトに関しては記憶に蓄えられます。
また、人間と同様に「長期記憶」と「短期記憶」に分けて保持しておくことで、一定時間経過したら忘れてしまう、という挙動や、忘れることなく必ず実行させる、ということが可能になります。

短期記憶させるものとしては、例えば回復アイテムの位置を覚えさせておいたり、武器の場所を覚えさせておいたり、といったことが考えられます。
当然AIは自分の判断において行動するため、「見つけた」タイミングでそれらのアイテムを取りに行くとは限りません。

そのため、いったん記憶に留めておいてから、必要になったタイミングでそれを取り出し、その場所に向かうことで自然な動きを実現している、というわけです。

長期記憶に適しているものとしては、例えば陣取りゲームなどの場合は最重要ゴールは「陣地を取る」ということです。
こうしたことを「忘れて」しまっては困るので、直近でやるべきゴールがない場合に自動的に選択され、かつ消去されないように長期記憶に留めておくなどするといいでしょう。

記憶にとどめる処理自体は、AIがなにか記憶に留めておくべきものを「見つけた」ときに記憶されます。
見つける方法は様々です。今回のサンプルでは単に、isTriggerなコライダを用いて、記憶可能オブジェクトが検知エリア内に入った場合にそれを保持するようにしています。

今回はコライダを設定していますが、例えば視覚を表現するようにキャラクターの目の前のオブジェクトだけを対象としたり、あるいは「音」に反応するようにしてもいいと思います。
要は「外界を認識するセンサー」をエージェントに実装し、それらが「知覚」したときに記憶に留めることをすれば、より自然な形で記憶に留めさせることができます。

f:id:edo_m18:20161126122632p:plain
サンプルではSphereColliderでセンサーを表現

プランを選択する「プランナー」

そして実際に「どのプランを選択するか」については「プランナー」クラスを設けています。
プランナークラスに判断を任せることで、あとから性格を変えたりといったことが容易になるようにしています。

ということで、プランナークラスを見てみましょう。

PlannerBase

PlannerBaseクラスはIPlannerインターフェースを実装し、プランナーのベースとなる処理を実装しています。
プランの実際の評価については派生クラスに任せています。

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

namespace AI
{
    /// <summary>
    /// プランナーインターフェース
    /// </summary>
    interface IPlanner
    {
        float EvaluatePlan(PlanBase plan);
        PlanBase Evaluate(List<PlanBase> plans);
    }

    /// <summary>
    /// プランナーのベースクラス
    ///
    /// プランリストから適切なプランを選択する
    /// </summary>
    public class PlannerBase<T> : IPlanner where T : AIBase
    {
        protected T _owner;

        #region Constructor

        // コンストラクタ
        public PlannerBase(T owner)
        {
            _owner = owner;
        }

        #endregion


        /// <summary>
        /// プランリストを評価して、報酬見込みが一番高いものを返す
        /// </summary>
        /// <param name="plans">評価対象のプランリスト</param>
        /// <returns>選択されたプラン</returns>
        public virtual PlanBase Evaluate(List<PlanBase> plans)
        {
            float maxValue = 0f;
            PlanBase selectedPlan = null;
            foreach (var plan in plans)
            {
                float value = EvaluatePlan(plan);
                if (maxValue <= value)
                {
                    maxValue = value;
                    selectedPlan = plan;
                }
            }

            return selectedPlan;
        }

        /// <summary>
        /// プランを評価する
        /// </summary>
        /// <param name="plan">評価対象のプラン</param>
        /// <returns>オーナーの現在の状態を加味したプランに応じた報酬見込み値</returns>
        public virtual float EvaluatePlan(PlanBase plan)
        {
            return 0f;
        }
    }
}

大事なポイントはEvalute(List<PlanBase> plans)メソッドです。
プランナーはプランリストを受け取り、その中からオーナーのパラメータを元に「最適」となるプランを選択します。
最適なプランの選択は派生クラスの評価のロジックに任されています。

CharaPlanner
using UnityEngine;
using System.Collections;

namespace AI
{
    public class CharaPlanner : PlannerBase<AIBase>
    {
        #region Constructor

        // コンストラクタ
        public CharaPlanner(AIBase owner) : base(owner) { }

        #endregion


        /// <summary>
        /// プランを評価する
        /// 方針は、できるだけエネルギーを蓄えつつ、パワーを拡充していく性格。
        /// つまりエネルギーの補充に充填を置く挙動にする。
        /// </summary>
        public override float EvaluatePlan(PlanBase plan)
        {
            float value = 0f;

            // 攻撃プランの場合は、オーナーの状態を見て攻撃に転じるかを判断する
            if (plan.GoalType == GoalType.Attack)
            {
                // パワーがない場合は攻撃できない
                if (_owner.AttackPower == 0.0f)
                {
                    return 0f;
                }

                value += Mathf.Pow(_owner.Energy, 2f);
                value += Mathf.Pow(_owner.AttackPower, 1.5f);
                return value;
            }

            foreach (var reward in plan.RewardProspects)
            {
                switch (reward.RewardType)
                {
                    case RewardType.Enegy:
                        value += Mathf.Pow(1f - _owner.Energy, 2f) * reward.Value;
                        break;

                    case RewardType.Power:
                        value += Mathf.Pow(1f - _owner.AttackPower, 3f) * reward.Value;
                        break;
                }
            }

            return value;
        } 
    }
}

今回のサンプルではキャラクターはひとりしかいないため、CharaPlannerのみの実装です。
これを複数種類実装して、それぞれのキャラクターごとに変えることで、キャラごとの性格を変える、といったことも可能です。

CharaPlannerはシンプルにEvaluatePlan(PlanBase plan)メソッドをオーバーライドして、プランごとの評価部分を実装しています。
プラン選択の要はこのメソッドです。

見てもらうと分かる通り、各プランに設定された「報酬」を元に、2次関数の形で報酬を評価しています。
当然これを3次関数にしたり、あるいはまったく別のロジックを入れることによって、柔軟に、選択するプランに変化を与えることが可能になります。
(例えば、上のサンプルで言えばエネルギーの評価値を上げておくことで、ひたすらエネルギー回収に向かうAIにする、といった具合です)

前回の記事で「ファジーに判断する」と書いたのがまさにこの部分です。
いったんすべてのプランを評価し、その中で「一番評価の高かった(つまりAIにとって最適の)プラン」を選択することで、AIがまるで人が操作しているかのような「状況を判断して行動している」様子を表現している、というわけです。

評価は報酬から計算する

さて、最後は報酬とプラン選択のロジックについて少し深掘りして終わりにしたいと思います。
(さすがにすべてのクラス、実装を解説するには長くなりすぎるので)

前述の通り、プランは報酬を計算して最終的に選択されます。報酬自体はただの数値的なパラメータです。
(例えばこれを手に入れたらライフがこれだけ回復する、とか、これを手に入れたらエネルギーがこれだけ充填される、など)

こうしたパラメータを元に、オーナーの状態(例えばライフが少なくてピンチ! など)に応じて、適切なプランが選択されるようにロジックを組めば、AIがさも人が操作しているかのような錯覚を生み出すことができます。

ではその計算ロジックはどうしたらいいでしょうか。

正直なところ、答えはありません。というより、キャラクターに取らせたい行動に応じて最適解が変わってきます。
このあたりの調整はゲームの面白さを左右する部分にもなるので大変、でも楽しい部分ではないでしょうか。

とはいえこれでは解説にならないので、今回実装したものを解説すると、2次関数として実装しています。
具体的には以下の部分です。

value += Mathf.Pow(1f - _owner.Energy, 2f) * reward.Value;

1からオーナーのパラメータを引いたものを二乗したものに、報酬の値を掛け算して求めています。
これはつまり、オーナーのエネルギーが最大(1が最大)の場合、報酬見込み値は0になります。(1 - 1 = 0です)

逆に、オーナーのエネルギーが減ってくると2次関数的に見込みが増加します。
実際に値を入れてみると分かりやすいと思います。

pow(1 - 0.9, 2) = 0.1 * 0.1 = 0.01
pow(1 - 0.5, 2) = 0.5 * 0.5 = 0.25
pow(1 - 0.1, 2) = 0.9 * 0.9 = 0.81

という具合です。

※ シェーダもそうですが、0~1で表現するとこうも値が扱いやすくなるのはほんとすごいなと思ってます。

小数点の2次関数とすることで、以下のように充足度が減るにつれて、その報酬見込みが顕著に上昇していくようになります。
またこれらの式を工夫することで、最初は興味がない事柄に対しても、途中から急激に興味を示す、というようなことが可能になります。

f:id:edo_m18:20161126170858p:plain

まとめ

さて、いかがだったでしょうか。
AIが作れるようになると、ゲームの幅がとても広がります。

そしてゲームに限らず、ゲームAIの理論を組み合わせてサービスの向上を図ることもできるのではないかなと思っています。
今回は「ゴール駆動型」のAIの紹介・解説でしたが、参考にした書籍「実例で学ぶ ゲームAIプログラミング」では様々なAIについて解説されています。

例えば、

などなど、組み合わせることでより多様なAIを作れる内容が盛り沢山です。

経路探索などはUnityではNavMeshなどで手軽に実装できますが、理論を知っておくと色々と応用が効くのでおすすめです。
A*ではありませんが、過去にダイクストラのアルゴリズムについても記事を書いたので、興味がある人は読んてみてください。

qiita.com

ゴール駆動型エージェントの実装(概念編)

概要

Unityだけに関わらず、ゲームやそれに類するコンテンツを制作する際に必要となるのが「AI」の存在です。
格闘ゲームやシミュレーションの対戦相手のような明確な相手ではなくとも、例えばちょっとした敵が出現するコンテンツなどでも、AIで操作されたキャラクターがいるとよりリアリティが増します。

特に、VRコンテンツの場合は没入感やリアリティがとても重要な要素になってきます。
敵が単調な動きだけをしていたらその時点で現実に引き戻されるし、コンテンツ自体が単調になりかねません。

ということで、今回はタイトルの通り、ゴール駆動型エージェントによるAI実装について書きたいと思います。

本記事は「実例で学ぶゲームAIプログラミング」を読んだ上で、それを参考に実装したものをベースに書いています。

※ ちなみに、Cygames Engineers' Blogの「ゲームAI – 基礎編(2) – 『はじめてのエージェントベースアーキテクチャ』」という記事もとてもよかったのでここで紹介しておきます。

※ 今回の記事を書くに当たって、ごく簡単なUnityのサンプルプロジェクトを作成しました。Githubで公開しているので、動作サンプルを見たい方はこちらを見てください。(ただし、学習目的で作っており、作り方がよくない箇所や、解釈が間違っている部分もあるかもしれません)

全部を一度に書くととても長くなってしまいそうなので、概念と実装を分けて書きたいと思います。
今回は「概念」を書きたいと思います。

動作イメージ


(AIらしさがないですが・・w 実際に実行するとアイテムの収集や敵への攻撃が自動で選択されていきます)

考え方

考え方の大事なポイントを列挙すると、

1. AIの行動ルールを「ゴール」として定義する
2. ゴールの「選択」を行う
3. ゴールの選択は「欲求」や「状況」を織り交ぜてファジーに判断する

この3点です。

ひとつずつ概要を書いていきましょう。

AIの行動ルールを「ゴール」として定義する

ゴール駆動型なので「ゴール」が単位になるのは自然な流れですね。
ちなみに「エージェント」はここでは「AI」です。

行動ルールを「ゴール」として定義するというのは、具体的に言うと以下のようなイメージです。

まず、シチュエーションとして、

徘徊しているゾンビがプレイヤーを見つけて攻撃を仕掛けてくる

というシーンを想像してみてください。

最初は当てもなく、(空腹を満たすために)色々なところをうろついていると思います。
そしてプレイヤーが視界(センサー)に入ると、プレイヤーを捕食しようと襲いかかります。(餌だ!)

さてここで、プレイヤーが視界に入った時点では当然、まだプレイヤーまで距離があります。
つまり、襲いかかるにはプレイヤーを攻撃できる場所まで移動する必要がある、ということです。
なので、これを行動として分解すると、

1. プレイヤーに攻撃できる場所まで移動する
2. プレイヤーを攻撃する
3. プレイヤーが死んだら捕食する

という「行動」が必要になります。
この3つの「行動」を満たした時、最終目的である「空腹を満たす」というゴールが達成できるという具合です。
つまりこれは、「空腹を満たす」というゴールを達成するのに3つのアクションが必要、と見ることもできます。

これがAIの行動ルールをゴールとして定義することのイメージです。
実際に読んでみるとなんだか当たり前のように感じませんか?

それは人がなにか物を考え、決定するときはこうした「ゴール」から逆算して行動を決定しているからに他なりません。
つまり、自分たちが行動を決定するやり方に近いから「当たり前に感じる」わけですね。

今回はこれを実際にコードに落とし、AIとしてキャラクターが動き出すまでを書いていきたいと思います。

ゴールの「選択」を行う

行動ルールをゴールとして定義するイメージはわいたでしょうか。

確かに考え方は自分たち人間が行うことに近いのでイメージしやすいと思いますが、状況に応じて無数にあるゴールからどれを選択したらいいのでしょうか。
答えは、それこそ無数にあるでしょう。この「ゴールの決定」自体がまさにAIの頭の良さにもつながります。
なので様々なアルゴリズムやロジックがあることと思います。

ですが、今回は比較的シンプルな方法でこれを実装しようと思います。
(というか、そもそもAIは冒頭で紹介した書籍を読んで学んだ範囲を書いているので、それ以上のことは書けませんw)

ゴールはサブゴールの集合体

今回実装した方法は、ゴールの決定を行うゴールを設定する、です。
なんのこっちゃと思うかもしれませんが、上で書いた通り、ゴールは複数の行動を集めたものと考えることができます。 そして、感の言い方なら気づいているかもしれませんが、ゴールは入れ子にすることができます。

どういうことかというと、一見、単純な行動に見えるものもよくよく見てみれば複数のゴールの集合なのです。

例えば、(AIではなく人が)目の前にあるバナナを食べる、というシーンを考えてみてください。
バナナを食べる、というゴールだとしても。
これを際限なく分解することが可能です。やってみましょう。

1. バナナを見る
2. バナナまで手を伸ばす
3. バナナを手に取る
4. バナナの皮をむく
5. バナナを口に運ぶ
6. バナナを咀嚼する
7. バナナを飲み込む
....

という具合です。
当然これはやりすぎなくらいに分解していますが、やろうと思えばもっと分解することも可能ですね。
このように、ひとつのゴールは無数のサブゴールから成り立っています。

今回の記事で扱うAIは、これを「ほどよく」分解し、現実的な範囲でゴールを決定する方法です。

ゴールの選択は「欲求」や「状況」を織り交ぜてファジーに判断する

ゴールの決定についてはイメージできましたでしょうか。

さて次は「ファジー」に決定する、という部分です。
ファジー理論は、ざっくり言うと「0か1かではない曖昧な決定」を下すこと。

仮に、ぱっとは決めづらい2つの事柄があった場合、人はそれぞれの事柄について色々考え、あの場合はこうだけど、この場合はこうだから・・と悩み、どちらかがいい、と断言できる例は稀でしょう。
AIの行動決定ロジックもこうしたファジーな状況を作り出せるとより「人間らしく」なります。

具体的に言うと、普通のアプリ開発のようにif文を連ねて、もし特定の値が一定以上だったらこうする、という分岐を行った場合、行動決定がロジカルすぎて途端に「機械らしく」見えてしまいます。

擬似コードで書くと以下のような感じです。

if (power > 1.0f) 攻撃
else if (energy < 0.5f) エネルギー補給
else if (hungry < 0.2f) なにか食べる

こうしてしまうと、攻撃力が一定以上ある場合はひたすら攻撃を繰り返すAIができてしまいます。
仮に他のパラメータの値が減少したりしていても、前方のif文の分岐に入ってしまって下はまったく評価されません。

これを防ぐために、上述の「ファジー理論」や、それに近い考え方を用いて行動を決定するロジックを組みます。

クラス構成

実際に動くサンプルを見てもらうのが早いかと思いますが、今回のサンプルのために用意したクラスは以下の通りです。

Goals

  • Goal
  • compositeGoal
  • GoalSeek
  • GoalWander
  • GoalPickup
  • GoalGetItem
  • GoalAttackTarget
  • GoalAttack
  • Brain

Plans

  • PlanBase
  • PlanWander
  • PlanGetPower
  • PlanGetEnergy
  • PlanAttackTarget
  • PlanObject
  • Reward

Planner

  • PlannerBase
  • CharaPlanner

Memory

  • Memory

AIBase

  • AIBase

クラスの連携

細かいクラスは具象化されたものなので、注目してもらいたい点としてはベースクラスの区分けです。
具体的には

  • Goal
  • PlanBase
  • Planner
  • Memory
  • AIBase

の5つ。

AIBase

順番は前後しますが、まずはAIBaseから。
AIBaseはAIのベースとなるクラスです。
主に、ゴール選択を行うBrainクラスを持っていたり、各種パラメータなど、全体を統括、使役するクラスです。

GoalクラスはこのAIBaseクラスをOwnerとして保持していて、オーナーから様々なデータや状況を得て動作を決定します。
UnityではMonoBehaviourを継承し、Prefabにアタッチするもの、と考えるとイメージしやすいかと思います。

Goal

ゴールクラスは今回の趣旨にもなっている「ゴール」を示すクラスです。
具体的な「行動」はこのクラスが担っています。
ゴールクラスのリストの中にBrainクラスがありますが、ベースはGoalクラスになっていて、全体のゴールを決定するロジックを持っているやや特殊なクラスとして存在しています。
ただ、動作のライフサイクル的にはゴールの仕組みそのままの実装になっています。

Plan

プランクラス。
プランクラスはその名の通り「計画」を表すクラスです。
イメージで言うと「旅行プラン」などを想像してもらうと分かりやすいかと思います。

例えば、旅行プランで検討しなければならない項目はいくつかありますよね。
金額はいくらなのか。今まで行ったことがある場所か。その場所でどんな体験ができるのか。

そうした様々な要件を検討して、「今まで行ったことがない、おいしいものが食べられる旅行プランにしよう」という感じで決定するかと思います。
プランクラスはまさにこうした「それぞれの計画を行った場合になにが得られるか」を表しています。

例えの粒度で表せば「沖縄旅行」「北海道旅行」などになります。

リストの中にRewardクラスがありますが、これは「報酬」を意味するクラスです。
各プランにはそれぞれ「報酬」が設定されていて、この報酬の状況を元に、「Planner」クラスがプランを決定します。

「報酬」と書くと金銭的なイメージが出ていますかもしれませんが、「なにを得られるか」が報酬です。
例えば沖縄旅行なら、今までに体験したことがないスクーバダイビングができる、は大きな報酬(体験)として認識されるでしょう。
一方で、北海道でおいしいものが食べたい、となればまた違った報酬(体験)になります。

キャラクターの性格や状況(金銭面とか)に応じて決定されるプランが変わるように、こうした「報酬」と「内容」をセットにして表しているのがこの「プラン」クラスとなります。

Planner

プランナー。プランを決定する役割を担っています。
前述のプランクラスを並べて、「現在の状況」から一番いいプランを選択する、というクラスです。

「現在の状況」というのは、キャラクターの状況のことです。
例えば、ライフが減っている、攻撃ができない、パワーが足りない、などなど。
ゲームのキャラクターの状況は刻一刻と変化していきます。

その状況に応じて最適な「プラン」を選択するのがこのプランナークラスです。
もう少し具体的なイメージを書くと例えば。

最初はパワーもたくさんあり、好戦的に敵に向かって行ったとします。
しかし途中でダメージを追い、ライフが減ってピンチに陥ると状況が変化します。
それまでは積極的に攻撃を仕掛けていたキャラクターが、敵から逃げるようになり、ライフを回復するアイテムを求めて動き回る、といった「プラン」が選択されることになります。

この「プランナー」クラスをいくつか用意して差し替えることで「性格」を表すこともできそうですね。
例えば、プランを選択する中で「ライフの減少」をまったく気にかけないプランナークラスを実装した場合。

向こう見ずでとにかく敵に突進していく、というキャラクターの出来上がりです。
逆に、ライフにばかり執着するようにすれば臆病者のキャラクターになりますね。
これはまさに「性格そのもの」と言えるでしょう。

なので(作っておいてなんですが)「プランナー」より「性格」を意味するクラス名のほうがよかったかもしれませんw

Brain

ゴールクラスの中にありますが、少しだけ特殊なので個別に解説。
Brainクラスはその名の通り、「脳」を司るクラスです。

「脳」の役割は「記憶」すること。
つまりキャラクターに「記憶」の概念を与えます。

一緒にMemoryクラスの説明もしてしまいますが、メモリクラスは記憶オブジェクト、と読んでも構いません。
キャラクターが記憶にとどめておくべきものを認識し、それを蓄えます。

そして必要があればそれを取り出して適切に利用します。
人がなにかを思い出して行動をするのに似たことを実現するために用いています。

PlanObject

プランオブジェクト。これは前述の「プラン」クラスから派生したものではなく、どちらかというと前述のMemoryクラスに近い存在のものです。

Cygames Engineers' Blogの「ゲームAI – 基礎編(2) – 『はじめてのエージェントベースアーキテクチャ』」で紹介されているものとほぼ同じです。(だと思う。実装が明かされていないので詳細は分かりませんがw)

役割としては、「プランを立てるにあたって必要な情報を格納したオブジェクト」です。
例えば、前述の例えを利用すると、ライフを回復したいキャラクターは「ライフ回復アイテム」の場所を探すことになります。
その場所はどこでしょうか? まだ一度も発見していなければ辺りを探すことになりますね。

そしてもし、過去にそれを「見て」いたら。
それを思い出してその場所まで戻ろうとするのが「人らしい」行動になります。

そしてプランオブジェクトはまさにこの挙動を取らせるためのクラスになります。
MonoBehaviourをアタッチして、キャラクターのセンサーに反応させる、と言えばピンとくる人もいるのではないでしょうか。

具体的に言えば、「ライフ回復アイテム」にこれをアタッチしておいて、キャラクターに「これは回復アイテムだ」と認識させます。
認識されたオブジェクトはすぐさま記憶として蓄えられます。

そして「思考サイクル」の中でライフが減った状況になり、「ライフ回復アイテム」が必要になったタイミングでこのことを思い出し、プランナーは「ライフ回復アイテムの回収」というプランを選択します。

こうすると、キャラクターが傷ついたときに自然とライフ回復を行うように仕向けることが可能になります。

概念編まとめ

どうでしょうか。なんとなく「ゴール駆動型」のイメージがついてきたでしょうか。
最初に自分が、参考にした本や記事を読んだときは「なんてよくできた仕組みなんだ」と思いました。

このあとは実際の実装について解説していきます。
が、だいぶ長くなってしまうので、続きは実装編として書こうと思います。

後編の「実装編」を書きました。
edom18.hateblo.jp