地面と接触しているかを判定する [Unity2D]

Unityで2Dゲームを実装する際に、地面に立っているか・地面に触れているかどうかを判定するスクリプトを実装します。

# 実装

```CS:GroundCheck.cs
using Sirenix.OdinInspector;
using UnityEngine;
using Updates;

namespace Movements
{
    [DisallowMultipleComponent]
    public class GroundCheck : MonoBehaviour
    {
        [Title("初期値設定")] public float GroundLimitAngle = 30f;
        public float FootThreshold = 0.4f;
        // 検知できる衝突の最大数
        [SerializeField] private int maxCollisionCount = 8;
        [Title("デバッグ")] [SerializeField] [ReadOnly]
        private bool isGrounded = true;
        public bool IsGrounded => isGrounded;
        [SerializeField] [ReadOnly] private bool touchGround = false;
        public bool TouchGround => touchGround;
        [SerializeField] [ReadOnly] private Vector2 normal;
        public Vector2 Normal => normal;
        // 地面として扱わないゲームオブジェクト
        [ReadOnly] public GameObject NotGroundObject;

        private ContactPoint2D[] collisionContacts;
        private int currentContactCount = 0;
        private Transform tf;

        private void Awake()
        {
            tf = transform;
        }

        private void Start()
        {
            collisionContacts = new ContactPoint2D[maxCollisionCount];
        }

        public void FixedUpdate()
        {
            CheckGrounded();

            // フラグを下ろす
            currentContactCount = 0;
        }

        private void CheckGrounded()
        {
            // 最初にいくつあるかを数える
            // 衝突が1つだけなら、angleの判定を行う
            if (currentContactCount <= 0)
            {
                isGrounded = false;
                touchGround = false;
                return;
            }

            touchGround = true;

            if (currentContactCount == 1)
            {
                // 足元の判定を行う
                if ((collisionContacts[0].point - (Vector2)tf.position).y >= FootThreshold)
                {
                    isGrounded = false;
                    return;
                }

                var targetNormal = collisionContacts[0].normal;
                var angle = Vector2.Angle(Vector2.up, targetNormal);
                // 衝突対象がBoxCollider2Dで、頂点に衝突した場合
                // 前回の状態を維持する
                if (collisionContacts[0].collider is BoxCollider2D boxCollider)
                {
                    var colliderAngle = Vector2.Angle(boxCollider.transform.up, targetNormal);
                    if (!Mathf.Approximately(colliderAngle % 90f, 0f))
                    {
                        return;
                    }
                }

                if (angle > GroundLimitAngle)
                {
                    isGrounded = false;
                }
                else
                {
                    isGrounded = true;
                    normal = targetNormal;
                }

                return;
            }
            else
            {
                var averageNormal = Vector2.zero;
                var minNormal = Vector2.zero;
                var minAngle = float.MaxValue;
                var normalCount = 0;
                for (var i = 0; i < currentContactCount; i++)
                {
                    // 足元の判定を行う
                    if ((collisionContacts[i].point - (Vector2)tf.position).y >= FootThreshold)
                    {
                        continue;
                    }

                    var n = collisionContacts[i].normal;
                    var a = Vector2.Angle(Vector2.up, n);
                    if (a < minAngle)
                    {
                        minNormal = n;
                        minAngle = a;
                    }

                    averageNormal += n;
                    normalCount++;
                }

                averageNormal /= normalCount;
                var averageAngle = Vector2.Angle(Vector2.up, averageNormal);
                Vector2 currentNormal;
                var currentAngle = 0f;
                if (minAngle < averageAngle)
                {
                    currentNormal = minNormal;
                    currentAngle = minAngle;
                }
                else
                {
                    currentNormal = averageNormal;
                    currentAngle = averageAngle;
                }

                if (currentAngle > GroundLimitAngle)
                {
                    isGrounded = false;
                }
                else
                {
                    isGrounded = true;
                    normal = currentNormal;
                }
            }
        }

        private void OnCollisionStay2D(Collision2D col)
        {
            TryGetGround(col);
        }

        private void TryGetGround(Collision2D col)
        {
            if (NotGroundObject != null && col.gameObject == NotGroundObject) return;

            collisionContacts[currentContactCount] = col.contacts[0];
            currentContactCount++;
        }
    }
}
```

# 使い方
接触判定を行いたいGameObjectにアタッチして使います。
`IsTouchGround`はどこでかを問わず他の物に接触しているかどうかを、`IsGround`プロパティは実際に地面に立っているかどうかを返します。
`Normal`プロパティは自分が今立っている地面の法線ベクトルを返します。