エフアンダーバー

個人開発の記録

【Unity】 2Dアクション用のステージギミック

こちらの動画で2DアクションならではのギミックをUnity上で作成する様子をみて、 もう少しなんとかならないかなと思ったので自分でもつくってみました。

おしながき

今回作成したのは動画上にて紹介されていた以下の二つです。

  • 一方向からのみ乗れる床
  • 上下あるいは左右に動き、何かを押しつぶしそうになると移動方向が逆になるブロック

一方通行の床はまさに2Dアクションならではというギミックで、 キャラクターの移動方向によって床と衝突するか否かが変わります。 床とキャラクターとは一対多の関係なので単純に床の判定を消すだけでは実装できません。

方向転換するブロックはキャラクターを押しつぶさないという点でゲームとしては良心的ですが、 この押しつぶさないという処理がプログラミング上の大きな障害となります。 キャラクターの後ろに空間があるならばキャラクターを押し出し、 ないならば押すのをやめて反転するという仕組みとなるため、 空間があるかどうかをいかに判定するかがカギとなってきます。

注意事項

この記事での実装はギミックをつくる上で都合のいい仮定を置いています。 どんな構造のゲームにも適用できるわけではないので、 自身の環境で動かないようであれば改造するなりすっぱりあきらめるなりしてください。

記事中のコードは自由に使ってもらって構いませんが自己責任でお願いします。

一方通行の床

f:id:fspace:20151121021920g:plain

一方通行の床に関しては実はUnityにそのためのコンポーネントがあります。 ですので、実装というより機能の紹介になります。

PlatformEffector2D

Unity5になって2Dの物理演算にXXXEffector2Dという名前のコンポーネントがいくつか追加されました。 PlatformEffector2Dはそのうちのひとつで、一方向の衝突と側面の摩擦の無効化をサポートします。 XXXEffector2DはCollider2Dと組み合わせて使用します。

f:id:fspace:20151121023129p:plain

まずPlatformEffector2DをCollider2D系のコンポーネントがアタッチされたゲームオブジェクトにアタッチします。 その後、Collider2Dの"Used By Effector"にチェックを入れればPlatformEffector2Dが有効になります。

PlatformEffector2D自体の設定は"Use One Way"にチェックを入れて一方向の衝突を有効にし、 必要に応じて"Surface Arc"の値を変更します。 "Surface Arc"はどのくらいの角度までを面とみなすか、 つまりはキャラクターが接近してきた面がどれくらいの角度のときまでを衝突とするかを表します。 ここではBoxCollider2Dで真上以外は必要ないので0を設定しています。

方向転換するブロック

f:id:fspace:20151121024934g:plain

動画では質量を調整するなどして物理的な挙動を保ちながら実装をしていましたが、 こちらではRigidbody2Dの"Is Kinematic"のチェックを入れて非物理的な挙動で実装してみたいと思います。

仕組み

f:id:fspace:20151121025700p:plain

仕組みとしては適当な量のレイをブロックの前方に飛ばして、 当たったオブジェクトの一覧から前方にあとどれくらいの空間があるのかを把握するという方法になります。 空間が移動量以上にあれば移動しても問題ないと判断して移動し、移動量以下であればこれ以上は移動できないと判断し反転します。

ソースコード

少々長いですが、実装コードです。

using System.Diagnostics;
using UnityEngine;

[RequireComponent(typeof(BoxCollider2D), typeof(Rigidbody2D))]
public class MovingBlockControl : MonoBehaviour
{
   #region Constants

    private const int MaxRaycastHit = 8;

   #endregion

   #region Fields

    [SerializeField]
    private bool m_IsVertical;

    [SerializeField]
    private float m_Speed;

    [SerializeField]
    private int m_RaySize;

    [SerializeField]
    private float m_MaxRayDistance;

    [SerializeField]
    private LayerMask m_Collidable;

    [SerializeField]
    private LayerMask m_StaticCollidable;

    private BoxCollider2D m_BoxCollider;

    private Rigidbody2D m_Rigidbody;

    private readonly RaycastHit2D[] m_Results = new RaycastHit2D[MaxRaycastHit];

   #endregion

   #region Properties

    public bool IsVertical
    {
        get { return m_IsVertical; }
        set { m_IsVertical = value; }
    }

    public float Speed
    {
        get { return m_Speed; }
        set { m_Speed = value; }
    }

    public int RaySize
    {
        get { return m_RaySize; }
        set { m_RaySize = value; }
    }

    public float MaxRayDistance
    {
        get { return m_MaxRayDistance; }
        set { m_MaxRayDistance = value; }
    }

    public LayerMask Collidable
    {
        get { return m_Collidable; }
        set { m_Collidable = value; }
    }

    public LayerMask StaticCollidable
    {
        get { return m_StaticCollidable; }
        set { m_StaticCollidable = value; }
    }

   #endregion

   #region Messages

    private void Awake()
    {
        this.m_BoxCollider = GetComponent<BoxCollider2D>();
        this.m_Rigidbody = GetComponent<Rigidbody2D>();

        m_Rigidbody.isKinematic = true;
        m_Rigidbody.sleepMode = RigidbodySleepMode2D.NeverSleep;
    }

    private void FixedUpdate()
    {
        Move();
    }

    private void OnValidate()
    {
        m_RaySize = Mathf.Max(m_RaySize, 1);
        m_MaxRayDistance = Mathf.Max(m_MaxRayDistance, 0.0f);

        Rigidbody2D rigidbody = GetComponent<Rigidbody2D>();
        rigidbody.isKinematic = true;
        rigidbody.sleepMode = RigidbodySleepMode2D.NeverSleep;
    }

   #endregion

   #region Methods

    private void Move()
    {
        Vector2 start, direction, range;

        CollectRayInfo(out start, out range, out direction);

        Vector2 spacing = (m_RaySize > 1 ? range / (m_RaySize - 1) : Vector2.zero);
        for (int i = 0; i < m_RaySize; i++)
        {
            Vector2 origin = start + spacing * i;

            if (!CheckSpace(origin, direction))
            {
                m_Speed = -m_Speed;

                return;
            }
        }

        Vector2 nextPosition = m_Rigidbody.position + direction * Mathf.Abs(m_Speed) * Time.fixedDeltaTime;

        m_Rigidbody.MovePosition(nextPosition);
    }


    private void CollectRayInfo(out Vector2 start, out Vector2 range, out Vector2 direction)
    {
        Bounds bounds = m_BoxCollider.bounds;
        Rect rect = new Rect(bounds.min, bounds.size);
        if (m_IsVertical)
        {
            if (m_Speed >= 0.0f)
            {
                start = new Vector2(rect.xMin, rect.yMax + Vector2.kEpsilon) + Vector2.right * Vector2.kEpsilon;
                range = Vector2.right * (rect.width - Vector2.kEpsilon * 2.0f);
                direction = Vector2.up;
            }
            else
            {
                start = new Vector2(rect.xMin, rect.yMin - Vector2.kEpsilon) + Vector2.right * Vector2.kEpsilon;
                range = Vector2.right * (rect.width - Vector2.kEpsilon * 2.0f);
                direction = Vector2.down;
            }
        }
        else
        {
            if (m_Speed >= 0.0f)
            {
                start = new Vector2(rect.xMax + Vector2.kEpsilon, rect.yMin) + Vector2.up * Vector2.kEpsilon;
                range = Vector2.up * (rect.height - Vector2.kEpsilon * 2.0f);
                direction = Vector2.right;
            }
            else
            {
                start = new Vector2(rect.xMin - Vector2.kEpsilon, rect.yMin) + Vector2.up * Vector2.kEpsilon;
                range = Vector2.up * (rect.height - Vector2.kEpsilon * 2.0f);
                direction = Vector2.left;
            }
        }

        if (m_RaySize == 1)
        {
            start += range * 0.5f;
            range = Vector2.zero;
        }
    }

    private bool CheckSpace(Vector2 origin, Vector2 direction)
    {
        int layerMask = m_Collidable.value | m_StaticCollidable.value;
        int size = Physics2D.RaycastNonAlloc(origin, direction, m_Results, m_MaxRayDistance, layerMask);

        DrawRay(origin, direction, m_MaxRayDistance, Color.green);

        float space = 0.0f;
        float offset = 0.0f;
        for (int i = 0; i < size; i++)
        {
            RaycastHit2D result = m_Results[i];
            Collider2D collider = result.collider;

            if (collider == m_BoxCollider) continue;

            float diff = result.distance - offset;
            if (diff > Mathf.Epsilon)
            {
                space += diff;
            }

            int layer = collider.gameObject.layer;
            if (((1 << layer) & m_StaticCollidable.value) != 0)
            {
                DrawRay(origin, direction, result.distance, Color.red);

                return (space > Mathf.Abs(m_Speed * Time.fixedDeltaTime));
            }

            offset = GetNextOffset(origin, collider);
        }

        return (size != MaxRaycastHit);
    }

    private float GetNextOffset(Vector2 origin, Collider2D collider)
    {
        float offset;

        if (m_IsVertical)
        {
            if (m_Speed >= 0.0f)
            {
                offset = collider.bounds.max.y - origin.y;
            }
            else
            {
                offset = origin.y - collider.bounds.min.y;
            }
        }
        else
        {
            if (m_Speed >= 0.0f)
            {
                offset = collider.bounds.max.x - origin.x;
            }
            else
            {
                offset = origin.x - collider.bounds.min.x;
            }
        }

        return offset;
    }

    [Conditional("UNITY_EDITOR")]
    private void DrawRay(Vector2 origin, Vector2 direction, float distance, Color color)
    {
        UnityEngine.Debug.DrawLine(origin, origin + direction * distance, color);
    }

   #endregion
}

手法の比較

最初に書いたように方向転換するブロックの実装には移動できる空間があるかどうかの把握が重要になります。 動画の方法では物理エンジンの力を借りて押し返す力の検知により空間があるかどうかを確認しています。 一方でこの記事の方法では原始的に空間を計算しています。

動画の方法の問題点は非物理的な挙動を物理的な挙動に押し込んでいることです。 これにより他の物理的なオブジェクトとの関係が密になり、影響を与えやすくかつ受けやすくなります。 例えば、十分に大きい質量と仮定したオブジェクト同士が衝突しておかしな動作をしたり、 何か大きな力を押し返す力と勘違いして方向転換することがあるかもしれません。 最悪でも物理的な挙動をするはずなので仕様だと割り切ってしまうことはできるかもしれませんが・・・

一方でこの記事の方法の問題点は正確さに欠けることです。 現在は衝突対象が十分に大きくかつ矩形判定をもっているため概ね正確に検知できていますが、 衝突対象が小さければレイの隙間を抜けてしまうかもしれませんし、 複雑な形状を持っていれば空間の計算の精度が落ちます。 また応用のしづらさも問題になります。 現在ブロックは縦横にしか動きませんが、これを斜めに動かそうと思うとレイの飛ばし方を変える必要があります。 形状によっても異なるのであらゆる場合に対処するのはなかなかに骨が折れます。

・・・と、まあ、ようはどちらも万能な方法ではないということです。 用いている手法の弱点を把握しつつ、状況に適したものを利用するのがいいと思います。 あるいは確立されたもっと素晴らしい手法がどこかにあるのかもしれません。

おわりに

一方通行の床については標準機能でさくさくでしたが、 方向転換するブロックについては思いのほか苦戦しました。 もともとはKinematicで移動した方がいいんじゃないかと思っただけだったんだけど・・・

余談ですが、 実は自分も次の作品を自動生成ダンジョンの2Dアクションゲームにしようと思っていたので、 最初に動画見たときはけっこうショックでした。 ただ、動画を見ていくとかなり面白いものができそうなので今は自分のゲームの参考にしつつ期待して見てます。

ちなみに最終的なゲームデザインはけっこう異なりそうなので、一応まだつくる気でいます。 しばらくは忙しくて手がつけられそうにないですが・・・

ユニティちゃんライセンス

ユニティちゃんライセンス

このコンテンツは、『ユニティちゃんライセンス』で提供されています