見出し画像

【Unity】3Dローグライクゲームの作り方〜Step7-3〜

前回の記事はこちら
前回はダメージ計算式を実装し、敵の簡易的な行動AIを作成するところまでを行いました。

今回参考にさせて頂いた記事

正直この記事を読むよりこの方の記事を読んだ方が断然わかりやすいです......。

Nodeクラスの実装

それでは実際にA*アルゴリズムを実装していきたいと思います。
まずは「Node」という内部クラスの実装から行います。
EnemyOperationスクリプトを開きます。そこに、以下のように記述して下さい。

public class EnemyOperation : ActorOperation
{
   /*     省略     */
   private class Node
   {
       public Pos2D grid;
       public EDir direction;
       public int actualCost = 0;
       public int estimatedCost = 0;
       public Node parentNode = null;
   }
}

このクラスは現状EnemyOperationクラス以外で使う予定はないので、privateな内部クラスにします。

基準ノードをオープンする

A*アルゴリズムではノードをオープン(スコア計算済み)にしていく必要が有ります。
このスコアとは、ゴールまでに実際にかかっている距離(actualCost:実コスト)と、ノードの持っている座標から障害物を考えない場合のゴールまでの最短距離(estimatedCost:推定コスト)を足して合計したものになります。
この処理を実際にコードとして書くとこうなります。

actualCost = parentNode == null ? 0 : parentNode.actualCost + 1;
estimatedCost = (target.grid.x - grid.x) + (target.grid.z - grid.z);
int score = actualCost + estimatedCost;

周囲のノードをオープンにする

次にオープンしたノード(基準ノード)の持つ座標の、周囲の「移動できて且つコスト計算済みでない」座標を持つノードを作成、オープンにし、基準ノードはクローズド(オープンリストから削除)します。
なお新たにオープンしたノードは、親ノードに基準ノードを設定し、オープンリスト(スコア計算済みノードを集めたもの)に入れておきます。

foreach (EDir d in System.Enum.GetValues(typeof(EDir)))
{
   if (d == EDir.Pause) continue;
   Pos2D newGrid = DirUtil.GetNewGrid(grid, d);
   if (target.x == newGrid.x && target.z == newGrid.z) return this;
   if (nodeMap.Get(newGrid.x, newGrid.z) > 0) continue;
   Node node = new Node();
   node.grid = newGrid;
   node.direction = d;
   node.parentNode = this;
   openList.Add(node);
   nodeMap.Set(node.grid.x, node.grid.z, 1);
}

スコアが最も小さいノードをオープンする

今度はスコアが最も小さいノードを基準ノードにし、オープンリストから削除します。基準ノードの座標の周囲の座標を持つノードをオープンして、最もスコアの小さいノードを基準ノードにしてクローズドするといった一連の作業を繰り返します。

if (openList.Count < 1) return this;
openList = openList.OrderBy(n => (n.actualCost + n.estimatedCost)).ThenBy(n => n.actualCost).ToList();
Node baseNode = openList[0];
openList.RemoveAt(0);
return baseNode.OpenAroundNode(target, field, openList, nodeMap);

親ノードを辿って道順を得る

そうしてゴールまで辿り着いたら、ゴールのノードから親ノードを辿っていきます。すると、進むべき方向がわかります。

while (node.parentNode.parentNode != null) node = node.parentNode;
return node.direction;

実際に実装してみる

これらを踏まえた上で、再起的に実装したものがこちらです。
Nodeクラスにこれらのメソッドを加えて下さい。

// その前にEnemyOperationスクリプトの一番上に以下を追加して下さい!
using System.Collections.Generic;
using System.Linq;

/**
* A*アルゴリズムにて算出した方向を返す
*/
public EDir GetAstarNextDirection(Pos2D pos, Pos2D target, Field field)
{
   grid = new Pos2D();
   grid.x = pos.x;
   grid.z = pos.z;
   Array2D nodeMap = field.GetMapData();
   nodeMap.Set(grid.x, grid.z, 1);
   Node node = Astar(target, field, new List<Node>(), nodeMap);
   if (node.parentNode == null) return EDir.Pause;
   while (node.parentNode.parentNode != null) node = node.parentNode;
   return node.direction;
}

/**
* 再起的にA*アルゴリズムを計算し、結果を返す
*/
private Node Astar(Pos2D target, Field field, List<Node> openList, Array2D nodeMap)
{
   foreach (EDir d in System.Enum.GetValues(typeof(EDir)))
   {
       if (d == EDir.Pause) continue;
       Pos2D newGrid = DirUtil.GetNewGrid(grid, d);
       if (target.x == newGrid.x && target.z == newGrid.z) return this;
       if (nodeMap.Get(newGrid.x, newGrid.z) > 0) continue;
       Node node = new Node();
       node.grid = newGrid;
       node.direction = d;
       node.parentNode = this;
       node.actualCost = node.parentNode.actualCost + 1;
       node.actualCost += field.GetExistActor(node.grid.x, node.grid.z) == null ? 0 : field.enemies.transform.childCount * 2;
       node.estimatedCost = Mathf.Abs(target.x - node.grid.x) + Mathf.Abs(target.z - node.grid.z);
       openList.Add(node);
       nodeMap.Set(node.grid.x, node.grid.z, 1);
   }
   if (openList.Count < 1) return this;
   openList = openList.OrderBy(n => (n.actualCost + n.estimatedCost)).ThenBy(n => n.actualCost).ToList();
   Node baseNode = openList[0];
   openList.RemoveAt(0);
   return baseNode.Astar(target, field, openList, nodeMap);
}

今回実コストにプラスして加えているのは、「敵キャラクターがいればコストに全体の敵キャラクターの数×2を足す」というものです。
何故こんなことをしているかと言いますと、他の敵キャラクターを完全に動かないオブジェクトとして見てしまうと、現在動こうとしている敵はそれを迂回してプレイヤーに近づこうとします。それが部屋の場合ならまだいいですが、例えば通路内にプレイヤーまでの道のりを塞がれる形で敵キャラクターが存在している時に問題になります。

スクリーンショット 2020-05-05 22.29.59

こういう時、左の敵が進んで欲しい方向は右の敵に近づく方向です。しかしこの場合の最短経路は右の敵(この場合は障害物)を避けた反対方向になるのです。
これを改善する為に、敵を障害物として見なすのではなく、乗り越えられるけれどもそれだけコストがかかるものとして見なさせることで、迂回を防いでいます。
因みにこのコストの値、あまり大きすぎても意味がないし、かといって小さすぎると敵がプレイヤーの近くに来なくなってしまいます。悩んだのですが、今回は敵オブジェクトの数の2倍分だけ足すことで、ざっくりと設定することにしました。

A*アルゴリズムを使ってみる

前置きはこのくらいにしておいて、この経路探索のコードを使ってみようと思います。EnemyOperationクラスに、以下のメソッドを追加して下さい。

/**
* A*アルゴリズムを用いた行動AI
*/
private EAct AstarActionAI(ActorMovement actorMovement)
{
   EDir d = GetPlayerDirection(actorMovement);
   if (d == EDir.Pause)
   {
       d = AstarMovementAI(actorMovement);
       if (d == EDir.Pause) return EAct.KeyInput;
       actorMovement.SetDirection(d);
       if (actorMovement.IsMoveBegin()) return EAct.MoveBegin;
       return EAct.KeyInput;
   }
   actorMovement.SetDirection(d);
   return EAct.ActBegin;
}

/**
* A*アルゴリズムを用いた移動AI
*/
private EDir AstarMovementAI(ActorMovement actorMovement)
{
   Node node = new Node();
   return node.GetAstarNextDirection(actorMovement.grid, target.newGrid, GetComponentInParent<Field>());
}

そして、Operateメソッドの中身を以下のように変更します。

return AstarActionAI(actorMovement);

この状態でテストプレイします。
以下のように、どちらの敵もプレイヤーを追いかけてきたらOKです。

A*アルゴリズム

なお、このアルゴリズムは結構処理が重たいので、実際に使うには少々工夫が要ります。でもまあ、遠いところから敵がプレイヤー目掛けて追いかけてきたらそれはそれで怖いですから、当然ではありますね。

さて、本ステップのメインの実装は終わりました。しかし一つ問題が残っています。そう、シーケンス管理クラスの問題です。もしかしたらもうその問題に直面している方もいらっしゃるかもしれませんが、次回以降それを改善していきますのでよろしくお願いします。

この記事が気に入ったらサポートをしてみませんか?