こんな感じに動作します。
画像のような感じで簡単な障害物なら迂回しつつ、対象にたどり着いたら周囲を旋回するような動きをします。
2Dゲームの敵キャラクターのAIなどに使える動きかと思います。
サンプルプロジェクト
https://github.com/mojopon/SteeringBehaviour(Spriteファイルは再配布できなかったので簡単な図形Spriteで代用しています)
プロジェクトをダウンロードするか、クローンしてご利用ください。Scenesフォルダの中のSteeringBehaviourシーンで追尾スクリプトの動作確認ができます。
以下、このサンプルプロジェクトのコードについて解説します。
SteeringAlgorithm.cs
対象に近づくためのアルゴリズムをまとめたクラスです。
前提として移動方向を16方向に分割して、Apply~で始まる各メソッドでそれぞれのアルゴリズムに従って、16方向に対する重みづけを行います。
最終的に、最も重みの大きい(もっとも対象に近づけると判断された)方向が選ばれてGetMovement()で返されます。
長いですが、コードは以下のような感じです。
using System.Collections.Generic;
using UnityEngine;
public class SteeringAlgorithm
{
private float[] currentWeightMap = new float[Directions.AllDirections.Count];
private int previousDirection = -1;
// 内積を使って対象に最も近づける移動方向に近い順に重みづけをする
public void ApplySeek(Vector2 self, Vector2 target, float intensity = 1f)
{
var weightMap = CalculateSeek(self, target, intensity);
ApplyWeightMap(weightMap);
}
private float[] CalculateSeek(Vector2 self, Vector2 target, float intensity)
{
var weightMap = new float[Directions.AllDirections.Count];
Vector2 displacement = target - self;
for (int i = 0; i < weightMap.Length; i++)
{
float dot = Vector2.Dot(displacement.normalized, Directions.AllDirections[i]);
dot = (dot + 1) * 0.5f;
weightMap[i] = dot * intensity;
}
return weightMap;
}
// 対象から離れる移動方向を優先で重みづけをする
public void ApplyFlee(Vector2 self, Vector2 target, float intensity = 1f)
{
var displacement = target - self;
var opposite = self - displacement;
var weights = CalculateSeek(self, opposite, intensity);
ApplyWeightMap(weights);
}
// 対象に平行移動するような移動方向の重みづけをする
public void ApplyStrife(Vector2 self, Vector2 target, float intensity = 0.1f)
{
var weightMap = new float[Directions.AllDirections.Count];
Vector2 displacement = target - self;
for (int i = 0; i < weightMap.Length; i++)
{
float dot = Vector2.Dot(displacement.normalized, Directions.AllDirections[i]);
var modifier = 1.0f - Mathf.Pow(Mathf.Abs(dot + 0.25f), 2.0f);
var result = (dot + 1) * 0.5f * modifier * intensity;
weightMap[i] = result;
}
ApplyWeightMap(weightMap);
}
// 1フレーム前の移動方向を軸とした重みづけをする
public void ApplyBias(Vector2 self, int preferedDirection, float intensity = 0.3f)
{
if (preferedDirection != -1)
{
var displacement = Directions.AllDirections[preferedDirection];
ApplySeek(self, self + displacement, intensity);
}
}
public void ApplyBias(Vector2 self, float intensity = 0.3f)
{
ApplyBias(self, previousDirection, intensity);
}
// 障害物をよける重みづけを行う
// 対象が移動可能かを示したint配列を受け取って、指定された方向は無視する処理を行う
public void ApplyCollisionAvoidance(int[] blockedDirections)
{
foreach (var directionNumber in blockedDirections)
{
currentWeightMap[directionNumber] = Mathf.NegativeInfinity;
}
}
// Neighbor(他キャラ)から離れるような移動を行うための重みづけを行う
public void ApplySeparation(Vector2 self, List<Vector2> neighbors, float separationRange, float intensity = 8f)
{
if (neighbors.Count == 0 || 0 >= intensity) return;
Vector2 away = Vector2.zero;
foreach (var neighbor in neighbors)
{
var displacement = self - neighbor;
var distance = Vector2.Distance(self, neighbor);
if (0.05f > distance)
{
away += Directions.AllDirections[Random.Range(0, Directions.AllDirections.Count)];
}
else if (separationRange > distance)
{
var factor = 1.0f - (distance / separationRange);
away += displacement.normalized * factor;
}
}
if (away == Vector2.zero)
{
return;
}
else
{
ApplySeek(self, self + away, intensity);
}
}
// 重みを適用
private void ApplyWeightMap(float[] weightMap)
{
if (weightMap.Length == 0) return;
for (int i = 0; i < weightMap.Length; i++)
{
if (weightMap[i] == Mathf.NegativeInfinity)
{
continue;
}
if (weightMap[i] == Mathf.NegativeInfinity)
{
currentWeightMap[i] = Mathf.NegativeInfinity;
}
else
{
currentWeightMap[i] += weightMap[i];
}
}
}
// 重みマップを初期化
public void ClearCurrentWeightMap()
{
currentWeightMap = new float[Directions.AllDirections.Count];
}
// 最も重みの大きい移動方向を返す
public Vector2 GetMovement()
{
int desiredDirection = GetDesiredDirectionNumber();
if (desiredDirection != -1)
{
previousDirection = desiredDirection;
return Directions.AllDirections[desiredDirection];
}
else
{
previousDirection = -1;
return Vector2.zero;
}
}
public int GetDesiredDirectionNumber()
{
int desiredDirection = -1;
float max = 0f;
for (int i = 0; i < currentWeightMap.Length; i++)
{
if (currentWeightMap[i] > max)
{
max = currentWeightMap[i];
desiredDirection = i;
}
}
return desiredDirection;
}
public float[] GetWeightMap()
{
return currentWeightMap;
}
}
public static class Directions
{
public static List<Vector2> AllDirections = new List<Vector2>{
new Vector2(0,1).normalized,
(new Vector2(0,1).normalized + new Vector2(1,1).normalized).normalized,
new Vector2(1,1).normalized,
(new Vector2(1,1).normalized + new Vector2(1,0).normalized).normalized,
new Vector2(1,0).normalized,
(new Vector2(1,0).normalized + new Vector2(1,-1).normalized).normalized,
new Vector2(1,-1).normalized,
(new Vector2(1,-1).normalized + new Vector2(0,-1).normalized).normalized,
new Vector2(0,-1).normalized,
(new Vector2(0,-1).normalized + new Vector2(-1,-1).normalized).normalized,
new Vector2(-1,-1).normalized,
(new Vector2(-1,-1).normalized + new Vector2(-1,0).normalized).normalized,
new Vector2(-1,0).normalized,
(new Vector2(-1,0).normalized + new Vector2(-1,1).normalized).normalized,
new Vector2(-1,1).normalized,
(new Vector2(-1,1).normalized + new Vector2(0,1).normalized).normalized,
};
}
SteeringBehaviour.cs
SteeringAlgorithmクラスを利用して移動方向を取得し、その移動方向に基づいてGameObject(キャラクター)を動かすクラスです。
コードは以下のような感じです。
using UnityEngine;
public class SteeringBehaviour : MonoBehaviour
{
[SerializeField]
private Transform targetTransform;
[SerializeField]
private Detector detector;
[SerializeField]
private GizmoDrawer gizmoDrawer;
public float Speed = 1.5f;
public float PursueOffset = 0.25f;
public float PursueDistanceMax = 1.2f;
public float PursueDistanceMin = 0.8f;
public float CircleCastSize = 0.25f;
public float CircleCastRange = 1f;
public float StrifeFactor = 0.1f;
public float BiasStrength = 0.1f;
public float SeparationRange = 0.5f;
private Rigidbody2D rb2d;
private SteeringAlgorithm algorithm = new SteeringAlgorithm();
private Vector2 movement;
private void Awake()
{
rb2d = GetComponent<Rigidbody2D>();
}
private void Update()
{
if (targetTransform == null) return;
// 重みのリセット
algorithm.ClearCurrentWeightMap();
var self = transform.position;
var target = targetTransform.position;
// 障害物がある場合はよける重みづけを行う
// 障害物の検知はDetectorで行い、ブロックされた方向の情報をSteeringAlgorithmに渡す
var blockedDirections = detector.DetectAllCollisionsWithCircleCast(self, CircleCastSize, CircleCastRange);
algorithm.ApplyCollisionAvoidance(blockedDirections.ToArray());
var distance = Vector2.Distance(self, target);
// 対象までの距離がPursueDistanceMaxより大きい場合は、対象に近づく&対象から横に平行移動する重みづけを行う
if (distance > PursueDistanceMax)
{
algorithm.ApplySeek(self, target);
algorithm.ApplyStrife(self, target, StrifeFactor);
}
// PursueDistanceMinより距離が近い場合は、対象から離れる重みづけを行う
else if (PursueDistanceMin > distance)
{
algorithm.ApplyFlee(self, target);
}
// それ以外は対象から横に並行移動する重みづけを行う
else
{
algorithm.ApplyStrife(self, target, StrifeFactor);
}
// 前回移動した移動方向を軸に重みづけを行う(スタックの防止)
algorithm.ApplyBias(self);
// 近距離の他キャラから離れる重みづけを行う
var neighbors = detector.DetectNeighborsWithOverlapCircle(self, SeparationRange);
algorithm.ApplySeparation(self, neighbors, SeparationRange);
// 最終的に割り出された移動方向をセット
SetMove(algorithm.GetMovement());
// 重みづけマップのギズモを描画する(重みの可視化)
gizmoDrawer.SetData(algorithm.GetWeightMap());
}
// Rigidbody2dでの移動はFixedUpdateに行う
private void FixedUpdate()
{
Move();
}
private void SetMove(Vector2 direction)
{
movement = direction;
}
private void Move()
{
if (movement == Vector2.zero) return;
rb2d.MovePosition(transform.position + ((Vector3)movement * Time.fixedDeltaTime * Speed));
movement = Vector2.zero;
}
}
対象に近づく動き(Apply Seek)
対象を追尾する動きを実現するために内積(Vector2.dot)を利用しています。
詳しい説明は省きますが、ここでのざっくりとした考え方としてはどの方向に進めば追跡対象に最も近づけるかを計算して割り出している、といった感じです。
基本的には、対象に最も近くなる移動方向を計算で割り出して、その方向に進んでいくという考え方です。
public void ApplySeek(Vector2 self, Vector2 target, float intensity = 1f)
{
var weightMap = CalculateSeek(self, target, intensity);
ApplyWeightMap(weightMap);
}
private float[] CalculateSeek(Vector2 self, Vector2 target, float intensity)
{
var weightMap = new float[Directions.AllDirections.Count];
Vector2 displacement = target - self;
for (int i = 0; i < weightMap.Length; i++)
{
float dot = Vector2.Dot(displacement.normalized, Directions.AllDirections[i]);
dot = (dot + 1) * 0.5f;
weightMap[i] = dot * intensity;
}
return weightMap;
}
対象から離れる動き(Apply Flee)
単純に、ターゲットとは反対方向にApply Seekをすることで離れる動きを実現しています。
public void ApplyFlee(Vector2 self, Vector2 target, float intensity = 1f)
{
var displacement = target - self;
var opposite = self - displacement;
var weights = CalculateSeek(self, opposite, intensity);
ApplyWeightMap(weights);
}
対象に対して横移動する動き(Apply Strife)
Apply Seekとあわせて横移動の重みをつけることで、前方への移動が不可能な場合に横移動が選択されるアルゴリズムを実現しています。
public void ApplyStrife(Vector2 self, Vector2 target, float intensity = 0.1f)
{
var weightMap = new float[Directions.AllDirections.Count];
Vector2 displacement = target - self;
for (int i = 0; i < weightMap.Length; i++)
{
float dot = Vector2.Dot(displacement.normalized, Directions.AllDirections[i]);
var modifier = 1.0f - Mathf.Pow(Mathf.Abs(dot + 0.25f), 2.0f);
var result = (dot + 1) * 0.5f * modifier * intensity;
weightMap[i] = result;
}
ApplyWeightMap(weightMap);
}
前回移動した方向に重みづけ(Apply Bias)
ApplySeekやApplyStrifeのみだと対象と自分の位置関係が特定のパターンの時に、次フレームは右移動、その次フレームは左移動となってその繰り返しになる、といったスタックが発生する場合があります。
Apply Biasで前回移動した方向を優先する重みづけを行うことでそのスタックの発生を防ぎます。
public void ApplyBias(Vector2 self, int preferedDirection, float intensity = 0.3f)
{
if (preferedDirection != -1)
{
var displacement = Directions.AllDirections[preferedDirection];
ApplySeek(self, self + displacement, intensity);
}
}
障害物をよける動き(Apply Collision Avoidance)
障害物によって特定の方向に移動できないケースでは、その重みづけを無効化する処理を行います。
例えば北方向に障害物がある場合はその移動方向は無視されるので他の移動方向が選ばれ、結果として障害物をよけるような動きが実現できます。
public void ApplyCollisionAvoidance(int[] blockedDirections)
{
foreach (var directionNumber in blockedDirections)
{
currentWeightMap[directionNumber] = Mathf.NegativeInfinity;
}
}
隣接した他キャラをよける動き(Apply Separation)
特定方向に他キャラが隣接してる場合はその方向とは反対方向を優先して移動するような重みづけをすることで、集団で対象を追尾する際にぶつかり合わないような動きを実現しています。
public void ApplySeparation(Vector2 self, List<Vector2> neighbors, float separationRange, float intensity = 8f)
{
if (neighbors.Count == 0 || 0 >= intensity) return;
Vector2 away = Vector2.zero;
foreach (var neighbor in neighbors)
{
var displacement = self - neighbor;
var distance = Vector2.Distance(self, neighbor);
if (0.05f > distance)
{
away += Directions.AllDirections[Random.Range(0, Directions.AllDirections.Count)];
}
else if (separationRange > distance)
{
var factor = 1.0f - (distance / separationRange);
away += displacement.normalized * factor;
}
}
if (away == Vector2.zero)
{
return;
}
else
{
ApplySeek(self, self + away, intensity);
}
}
以上の、SteeringAlgorithmクラス内の各メソッドで重みづけをした結果最も優先度が高かった方向がSteeringAlgorithm.GetMovement()で返されて、その方向に進むことで対象を追尾する動きを実現しています。
Detector.cs
UnityのRaycast関連クラスを利用してコリジョンを検知するためのクラスです。
特定方向に障害物があるかどうかとRaycastを使ってコリジョンを検知するかどうかは別の関心事です。前者は追跡のアルゴリズム、後者はUnityの実装に属しています。そこで追跡のアルゴリズムにUnityの実装が密結合しないように、コリジョン検知の手段を提供するクラスとしてアルゴリズム関連クラスとは分離しています。
あらかじめLayerMaskでコリジョンと他キャラのレイヤーを定義して、コリジョンを検知した結果を提供します。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Detector : MonoBehaviour
{
[SerializeField]
private LayerMask CollisionLayer;
[SerializeField]
private LayerMask NeighborLayer;
private Collider2D myCollider;
private void Awake()
{
myCollider = GetComponent<Collider2D>();
}
public List<Vector2> DetectNeighborsWithOverlapCircle(Vector2 sourcePosition, float radius)
{
List<Vector2> neighbors = new List<Vector2>();
Collider2D[] colliders = Physics2D.OverlapCircleAll(sourcePosition, radius, NeighborLayer);
foreach (var collider in colliders)
{
if (collider != myCollider)
{
neighbors.Add(collider.transform.position);
}
}
return neighbors;
}
public List<int> DetectAllCollisionsWithCircleCast(Vector2 self, float circleCastSize, float circleCastRange)
{
// CircleCastの邪魔にならないように自身のコライダーをオフにする
myCollider.enabled = false;
List<int> collisionDetectedDirections = new List<int>();
for (int i = 0; i < Directions.AllDirections.Count; i++)
{
var detected = DetectCollisionWithCircleCast(self, i, circleCastSize, circleCastRange);
if (detected)
{
collisionDetectedDirections.Add(i);
}
}
myCollider.enabled = true;
return collisionDetectedDirections;
}
public bool DetectCollisionWithCircleCast(Vector2 self, int directionNumber, float circleCastSize, float circleCastRange)
{
var direction = Directions.AllDirections[directionNumber];
RaycastHit2D hit = Physics2D.CircleCast(self, circleCastSize, direction, circleCastRange, CollisionLayer);
if (hit.collider != null)
{
return true;
}
else
{
return false;
}
}
}
GizmoDrawer.cs
前述のアルゴリズムで算出した重みをGizmoで可視化するためのクラスです。
Sceneウィンドウで確認できます。
using UnityEngine;
public class GizmoDrawer : MonoBehaviour
{
[SerializeField]
private bool drawGizmos = true;
private float[] weightMap = new float[0];
public void SetData(float[] weightMap)
{
this.weightMap = weightMap;
}
private void OnDrawGizmos()
{
if (Application.isPlaying)
{
if (!drawGizmos) return;
if (weightMap.Length == 0) return;
Gizmos.color = Color.white;
if (weightMap.Length == Directions.AllDirections.Count)
{
for (int i = 0; i < Directions.AllDirections.Count; i++)
{
if (0 > weightMap[i]) continue;
Gizmos.DrawRay(transform.position, Directions.AllDirections[i] * weightMap[i] * 2);
}
}
}
weightMap = new float[0];
}
}
参考