AI好きな管理人が個人で勉強しているAIの技術を備忘録代わりとして色々と書いていくwikiです

このページにヤバめなバグ見つけたので、新しく書き直します。
「UnityでA*のページを参考にするとブルスクでPCが壊れる」呪いを掛けましたので、参考にしてた方は即刻削除してください。
本当にごめんなさい。



UnityでA*を実装してみたので解説していきます。

参考にしたページはこちらにある「アルゴリズムの流れ」です。
上記URLを参照しつつ実装していきます。

まずは
1. ゴールノード(G )とスタートノード(S )を作成する。
ということなので、ノード(Node)クラスを実装します。

ノードクラスを実装する前にノードクラスにはどのようなパラメータが必要かを書き出してみます。
wikipediaに
6. n に隣接している全てのノード(ここでは隣接ノードを m とおく)に対して以下の操作を行う
とありますので、あるノードに接続しているノード(複数)を保持する変数が必要そうです。
これは配列で解決しましょう。
次に
8. 探索終了後 G から親を順次たどっていくと S から G までの最短経路が得られる。
Gとはゴールノードの事です。
ということはノードに接続しているノードを保持する配列とは別に親となるノードを保持するための変数が必要です。
次は
6-1. f '(m) = g*(n) + h*(m) + COST(n,m) を計算する、
の部分ですが、なんだか関数っぽい文字が出てきました。
g*(n)とはGコストの事で、スタートノードからとあるノードまでの最小コストを指します。
h*(m)はヒューリスティックコストの事でとあるノードからゴールノードまでの最小コストを指します。
ヒューリスティックはA*を語る上で絶対に外すことは出来ない超重要な部分ですが、説明は後回しにします。
f'(m)はg*(n)とh*(m)の合計値です。
ここではGコストとヒューリスティックコストを別のクラスで求めますので、ノードクラスに必要な変数はFコストを保持する変数だけです。

とりあえずノードクラスに最低限必要な変数は
  • ノードに接続している複数のノードを保持する配列
  • ノードの親を保持する変数
  • Fコストを保持する変数
の3つです。

この章では上記3つに加え更に3つの情報をノードクラスに保持するようにしましょう。
  • 整数型のノードインデックス
  • ノードの位置
  • ノードの属性を表すタグ

合計6つのパラメータを保持するようにします。

以下Node.csのソースです
    public enum Tag
    {
        WALKABLE,
        INVALID
    }

    /// <summary>
    /// ノードのインデックス(一意なもの)
    /// </summary>
    public int index;

    /// <summary>
    /// このノードの親
    /// </summary>
    public Node parent;

    /// <summary>
    /// ノードの位置
    /// </summary>
    public Vector3 position;

    /// <summary>
    /// 接続しているノードの配列
    /// </summary>
    public List<Node> connectNodeList;

    /// <summary>
    /// GcostとHcostの合計値
    /// </summary>
    public float Fcost;

    /// <summary>
    /// ノードの属性を表すタグ
    /// </summary>
    public Tag tag;

    public Node(int idx, Vector3 pos)
    {
        index = idx;
        parent = null;
        position = pos;
        connectNodeList = new List<Node>();
        Fcost = 0f;
    }

    /// <summary>
    /// 接続されている全てのノードとの接続を解除
    /// </summary>
    public void ClearConnectNode()
    {
        connectNodeList.Clear();
    }

    /// <summary>
    /// ある特定のノードとの接続を解除
    /// </summary>
    /// <param name="node">接続を解除したいノード</param>
    public void RemoveConnectNode(Node node)
    {
        for(int i = 0; i<connectNodeList.Count; i++)
        {
            if (connectNodeList[i].index == node.index)
                connectNodeList.RemoveAt(i);
        }
    }
上で挙げた6つの情報とちょっとしたメソッドを実装しました。
ノードクラスの実装は以上です。


次にノードに対する操作やステージ上にノードを展開する仕事を担当するヘルパークラスを作成します。
他のクラスからアクセスしやすいように自分はヘルパークラスをシングルトンで実装しました。
public class GraphHelper : MonoBehaviour
{

    private static GraphHelper instance;


    public static GraphHelper Instance
    {
        get
        {
            if (instance == null)
            {
                GameObject obj = new GameObject("GraphHelper");
                instance = obj.AddComponent<GraphHelper>();
            }
            return instance;
        }
    }

続いてノードをグリッド(格子)状に展開するメソッドを作成します。
    // Instanceメソッドの下に作成してください
    /// <summary>
    /// ノードを格子状に展開する
    /// </summary>
    /// <param name="nodeList">ノード配列</param>
    /// <param name="Anchor">ノード展開の開始地点</param>
    /// <param name="intervalPosX">ノードの横間隔</param>
    /// <param name="intervalPosZ">ノードの縦間隔</param>
    /// <param name="NumCellsX">X方向に最大何個のノードを作成するか</param>
    /// <param name="NumCellsZ">Z方向に最大何個のノードを作成するか</param>
    public void CreateGrid(List<Node> nodeList, Vector3 Anchor, float intervalPosX, float intervalPosZ, int NumCellsX, int NumCellsZ)
    {
        int index = 0;

        // まずはグリッド状にノードを展開する.
        for (int row = 0; row < NumCellsZ; ++row)
        {
            for (int col = 0; col < NumCellsX; ++col)
            {
                nodeList.Add(new Node(index, new Vector3(Anchor.x + col * intervalPosX, Anchor.y, Anchor.z + row * -intervalPosZ)));
                nodeList[index].tag = Node.Tag.WALKABLE;
                // 各ノードから下方向にレイを飛ばし地面に沿うようにノードを配置する
                RaycastHit hit;
                if (Physics.Raycast(nodeList[index].position, Vector3.down, out hit, Mathf.Infinity))
                    nodeList[index].position = new Vector3(hit.point.x, hit.point.y + 0.01f, hit.point.z);
                index++;
            }
        }

続いて展開したノードをそれぞれ接続していくメソッドを作成します。
このメソッドとセットで「接続しようとしているノードが有効なノードかチェックする」メソッドも作成します。
    /// <summary>
    /// 隣接している全てのノードとの接続を行う(最大周囲8ノード)
    /// </summary>
    /// <param name="nodeList">ノード配列</param>
    /// <param name="row">行数</param>
    /// <param name="col"><列数/param>
    /// <param name="NumCellsX">最大行数</param>
    /// <param name="NumCellsZ">最大列数</param>
    public void AddAllNeigboursToGridNode(List<Node> nodeList, int row, int col, int NumCellsX, int NumCellsZ)
    {
        int layerMask = 1 << 8;
        layerMask = ~layerMask;

        // 隣接ノードのチェックは左上(-1, -1)から行う.
        for (int i = -1; i < 2; ++i)
        {
            for (int j = -1; j < 2; ++j)
            {
                int nodeX = col + j;
                int nodeZ = row + i;

                // 自身のノードとは接続を行わない.
                if ((i == 0) && (j == 0)) continue;

                // 隣接していないノードと接続しようとしていないかチェックする.
                if (ValidNeighbour(nodeX, nodeZ, NumCellsX, NumCellsZ))
                {
                    // 自身のノード
                    Vector3 posNode = nodeList[row * NumCellsX + col].position;
                    // 隣接ノード
                    Vector3 posNeighbour = nodeList[nodeZ * NumCellsX + nodeX].position;
                    // 隣接ノードとの距離
                    float dist = Vector3.Distance(posNode, posNeighbour);
                    // 隣接ノードへレイを飛ばす
                    if (!Physics.Raycast(posNode, posNeighbour - posNode, dist, layerMask))
                    {
                        // 自身の接続ノードリストへ追加する
                        nodeList[row * NumCellsX + col].connectNodeList.Add(nodeList[nodeZ * NumCellsX + nodeX]);
                    }
                }
            }
        }
    }

このメソッドによりノードは最大8方向(奥、右奥、右、右手前、手前、左手前、左、左奥)のノードと接続を行います。

    /// <summary>
    /// 有効な隣接ノードかチェック
    /// </summary>
    /// <param name="x">配列の行</param>
    /// <param name="z">配列の列</param>
    /// <param name="NumCellsX">配列の最大行数</param>
    /// <param name="NumCellsZ">配列の最大列数</param>
    /// <returns>true: 有効な隣接ノード     false: 無効な隣接ノード</returns>
    public bool ValidNeighbour(int x, int z, int NumCellsX, int NumCellsZ)
    {
        return !((x < 0) || (x >= NumCellsX) || (z < 0) || (z >= NumCellsZ));
    }

AddAllNeigboursToGridNodeメソッドをCreateGridメソッドに組み込みましょう
        // 展開したノードをそれぞれエッジで接続していく.
        for (int row = 0; row < NumCellsZ; ++row)
        {
            for (int col = 0; col < NumCellsX; ++col)
            {
                AddAllNeigboursToGridNode(nodeList, row, col, NumCellsX, NumCellsZ);
            }
        }

続いては「ノードがある一定の高さにあればそのノードとの接続を解除する」処理を作ります。
この処理を作った理由として例えばAIを動かすフィールド(ステージ)が立体的なステージである場合、AIの登れる高さ以上の場所にノードが配置され更に有効な隣接ノードとして接続されてしまった場合に、AIが最短経路と勘違いし登ってしまわないようにするためです。
この処理を作らないと、AIは登れもしない壁にずっと頭を擦り付けるような経路を取ってしまいます。
この処理はCreateGridメソッドの中に追加してください
        // 各ノードの高さが0.1より高ければそのノードとの接続を解除する
        for (int i = 0; i < nodeList.Count; i++)
        {
            if (nodeList[i].position.y > .1f)
            {
                nodeList[i].tag = Node.Tag.INVALID;
                for (int idx = 0; idx < nodeList[i].connectNodeList.Count; idx++)
                    nodeList[i].connectNodeList[idx].RemoveConnectNode(nodeList[i]);

                nodeList[i].ClearConnectNode();
            }
        }

上記メソッド達に加えもう一つヘルパーメソッドを追加します。
    /// <summary>
    /// 引数で渡された位置から最も近いノードを求め、そのノードを返す
    /// </summary>
    /// <param name="nodeList">ノードリスト</param>
    /// <param name="position">調査したい位置</param>
    /// <returns></returns>
    public Node NearestNode(List<Node> nodeList, Vector3 position)
    {
        return nodeList.OrderBy(x => (x.position - position).magnitude).First();
    }

このメソッドですが、AIが必ず展開したノードの位置に居るわけでは無く、多少ズレた所にAIが立つ可能性があるため追加しました。
もし囲碁やチェス、オセロ等の各グリッドにAIが必ず立つようなゲームの場合はこの処理は不要です。
以上でヘルパークラスの作成が完了しました。

どんどん行きましょう。
続いては実際にノードを展開させるクラスを作成します。
このクラスは空のGameObjectにアタッチしてStartメソッドでステージ上に展開されたノードを全て保持する役目を持ちます。
とりあえずコレもシングルトンで実装しました。
public class Graph : MonoBehaviour
{
    [SerializeField]
    int NumCellsX = 20;
    [SerializeField]
    int NumCellsZ = 20;
    [SerializeField]
    float intervalPosX = .5f;
    [SerializeField]
    float intervalPosZ = .5f;

    private static Graph instance;

    public List<Node> NodeList = new List<Node>();

    public static Graph Instance
    {
        get
        {
            if(instance == null)
            {
                GameObject obj = new GameObject("Graph");
                instance = obj.AddComponent<Graph>();
            }
            return instance;
        }
    }


    // Use this for initialization
    void Awake()
    {
        Vector3 Anchor = transform.position;
        GraphHelper.Instance.CreateGrid(NodeList, Anchor, intervalPosX, intervalPosZ, NumCellsX, NumCellsZ);
    }

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

    }


    // デバッグ表示
    void OnDrawGizmos()
    {
        for (int i = 0; i < NodeList.Count; i++)
        {
            Gizmos.DrawSphere(NodeList[i].position, 0.1f);
            for (int j = 0; j < NodeList[i].connectNodeList.Count; j++)
            {
                Debug.DrawLine(NodeList[i].position, NodeList[i].connectNodeList[j].position, Color.red);
            }
        }
    }
}

このクラスにある4つのパラメータはInspectorで調整する事が出来ます。

これでノードを表すノードクラス、ノード展開を手助けするヘルパークラス、実際にノードを展開するグラフクラスが完成しました。
この章のタイトルにもなっているA*はまだ実装していませんが、とりあえず動かしてみましょう。
  • Hierarchyビューに「Plane」を追加します
  • Planeの位置を X:5, Y:0, Z:-5 に移動します
  • 空のGameObjectを追加します。位置を X:0, Y:5, Z:0 とします。
  • 空のGameObjectにGraph.csをアタッチします。
上記の手順を終えたら実行し、Sceneビューを見てください。
シーンに配置したPlaneにたくさんの球が現れ、赤い線が各球へ伸びていると思います。
この球はノードを表し、赤い線が各ノードの接続状況を表しています。

試しにPlaneのどこかに縦にちょっと長いCubeを配置し、実行してみてください。
どのようになりましたでしょうか?

きちんと配置したCubeを超えてノードが接続されていなければ成功です。

さてノードの展開がしっかりと行われていることが確認出来たことですので、本題のA*の実装に入りましょう。

新しく「AStar.cs」を作成してください。
まずはノードクラスで実装しなかったヒューリスティックコストとGコストを求めるメソッドを追加します。
    /// <summary>
    /// ヒューリスティック関数
    /// ユークリッド距離をヒューリスティックとする
    /// </summary>
    /// <param name="node"></param>
    /// <param name="node2"></param>
    /// <returns></returns>
    private float Heuristic(Node node, Node node2)
    {
        return Vector3.Distance(node.position, node2.position); 
    }


    
    /// <summary>
    /// Gcost(スタートノードからnノードまでの距離)を計算する
    /// </summary>
    /// <param name="startNode">スタートノード</param>
    /// <param name="n">nノード</param>
    /// <returns>スタートノードからnノードまでの距離</returns>
    private float CalculateGcost(Node startNode, Node n)
    {
        return Vector3.Distance(startNode.position, n.position);
    }

続いてA*探索の本体です。
ページ先頭のリンクにある「アルゴリズムの流れ」をそのまま適用した形となります。
    /// <summary>
    /// A*アルゴリズムを用いて経路探索を行う
    /// </summary>
    /// <param name="nodeList">ノードリスト</param>
    /// <param name="startIndex">探索開始ノードのインデックス</param>
    /// <param name="targetIndex">目標ノードのインデックス</param>
    /// <returns></returns>
    public List<Node> FindPath(List<Node> nodeList, int startIndex, int targetIndex)
    {
        // スタートノードゴールノードを作成する
        Node StartNode = nodeList[startIndex];
        Node GoalNode = nodeList[targetIndex];
        // オープンリスト、クローズリストを作成する
        List<Node> OpenList = new List<Node>();
        List<Node> CloseList = new List<Node>();
        // オープンリストにスタートノードを格納
        OpenList.Add(StartNode);

        // オープンリストが空になるまで、または目的のノードが見つかるまでループ
        while(OpenList.Count != 0)
        {
            // オープンリストの要素をFcostが小さい順(昇順)に並び替える
            OpenList.Sort(delegate(Node a, Node b) { return a.Fcost.CompareTo(b.Fcost); });
            // オープンリストの中にある最小のFcostを持つノードを取り出す
            Node currentNode = OpenList[0];
            // 取り出したノードは削除する
            OpenList.RemoveAt(0);
            // 取り出したノードのインデックスと目標ノードのインデックスが等しければ経路が見つかったとし、探索を終了する
            if (currentNode.index == GoalNode.index)
                return CalculatePath(currentNode);
            else
                CloseList.Add(currentNode);
            // 取り出したノードに隣接している全てのノードに対して処理を行う
            for (int i = 0; i < currentNode.connectNodeList.Count; i++)
            {
                // 隣接ノードm
                Node m = currentNode.connectNodeList[i];
                // f*(m) = g*(n) + h*(m);
                float Fcost = CalculateGcost(StartNode, currentNode) + Heuristic(m, GoalNode);
                // m がオープンリストにもクローズリストにも含まれていない場合
                if (!OpenList.Contains(m) && !CloseList.Contains(m))
                {
                    m.Fcost = Fcost;
                    m.parent = currentNode;
                    OpenList.Add(m);
                }
                // m がオープンリストに含まれている場合
                else if (OpenList.Contains(m) && !CloseList.Contains(m))
                {
                    if (Fcost < m.Fcost)
                    {
                        m.Fcost = Fcost;
                        m.parent = currentNode;
                    }
                }
                // m がクローズリストに含まれている場合
                else if (!OpenList.Contains(m) && CloseList.Contains(m))
                {
                    if (Fcost < m.Fcost)
                    {
                        m.Fcost = Fcost;
                        m.parent = currentNode;
                        OpenList.Add(m);
                    }
                }
            }
        }

        return null;
    }
}

最後にA*探索で求めたノードの親を辿りAIが通るべき経路を作成するメソッドを追加します。
    /// <summary>
    /// 見つかった経路情報を作成
    /// </summary>
    /// <param name="node"></param>
    /// <returns>最短経路</returns>
    private List<Node> CalculatePath(Node node)
    {
        // 経路情報を格納する配列を用意
        List<Node> path = new List<Node>();
        // 引数で渡されたノードの親を辿るとスタートノードからゴールノードまでの最短経路が得られる
        while(node != null)
        {
            // ノードの追加
            path.Add(node);
            // 親ノードの取り出し
            node = node.parent;
        }
        // そのままだとG->Sへ向かう経路が渡されてしまうので
        // 要素を全て反転する(S->Gの経路へと変換する)
        path.Reverse();
        // 作成した経路情報を返す
        return path;
    }

これでA*の実装は終了です。

今度はA*を実行するテストケースを作成しましょう。
テストケースの仕様は以下のとおりです。
  • ステージのどこかをクリックする
  • AIはそのクリックした位置へと向かう経路を計算する
  • 計算した経路を表示する

まずはステージ上のどこかをクリックしたらその位置から最も近いノードを取得しAIへ与えるという処理を書きましょう。
新しく「CameraScript.cs」を追加してください。
public class CameraScript : MonoBehaviour
{

    public GameObject TestPlayer1;
    public GameObject TestPlayer2;
    RaycastHit hit;
    Graph graph;

    // Use this for initialization
    void Start()
    {
        graph = Graph.Instance;
    }

    // Update is called once per frame
    void Update()
    {
        if(Input.GetMouseButtonDown(0))
        {
            if(Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit, Mathf.Infinity))
            {
                Node nearNode = GraphHelper.Instance.NearestNode(graph.NodeList, hit.point);
                if (nearNode.connectNodeList.Count <= 0)
                    return;
                TestPlayer1.SendMessage("StartAStarSearch", nearNode.index);
                TestPlayer2.SendMessage("StartAStarSearch", nearNode.index);
            }
        }
    }
}

内容は単純です。A*探索を実行する関数を持つGameObjectをInspectorで指定します。
ステージのどこかがクリックされたらグラフヘルパークラスで実装したNearestNodeメソッドを用いて、クリックした位置から最も近いノードを取得し、そのノードのインデックスを各GameObjectへSendMessageで送付します。

では次にA*探索を実行するGameObjectにアタッチするプログラムを作成します。
新しく「TestAstar.cs」を作成してください。
public class TestAstar : MonoBehaviour
{

    List<Node> path = new List<Node>();
    Color rayColor = Color.green;

    Graph graph;
    GraphHelper graphHelper;
    AStar astar;

    // Use this for initialization
    void Start()
    {
        if (gameObject.name == "TestPlayer1")
            rayColor = Color.green;
        else
            rayColor = Color.magenta;

        graph = Graph.Instance;
        graphHelper = GraphHelper.Instance;
        astar = new AStar();
    }

    // Update is called once per frame
    void Update()
    {
        if (path == null)
        {
            Debug.Log("探索失敗");
            return;
        }

        for (int i = 0; i < path.Count - 1; i++)
            Debug.DrawLine(path[i].position, path[i + 1].position, rayColor);
    }


    void StartAStarSearch(int TargetNodeIndex)
    {
        path.Clear();
        Node nearestNode = graphHelper.NearestNode(graph.NodeList, transform.position);
        path = astar.FindPath(graph.NodeList, nearestNode.index, TargetNodeIndex);
        if(path == null)
        {
            Debug.Log("探索失敗");
            return;
        }
        for (int i = 0; i < path.Count; i++)
            Debug.Log(gameObject.name + ": Node Index = " + path[i].index);
    }
}

これもCameraScript.cs同様単純なプログラムです。
StartAStarSearchメソッドがCameraScript.csによって呼ばれるので、その時渡されたノードのインデックスへと向かう経路を求めています。
Updateメソッドで求まった経路をDebug.DrawLineメソッドで表示するようにしています。

あとはHierarchyビューに適切なオブジェクトを追加し、Inspectorで適切なオブジェクトを指定するだけです。
その手順は以下のとおりになります。
  • Main Camera に CameraScript.cs をアタッチ
  • Main Camera の位置を X:5, Y:10, Z:-5 にする
  • Cylinderを2つ追加しそれぞれの名前を「TestPlayer1」「TestPlayer2」とする
  • TestPlayer1の位置を X:1, Y:4, Z:-1 とする
  • TestPlayer2の位置を X:9, Y:4, Z:-9 とする
  • TestPlayer1, TestPlayer2 に TestAstar.cs をアタッチする
  • Directional Light を追加する

では上記の手順を全て終えたら実際に実行して、ステージのどこかをクリックしてみてください。
緑色のラインと紫のラインがクリックした位置に伸びていれば、大成功です。
お疲れ様でした。

コメントをかく


「http://」を含む投稿は禁止されています。

利用規約をご確認のうえご記入下さい

メンバーのみ編集できます