徳島ゲーム開発ごっこ 技術ブログ

ゲームを作るために役に立ったり立たなかったりする技術を学んでいきます!

【Unity】さあ、索敵をはじめよう

 現実世界の自分の敵はなかなか見つけにくいけど、ゲームの中ではサクッと敵を見つけ出したいものです。
 というわけで今回は索敵機能を作ります。
 敵、どこにいるのかなぁ、僕の敵。


この記事にはUnity2017.2.0f3を使用しています。

索敵するモノとされるモノ

 まずは索敵処理を記述するスクリプトを作りましょう。

SearchingBehavior.cs

using UnityEngine;
using System.Collections.Generic;

public class SearchingBehavior : MonoBehaviour
{
    public event System.Action<GameObject>  onFound = ( obj ) => {};
    public event System.Action<GameObject>  onLost  = ( obj ) => {};

} // class SearchingBehavior

 中身はまだ空っぽですが、対象者を見つけたり見失ったりしたら、イベントを返すようにします。
 このSearchingBehaviorを使用する索敵者スクリプトも作成しましょう。

Finder.cs

using UnityEngine;
using System.Collections.Generic;

public class Finder : MonoBehaviour
{
    [SerializeField]
    private Material    m_defaultMaterial   = null;
    [SerializeField]
    private Material    m_foundMaterial     = null;

    private Renderer            m_renderer  = null;
    private List<GameObject>    m_targets   = new List<GameObject>();


    private void Awake()
    {
        m_renderer  = GetComponentInChildren<Renderer>();

        var searching   = GetComponentInChildren<SearchingBehavior>();
        searching.onFound   += OnFound;
        searching.onLost    += OnLost;
    }

    private void OnFound( GameObject i_foundObject )
    {
        m_targets.Add( i_foundObject );
        m_renderer.material = m_foundMaterial;
    }

    private void OnLost( GameObject i_lostObject )
    {
        m_targets.Remove( i_lostObject );
        
        if( m_targets.Count == 0 )
        {
            m_renderer.material = m_defaultMaterial;
        }
    }

} // class Finder

 SearchingBehaviorが何かを見つけたら、このFinderがアタッチされているオブジェクトの色が変化します。
 !マークが出る演出でも作れば楽しいのですが、今回は割愛です。

 GameObjectの構成として、Finderの子オブジェクトにSearchingBehaviorがある形にします。
 これは後程、索敵用のコライダーなどを付けていく予定なので、索敵者とは別GameObjectの方が管理しやすそうなので。

 次に索敵される側であるオブジェクトを操作するスクリプトを作りましょう。

ObjectController.cs

using UnityEngine;

[RequireComponent( typeof( Rigidbody ) )]
public class ObjectController : MonoBehaviour
{
    [SerializeField]
    private float   m_moveSpeed     = 0.0f;
    [SerializeField]
    private float   m_turnSpeed     = 0.0f;
    [SerializeField]
    private float   m_jumpForce     = 0.0f;

    private Rigidbody   m_rigidbody     = null;


    private void Awake()
    {
        m_rigidbody = GetComponent<Rigidbody>();
    }

    private void Update()
    {
        ControlObject();
    }

    private void ControlObject()
    {
        Vector3 moveDir     = Vector3.zero;

        Vector3 forwardDir  = Vector3.forward;
        Vector3 rightDir    = Vector3.right;

        if( Input.GetKey( KeyCode.UpArrow ) )
        {
            moveDir += forwardDir;
        }
        if( Input.GetKey( KeyCode.DownArrow ) )
        {
            moveDir -= forwardDir;
        }
        if( Input.GetKey( KeyCode.RightArrow ) )
        {
            moveDir += rightDir;
        }
        if( Input.GetKey( KeyCode.LeftArrow ) )
        {
            moveDir -= rightDir;
        }

        if( moveDir.sqrMagnitude > Mathf.Epsilon )
        {
            moveDir = moveDir.normalized;
            Turn( moveDir );
            Move( moveDir );
        }

        if( Input.GetKeyDown( KeyCode.Space ) )
        {
            Jump();
        }
    }

    private void Move( Vector3 i_forward )
    {
        Vector3 delta       = i_forward * m_moveSpeed * Time.deltaTime;
        Vector3 targetPos   = transform.position + delta;
        m_rigidbody.MovePosition( targetPos );
    }

    private void Turn( Vector3 i_forward )
    {
        Quaternion  toRot   = Quaternion.LookRotation( i_forward );
        Quaternion  fromRot = transform.rotation;

        float delta             = m_turnSpeed * Time.deltaTime;
        Quaternion targetRot    = Quaternion.RotateTowards( fromRot, toRot, delta );

        m_rigidbody.MoveRotation( targetRot );
    }

    private void Jump()
    {
        // えっ、このままじゃ空中でもジャンプできちゃうって!?
        // 仕様だよ!

        m_rigidbody.velocity    = Vector3.zero;
        
        Vector3 jumpVec         = Vector3.up * m_jumpForce;
        m_rigidbody.AddForce( jumpVec, ForceMode.VelocityChange );
    }

} // class ObjectController

 索敵する脅威側を、自分で操作することになるとはなかなか妙な気もしますが、とりあえずこれで準備完了です。
f:id:urahimono:20171024072322p:plain

僕らは近くにあるものは何でもわかるんだ

 さて、ではどうやって"敵"を感知しましょうか。
 シンプルに考えれば、トリガーのコライダーに当たったかで判断するのが簡単そうです。
 索敵用GameObjectSphereColliderを追加し、スクリプトで受け取るようにしましょう。

SearchingBehavior.cs

private void OnTriggerEnter( Collider i_other )
{
    GameObject enterObject    = i_other.gameObject;
    onFound( enterObject );
}

private void OnTriggerExit( Collider i_other )
{
    GameObject exitObject   = i_other.gameObject;
    onLost( exitObject );
}

f:id:urahimono:20171024072334p:plain

f:id:urahimono:20171024072344g:plain
f:id:urahimono:20171024072414p:plain

 コライダーの範囲内に入った悪しきモノを感知できるようになりました。
 これでどんな脅威も怖くありません。
 今日もぐっすり寝むれそうです。

でも僕らは念能力者ではないんだ

 「纏」「練」の応用である「円」を使えば範囲内に入った全てを感知することができます。

f:id:urahimono:20171024072436g:plain

 ただ残念なことに僕らは念を使えません。
 索敵機能を少し弱める必要があります。
 まあ、いいじゃないですか。どうせ念を使えたところキメラアントに脳をいじくられるだけですよ。

 前方は感知できても、後方まで感知できない。
 それぐらいの索敵機能にしましょう。

 この感知できる範囲を、角度を指定して設定できるようにしましょう。
 前方に扇状の感知範囲が広がっているイメージですね。
 まずは角度情報をスクリプトに追加します。

SearchingBehavior.cs

[SerializeField, Range( 0.0f, 360.0f )]
private float   m_searchAngle   = 0.0f;

private SphereCollider  m_sphereCollider    = null;

public float SearchAngle
{
    get { return m_searchAngle; }
}

public float SearchRadius
{
    get { return m_sphereCollider.radius; }
}

private void Awake()
{
    m_sphereCollider    = GetComponent<SphereCollider>();
}

 ただ、角度の指定だけでは実際にどれぐらいの範囲なのかが分かりにくいので、ギズモを使って表示できるようにしましょう。
 まあ、これは前回使ったスクリプトをそのまま使う感じで大丈夫かと。

www.urablog.xyz

SearchingBehaviorGizmosEditor.cs

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

public static class SearchingBehaviorGizmosEditor
{
    private static readonly int TRIANGLE_COUNT  = 12;
    private static readonly Color MESH_COLOR    = new Color( 1.0f, 1.0f, 0.0f, 0.7f );


    [DrawGizmo( GizmoType.NonSelected | GizmoType.Selected )]
    private static void DrawPointGizmos( SearchingBehavior i_object, GizmoType i_gizmoType )
    {
        if( i_object.SearchRadius <= 0.0f )
        {
            return;
        }

        Gizmos.color = MESH_COLOR;

        Transform transform = i_object.transform;
        Vector3 pos         = transform.position + Vector3.up * 0.01f; // 0.01fは地面と高さだと見づらいので調整用。
        Quaternion rot      = transform.rotation;
        Vector3 scale       = Vector3.one * i_object.SearchRadius;


        if( i_object.SearchAngle > 0.0f )
        {
            Mesh fanMesh    = CreateFanMesh( i_object.SearchAngle, TRIANGLE_COUNT );

            Gizmos.DrawMesh( fanMesh, pos, rot, scale );
        }
    }

    private static Mesh CreateFanMesh( float i_angle, int i_triangleCount )
    {
        var mesh    = new Mesh();

        var vertices    = CreateFanVertices( i_angle, i_triangleCount );

        var triangleIndexes = new List<int>( i_triangleCount * 3 );

        for( int i = 0; i < i_triangleCount; ++i )
        {
            triangleIndexes.Add( 0 );
            triangleIndexes.Add( i + 1 );
            triangleIndexes.Add( i + 2 );
        }

        mesh.vertices   = vertices;
        mesh.triangles  = triangleIndexes.ToArray();

        mesh.RecalculateNormals();

        return mesh;
    }

    private static Vector3[] CreateFanVertices( float i_angle, int i_triangleCount )
    {
        if( i_angle <= 0.0f )
        {
            throw new System.ArgumentException( string.Format( "角度がおかしい! i_angle={0}", i_angle ) );
        }

        if( i_triangleCount <= 0 )
        {
            throw new System.ArgumentException( string.Format( "数がおかしい! i_triangleCount={0}", i_triangleCount ) );
        }

        i_angle = Mathf.Min( i_angle, 360.0f );

        var vertices    = new List<Vector3>( i_triangleCount + 2 );

        // 始点
        vertices.Add( Vector3.zero );

        // Mathf.Sin()とMathf.Cos()で使用するのは角度ではなくラジアンなので変換しておく。
        float radian    = i_angle * Mathf.Deg2Rad;
        float startRad  = -radian / 2;
        float incRad    = radian / i_triangleCount;

        for( int i = 0; i < i_triangleCount + 1; ++i )
        {
            float currentRad    = startRad + ( incRad * i );

            Vector3 vertex = new Vector3( Mathf.Sin( currentRad ), 0.0f, Mathf.Cos( currentRad ) );
            vertices.Add( vertex );
        }

        return vertices.ToArray();
    }

} // class SearchingBehaviorGizmosEditor

f:id:urahimono:20171024072523p:plain

UnassignedReferenceException: The variable m_sphereCollider of SearchingBehavior has not been assigned.
You probably need to assign the m_sphereCollider variable of the SearchingBehavior script in the inspector.
SearchingBehavior.get_SearchRadius () (at Assets/SearchingBehavior.cs:26)
SearchingBehaviorGizmosEditor.DrawPointGizmos (.SearchingBehavior i_object, GizmoType i_gizmoType) (at Assets/Editor/SearchingBehaviorGizmosEditor.cs:19)

 めっさエラーがでた。
 あー……、そうかエディタ中にAwake()が通ってないから、m_sphereColliderがまだnullのままかー。
仕方ありません。SearchRadiusを修正しましょう。

SearchingBehavior.cs

public float SearchRadius
{
    get
    {
        if( m_sphereCollider == null )
        {
            m_sphereCollider    = GetComponent<SphereCollider>();
        }
        return m_sphereCollider != null ? m_sphereCollider.radius : 0.0f;
    }
}

f:id:urahimono:20171024072609p:plain

 あんまり綺麗な処理ではないですが、ギズモが出るようになりました。
 あとはこの範囲内に悪意のある何かがある場合には、「見つけた」と判定するようにしましょう。

 自分の進行方向ベクトルと索敵対象オブジェクトへの方向ベクトルの内積を取ればcosθが取得できます。
f:id:urahimono:20171024072651p:plain

 ちなみに内積うんぬんに関しては以下の方々のページがわかりやすいです。
【数学】「内積」の意味をグラフィカルに理解すると色々見えてくる その1 - Qiita
基礎の基礎編その1 内積と外積の使い方

 指定した角度は、進行方向を中心に左右に半分の角度ずつ分かれます。
 内積では右回り左回りの判定はとれないですし、そもそもとる必要もないので、判定するのは指定した半分の角度でよさそうです。

f:id:urahimono:20171024072713p:plain

 あとはMathf.Cos()で指定した角度のコサイン値を所得すればいいのですが、渡すのは角度ではなくラジアン値なので、ラジアンに変換しましょう。
 コサイン値は 0°~ 90°~ 180°の動きが 1 ~ 0 ~ -1 になります。
 そのため、指定した角度のコサイン値より、内積で出たコサイン値が大きければ、角度内にあると判定できそうです。

 というわけで指定する角度情報をコサイン値に変更しましょう。
 ただ、毎回計算するのも面倒くさい。
 確かOnValidate()がインスペクター上でシリアライズされた変数値を変更したら通知してくれるはずなので、それを利用してコサイン値を保存しておきましょう。

SearchingBehavior.cs

private float   m_searchCosTheta    = 0.0f;

private void Awake()
{
    m_sphereCollider    = GetComponent<SphereCollider>();
    ApplySearchAngle();
}

// シリアライズされた値がインスペクター上で変更されたら呼ばれます。
private void OnValidate()
{
    ApplySearchAngle();
}

private void ApplySearchAngle()
{
    float searchRad     = m_searchAngle * 0.5f * Mathf.Deg2Rad;
    m_searchCosTheta    = Mathf.Cos( searchRad );
}

 さて、次に角度範囲計算処理を書きたい……ところですが、その前にやることが。
 角度範囲計算処理はコライダー内にいるオブジェクトに対して毎回判定してあげる必要があります。
 ということは、コライダー内にいるオブジェクトを覚えておく必要があります。
 しかも「見つけた」「見失った」と判定するには単純にオブジェクトをリストで持つだけでは情報として足りない気がします。
 というわけで、発見状態を管理するクラスを作成しましょう。

SearchingBehavior.cs

private class FoundData
{
    public FoundData( GameObject i_object )
    {
        m_obj   = i_object;
    }

    private GameObject  m_obj   = null;
    private bool    m_isCurrentFound    = false;
    private bool    m_isPrevFound       = false;

    public GameObject   Obj
    {
        get { return m_obj; }
    }

    public Vector3  Position
    {
        get { return Obj != null ? Obj.transform.position : Vector3.zero; }
    }

    public void Update( bool i_isFound )
    {
        m_isPrevFound       = m_isCurrentFound;
        m_isCurrentFound    = i_isFound;
    }

    public bool IsFound()
    {
        return m_isCurrentFound && !m_isPrevFound;
    }

    public bool IsLost()
    {
        return !m_isCurrentFound && m_isPrevFound;
    }

    public bool IsCurrentFound()
    {
        return m_isCurrentFound;
    }
}

 前回見つからず、今回見つかった場合は「見つけた」と判断し、
 今回見つからず、前回見つかった場合は「見失った」と判断する。
 あとはこのクラスのリストをコライダーに反応した場合に、追加削除を行うようにしましょう。

SearchingBehavior.cs

private List<FoundData> m_foundList = new List<FoundData>();

private void OnDisable()
{
    m_foundList.Clear();
}

private void OnTriggerEnter( Collider i_other )
{
    GameObject enterObject    = i_other.gameObject;

    // 念のため多重登録されないようにする。
    if( m_foundList.Find( value => value.Obj == enterObject ) == null )
    {
        m_foundList.Add( new FoundData( enterObject ) );
    }
}

private void OnTriggerExit( Collider i_other )
{
    GameObject exitObject   = i_other.gameObject;

    var foundData   = m_foundList.Find( value => value.Obj == exitObject );
    if( foundData == null )
    {
        return;
    }

    if( foundData.IsCurrentFound() )
    {
        onLost( foundData.Obj );
    }

    m_foundList.Remove( foundData );
}

 んー……、少しずつ処理が複雑になってきた。
 大丈夫かな。

 そしてようやく角度判定の処理を追加しましょう。

SearchingBehavior.cs

private void Update()
{
    UpdateFoundObject();
}

private void UpdateFoundObject()
{
    foreach( var foundData in m_foundList )
    {
        GameObject  targetObject = foundData.Obj;
        if( targetObject == null )
        {
            continue;
        }

        bool isFound    = CheckFoundObject( targetObject );
        foundData.Update( isFound );

        if( foundData.IsFound() )
        {
            onFound( targetObject );
        }
        else if( foundData.IsLost() )
        {
            onLost( targetObject );
        }
    }
}

private bool CheckFoundObject( GameObject i_target )
{
    Vector3 targetPosition      = i_target.transform.position;
    Vector3 myPosition          = transform.position;

    Vector3 myPositionXZ        = Vector3.Scale( myPosition, new Vector3( 1.0f, 0.0f, 1.0f ) );
    Vector3 targetPositionXZ    = Vector3.Scale( targetPosition, new Vector3( 1.0f, 0.0f, 1.0f ) );

    Vector3 toTargetFlatDir = ( targetPositionXZ - myPositionXZ ).normalized;
    Vector3 myForward       = transform.forward;
    if( !IsWithinRangeAngle( myForward, toTargetFlatDir, m_searchCosTheta ) )
    {
        return false;
    }

    return true;
}

private bool IsWithinRangeAngle( Vector3 i_forwardDir, Vector3 i_toTargetDir, float i_cosTheta )
{
    // 方向ベクトルが無い場合は、同位置にあるものだと判断する。
    if( i_toTargetDir.sqrMagnitude <= Mathf.Epsilon )
    {
        return true;
    }

    float dot = Vector3.Dot( i_forwardDir, i_toTargetDir );
    return dot >= i_cosTheta;
}

f:id:urahimono:20171024072728g:plain
f:id:urahimono:20171024072750p:plain

 よかった。うまく動いている!
 あー……、でも今回は角度の判定をXZ平面上でしか行ってないなぁ。
 高さも同じように処理すれば出来るっちゃ出来るけど……。
 面倒くさいからいいや。
 高さ判定はコライダーのみで。

でも僕らは透視能力者ではないんだ

 これで前方の脅威のみ見つけることが出来るようになりました。
 若干弱体化しましたが、こんなものではないでしょうか。
 前方の一定の距離内ならば、敵を見つけることができます。

 たとえ目の前に壁があったとしても!

 あー……、見えないね。壁があったら。

 残念ながら、まだ索敵機能を弱体化する必要がありそうです。
 これは仕方ありません。
 透視能力なんてものがあったら、世の中が覗き魔で溢れかえってしまいます。
 迷惑防止条例を守るためにも、障害物がある場合は見つけられないようにしましょう。

 とりあえず適当な障害物を置いてみます。

f:id:urahimono:20171024072803p:plain

 発見しました。

 ……いや、いいんですよ。そういうボケは。
 ”敵”のみを見つけてもらえば。

 とはいえこの索敵者は”害あるもの””無害なもの”かの判定ができません。
 どうやらレイヤーを追加する必要がありそうですね。

f:id:urahimono:20171024072816p:plain
f:id:urahimono:20171024072825p:plain

 レイヤーも追加しましたし、レイでもぶっ飛ばしましょうか。
 レイを”害あるもの”に飛ばし、別の何かに当たった場合は、何か途中で障害物があったと判定しましょう。

SearchingBehavior.cs

private bool CheckFoundObject( GameObject i_target )
{
    Vector3 targetPosition      = i_target.transform.position;
    Vector3 myPosition          = transform.position;

    Vector3 myPositionXZ        = Vector3.Scale( myPosition, new Vector3( 1.0f, 0.0f, 1.0f ) );
    Vector3 targetPositionXZ    = Vector3.Scale( targetPosition, new Vector3( 1.0f, 0.0f, 1.0f ) );

    Vector3 toTargetFlatDir = ( targetPositionXZ - myPositionXZ ).normalized;
    Vector3 myForward       = transform.forward;

    if( !IsWithinRangeAngle( myForward, toTargetFlatDir, m_searchCosTheta ) )
    {
        return false;
    }

    Vector3 toTargetDir = ( targetPosition - myPosition ).normalized;

    if( !IsHitRay( myPosition, toTargetDir, i_target ) )
    {
        return false;
    }

    return true;
}

private bool IsHitRay( Vector3 i_fromPosition, Vector3 i_toTargetDir, GameObject i_target )
{
    // 方向ベクトルが無い場合は、同位置にあるものだと判断する。
    if( i_toTargetDir.sqrMagnitude <= Mathf.Epsilon )
    {
        return true;
    }

    RaycastHit onHitRay;
    if( !Physics.Raycast( i_fromPosition, i_toTargetDir, out onHitRay, SearchRadius ) )
    {
        return false;
    }

    if( onHitRay.transform.gameObject != i_target )
    {
        return false;
    }

    return true;
}

f:id:urahimono:20171024072841g:plain
f:id:urahimono:20171024072912p:plain

 いいですね。
 壁の後ろに隠れられると見つけられなくなりました。

 今回は対象オブジェクトが少ないので端折りましたが、本来はレイを飛ばす際はレイヤーマスクを指定して、レイに当たるオブジェクトを絞ったほうが処理は軽いです。

結局僕らの敵はなんなのだろう

 んー、そこそこのスクリプト量になってしまいました。
 ガンガン正規化を掛けていますが、そんなに軽い処理じゃないから、もう少し整理したほうがいいような……。

 まあ、とにかくこれで敵を見つけることができるようになったはずです。
 探しましょう、僕らの人生の敵とやらを……。

【Unity】さあ、索敵をはじめよう