Unity3Dで動く床を作る

# 動く床とは何か

![q1_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/88db6883-4c0e-d0cc-fcdd-2eb359a30114.gif)

左右や上下に往復運動する床。
プレイヤーや他のオブジェクトが床の上に乗ると一緒に移動する。
床がプレイヤーや他のオブジェクトに衝突してもすり抜けない。

# 環境
macOS Monterey 12.6
Unity 2021.3.8f1 Personal
Game Creator 1.1.16

<a href="https://assetstore.unity.com/packages/tools/game-toolkits/game-creator-89443">Game Creator(有料アセット)</a>

今回の例では、PlayerキャラクターはGameCreatorのPlayerを使用している。
CharacterControllerによって移動し、Rigidbodyはない。
GameCreatorを使用しない場合も、今回紹介する動く床は作成できる。

# 大まかな作り方

1.動く床の3Dオブジェクトを作成。(MovingPlatform)
2.MovingPlatformを往復運動させるスクリプトを書く。
3.このままだとMovingPlatformに乗った他のオブジェクトが一緒に移動してくれないので、どうにかして一緒に動くようにする。
4.MovingPlatformが移動してプレイヤーなどのオブジェクトにぶつかるとすり抜けてしまうので、すり抜けないようにする。

# 1.動く床の3Dオブジェクトを作成。(MovingPlatform)

動く床の直方体を作る。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/367de9ca-f8df-b44f-6e77-3ca8e5aabdaf.png"/>


今回はProBuilderで作った。ProGridを使うとやりやすい。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/a2b3e82d-1dc7-08cd-20b5-c5f0b66055ab.png"/>

サイズはx:4 y:1 z:2とした。
ProBuilderで作成されたMesh Colliderは削除してBox Colliderに替えてしまった。(しなくてもいい)
Rigidbodyはつけない。
スクリプトについては後述する。

# 2.MovingPlatformを往復運動させるスクリプトを書く。

下のスクリプトをMovingPlatformにつける。

```c#:MovingPlatform.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MovingPlatform : MonoBehaviour
{
    enum MoveDirection
    {
        x = 0,
        y,
        z,
    }

    [SerializeField]
    MoveDirection moveDirection = MoveDirection.x;

    [SerializeField]
    float min;
    [SerializeField]
    float max;

    [SerializeField]
    float velocity = 2f;
    [SerializeField]
    float stopTime = 1f;

    float len;
    float sv;
    float maxVelocity;

    BoxCollider box;

    float time = 0f;

    LayerMask layermask;

    private void Start()
    {
        len = max - min;
        sv = stopTime * velocity;
        maxVelocity = velocity;

        box = GetComponent<BoxCollider>();

        layermask = LayerMask.GetMask("Collidable");
    }

    private float EaseInOutSine(float t)
    {
        //t [0, 0.5, 1, 1.5, 2]
        //x [0, 0.5, 1, 0.5, 0]
        return -(Mathf.Cos(Mathf.PI * t) - 1) / 2;
    }

    private float CalcPosition(float time)
    {
        float p = 0f;
        float tv = time * velocity;

        if (0 <= tv && tv < len)
        {
            p = EaseInOutSine(tv / len) * len + min;
        }
        else if (len <= tv && tv < len + sv)
        {
            p = max;
        }
        else if (len + sv <= tv && tv < len + sv + len)
        {
            p = EaseInOutSine((tv - sv) / len) * len + min;
        }
        else if (len + sv + len <= tv && tv < len + sv + len + sv)
        {
            p = min;
        }
        return p;
    }
    
    private void FixedUpdate()
    {
        time += Time.deltaTime;
        if ((len + sv) * 2 <= time * velocity)
        {
            time -= (len + sv) * 2 / velocity;
        }
        float calcP = CalcPosition(time);
        float maxDelta = maxVelocity * Time.deltaTime;
        Vector3 p = transform.position;

        if ((moveDirection == MoveDirection.x && calcP == p.x) ||
            (moveDirection == MoveDirection.y && calcP == p.y) ||
            (moveDirection == MoveDirection.z && calcP == p.z))
        {
            return;
        }

        Vector3 direction = Vector3.zero;
        float distance = 1f;

        switch (moveDirection)
        {
            case MoveDirection.x:
                direction.x = calcP < p.x ? -1 : 1;
                distance = Mathf.Min(Mathf.Abs(calcP - p.x), maxDelta);
                p.x = Mathf.Clamp(calcP, p.x - maxDelta, p.x + maxDelta);
                break;
            case MoveDirection.y:
                direction.y = calcP < p.y ? -1 : 1;
                distance = Mathf.Min(Mathf.Abs(calcP - p.y), maxDelta);
                p.y = Mathf.Clamp(calcP, p.y - maxDelta, p.y + maxDelta);
                break;
            case MoveDirection.z:
                direction.z = calcP < p.z ? -1 : 1;
                distance = Mathf.Min(Mathf.Abs(calcP - p.z), maxDelta);
                p.z = Mathf.Clamp(calcP, p.z - maxDelta, p.z + maxDelta);
                break;
        }

        //raycast box
        RaycastHit hit;
        bool moveUp = false;
        const float margin = 1f;
        Vector3 start = transform.position;
        switch (moveDirection)
        {
            case MoveDirection.x:
                start.x = direction.x < 0 ? start.x + margin : start.x - margin;
                break;
            case MoveDirection.y:
                start.y = direction.y < 0 ? start.y + margin : start.y - margin;
                moveUp = direction.y > 0;
                break;
            case MoveDirection.z:
                start.z = direction.z < 0 ? start.z + margin : start.z - margin;
                break;
        }
        
        const float BoxSizeDiff = 0.02f;
        Vector3 halfExtents = box.size * 0.5f;
        switch (moveDirection)
        {
            case MoveDirection.x:
                halfExtents.y -= BoxSizeDiff;
                halfExtents.z -= BoxSizeDiff;
                break;
            case MoveDirection.y:
                halfExtents.x -= BoxSizeDiff;
                halfExtents.z -= BoxSizeDiff;
                break;
            case MoveDirection.z:
                halfExtents.x -= BoxSizeDiff;
                halfExtents.y -= BoxSizeDiff;
                break;
        }

        if (Physics.BoxCast(start, halfExtents, direction, out hit, transform.rotation, distance + margin, layermask))
        {
            if (!(moveUp && hit.collider.transform.parent == transform))
            {
                const float minHitDistance = 0.01f;
                Vector3 currentP = transform.position;
                float hitDistance = hit.distance - margin;
                //Debug.Log("Hit : distance = " + distance.ToString() + " hitDistance = " + hitDistance.ToString());
                if (hitDistance < minHitDistance)
                {
                    return;
                }

                switch (moveDirection)
                {
                    case MoveDirection.x:
                        p.x = calcP < currentP.x ? currentP.x - hitDistance : currentP.x + hitDistance;
                        break;
                    case MoveDirection.y:
                        p.y = calcP < currentP.y ? currentP.y - hitDistance : currentP.y + hitDistance;
                        break;
                    case MoveDirection.z:
                        p.z = calcP < currentP.z ? currentP.z - hitDistance : currentP.z + hitDistance;
                        break;
                }
                //Debug.Log("p = " + p.x.ToString() + " calcP = " + calcP.ToString());
            }
        }

        //Debug.Log("x = " + transform.position.x.ToString() + " p = " + p.x.ToString() + " xdif = " + (transform.position.x - p.x).ToString());
        transform.position = p;
    }
}
```

### 変数の説明

#### moveDirection
床が動く方向。xの場合x軸に平行に動く。

#### min, max
動く範囲の最小/最大値。xの場合はminXとmaxXを意味する。

#### velocity
床の動く速度。2がちょうどいい。

#### stopTime
端で止まる時間。minからmaxまで移動->stopTimeの間停止->maxからminまで移動->stopTimeの間停止を繰り返す。

### スクリプトの説明
動いている間はEaseInOutSine関数に従って動く。
https://easings.net/ja#easeInOutSine

基本的には時間timeに対してあるべき位置calcPを計算して、transform.position = pで位置を書き換えている。
transform.positionを直接変更する方法だと細かくテレポートしていることになるため、プレイヤーなどの障害物があってもすり抜けてしまうという問題がある。
これについてはBoxCastで障害物を確認してぶつからないように止まることで解決した。これは後述する。

また、床を移動させる別の手段として、rigidbody.positionを変更する方法、rigidbody.MovePositionを使用する方法があるが、上に乗ったプレイヤーが(プレイヤーをMovingPlatformの子にしても)一緒に移動しなかったり、プレイヤーがガクガク震えるなどの問題があったため今回は使わなかった。

# 3.MovingPlatformに乗った他のオブジェクトを一緒に移動させる。

2のスクリプトをつけて実行すれば床が往復運動をするようになるが、プレイヤーが上に乗っても慣性が働かず、床から滑り落ちてしまう。

![q2_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/bdd07c9d-52db-1c7d-1884-8c2bb1b6fc23.gif)

MovingPlatformの上に乗ったオブジェクトが一緒に移動するようにするには、上に乗ったオブジェクトのparentをMovingPlatformに変更すればいい。
MovingPlatformから下りたら、parentを元に戻す。

今回の例ではMovingPlatformの上に乗る可能性があるオブジェクトはPlayerとBoxだけなので、この2種類について対応する。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/2217ffcc-9d4e-4671-4cd2-ac54ba5285f2.png"/>

MovingPlatformの子としてPlayerTriggerとBoxTriggerというオブジェクトを作成する。
それぞれCreate Emptyで作成して、Box Colliderをつけ、Is Triggerをtrueにする。

PlayerがPlayerTriggerに触れたらスクリプトでPlayerのparent(親)をMovingPlatformに変更し、PlayerTriggerから離れたらparentを元に戻す。
Boxについても同様。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/75fb58e9-8c1c-1807-4573-3d20ef951735.png"/>

Box Colliderの大きさは以下の通り

PlayerTrigger
Center 0 0.65 0
Size 3.8 0.3 2
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/600e30aa-b4d5-e5d2-dc47-903b0d0bed81.png"/>
BoxTrigger
Center 0 0.55 0
Size 2.2 0.1 2
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/97a78bfa-ef8d-b667-56d6-c7cf6f414144.png"/>

MovingPlatformの上部にBoxColliderを置くことによって、上に乗った時だけTriggerが反応するようにしている。
またx方向のサイズをMovingPlatformよりも少し短くして、オブジェクトが横からぶつかったり、オブジェクトの端っこが乗っただけだと反応しないようにしている。
BoxTriggerの場合はさらにx方向のサイズを短くして、Boxが半分以上MovingPlatformの上に乗らないとTriggerに触れないようにしている。

また、PlayerTriggerのsize.yをこれより小さくするとPlayerが一緒に移動しないので、BoxTriggerよりも厚くしている。

PlayerTriggerとBoxTriggerに以下のスクリプトをつける。

```c#:TriggerObject.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TriggerObject : MonoBehaviour
{
    public string tagCondition;
    public delegate void OnTriggerDelegate(Transform transform);
    public OnTriggerDelegate OnTriggerEnterDelegate;
    public OnTriggerDelegate OnTriggerExitDelegate;

    private void OnTriggerEnter(Collider other)
    {
        if (string.IsNullOrEmpty(tagCondition) || other.tag == tagCondition)
        {
            OnTriggerEnterDelegate?.Invoke(other.transform);
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (string.IsNullOrEmpty(tagCondition) || other.tag == tagCondition)
        {
            OnTriggerExitDelegate?.Invoke(other.transform);
        }
    }
}
```

tagConditionで指定したタグを持つオブジェクトがColliderに触れたり離れたりすると、それぞれコールバックが呼ばれる。
PlayerTriggerではtagConditionをPlayerに、BoxTriggerではBoxにする。

また次のスクリプトをMovingPlatformにつける。
ここでコールバックの内容を実装している。

```c#:MovingPlatformTriggerHandler.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MovingPlatformTriggerHandler : MonoBehaviour
{
    const string kPlayer = "Player";
    const string kBox = "Box";

    [SerializeField]
    Transform geometry;
    [SerializeField]
    TriggerObject playerTrigger;
    [SerializeField]
    TriggerObject boxTrigger;

    private void Start()
    {
        playerTrigger.tagCondition = kPlayer;
        playerTrigger.OnTriggerEnterDelegate = OnTriggerPlayerEnterCallback;
        playerTrigger.OnTriggerExitDelegate = OnTriggerPlayerExitCallback;

        boxTrigger.tagCondition = kBox;
        boxTrigger.OnTriggerEnterDelegate = OnTriggerBoxEnterCallback;
        boxTrigger.OnTriggerExitDelegate = OnTriggerBoxExitCallback;
    }

    void OnTriggerPlayerEnterCallback(Transform other)
    {
        Debug.Log("OnTriggerPlayerEnterCallback");
        other.SetParent(transform);
    }

    void OnTriggerPlayerExitCallback(Transform other)
    {
        Debug.Log("OnTriggerPlayerExitCallback");
        other.SetParent(null);
    }

    void OnTriggerBoxEnterCallback(Transform other)
    {
        Debug.Log("OnTriggerBoxEnterCallback");
        other.SetParent(transform);
    }

    void OnTriggerBoxExitCallback(Transform other)
    {
        Debug.Log("OnTriggerBoxExitCallback");
        other.SetParent(geometry);
    }
}
```

内容はPlayerがPlayerTriggerに触れたらPlayerのparentをMovingPlatformのtransformに変更し、離れたら元に戻すというもの。
Boxについても同様だが、geometryというのは元々Boxの親になっているEmptyオブジェクトを指す。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/f8970449-6afd-e09e-ba0b-dfe807bedb18.png"/>

InspectorでGeometry, Player Trigger, Box Triggerを設定する。

# 4.MovingPlatformが移動してオブジェクトにぶつかった際にすり抜けないようにする。

PlayerとBoxのレイヤーを変更し、Collidableという新規レイヤーにする。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/f6a21eb9-c961-aa8f-70b8-e589ec95257c.png"/>


MovingPlatform.csの該当部分を再掲する。

```c#:MovingPlatform.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MovingPlatform : MonoBehaviour
{
    /* 中略 */
    LayerMask layermask;

    private void Start()
    {
        /* 中略 */
        layermask = LayerMask.GetMask("Collidable");
    }
    
    /* 中略 */
    
    private void FixedUpdate()
    {
        time += Time.deltaTime;
        if ((len + sv) * 2 <= time * velocity)
        {
            time -= (len + sv) * 2 / velocity;
        }
        float calcP = CalcPosition(time);
        float maxDelta = maxVelocity * Time.deltaTime;
        Vector3 p = transform.position;

        if ((moveDirection == MoveDirection.x && calcP == p.x) ||
            (moveDirection == MoveDirection.y && calcP == p.y) ||
            (moveDirection == MoveDirection.z && calcP == p.z))
        {
            return;
        }

        Vector3 direction = Vector3.zero;
        float distance = 1f;

        switch (moveDirection)
        {
            case MoveDirection.x:
                direction.x = calcP < p.x ? -1 : 1;
                distance = Mathf.Min(Mathf.Abs(calcP - p.x), maxDelta);
                p.x = Mathf.Clamp(calcP, p.x - maxDelta, p.x + maxDelta);
                break;
            case MoveDirection.y:
                direction.y = calcP < p.y ? -1 : 1;
                distance = Mathf.Min(Mathf.Abs(calcP - p.y), maxDelta);
                p.y = Mathf.Clamp(calcP, p.y - maxDelta, p.y + maxDelta);
                break;
            case MoveDirection.z:
                direction.z = calcP < p.z ? -1 : 1;
                distance = Mathf.Min(Mathf.Abs(calcP - p.z), maxDelta);
                p.z = Mathf.Clamp(calcP, p.z - maxDelta, p.z + maxDelta);
                break;
        }

        //raycast box
        RaycastHit hit;
        bool moveUp = false;
        const float margin = 1f;
        Vector3 start = transform.position;
        switch (moveDirection)
        {
            case MoveDirection.x:
                start.x = direction.x < 0 ? start.x + margin : start.x - margin;
                break;
            case MoveDirection.y:
                start.y = direction.y < 0 ? start.y + margin : start.y - margin;
                moveUp = direction.y > 0;
                break;
            case MoveDirection.z:
                start.z = direction.z < 0 ? start.z + margin : start.z - margin;
                break;
        }
        
        const float BoxSizeDiff = 0.02f;
        Vector3 halfExtents = box.size * 0.5f;
        switch (moveDirection)
        {
            case MoveDirection.x:
                halfExtents.y -= BoxSizeDiff;
                halfExtents.z -= BoxSizeDiff;
                break;
            case MoveDirection.y:
                halfExtents.x -= BoxSizeDiff;
                halfExtents.z -= BoxSizeDiff;
                break;
            case MoveDirection.z:
                halfExtents.x -= BoxSizeDiff;
                halfExtents.y -= BoxSizeDiff;
                break;
        }

        if (Physics.BoxCast(start, halfExtents, direction, out hit, transform.rotation, distance + margin, layermask))
        {
            if (!(moveUp && hit.collider.transform.parent == transform))
            {
                const float minHitDistance = 0.01f;
                Vector3 currentP = transform.position;
                float hitDistance = hit.distance - margin;
                //Debug.Log("Hit : distance = " + distance.ToString() + " hitDistance = " + hitDistance.ToString());
                if (hitDistance < minHitDistance)
                {
                    return;
                }

                switch (moveDirection)
                {
                    case MoveDirection.x:
                        p.x = calcP < currentP.x ? currentP.x - hitDistance : currentP.x + hitDistance;
                        break;
                    case MoveDirection.y:
                        p.y = calcP < currentP.y ? currentP.y - hitDistance : currentP.y + hitDistance;
                        break;
                    case MoveDirection.z:
                        p.z = calcP < currentP.z ? currentP.z - hitDistance : currentP.z + hitDistance;
                        break;
                }
                //Debug.Log("p = " + p.x.ToString() + " calcP = " + calcP.ToString());
            }
        }

        //Debug.Log("x = " + transform.position.x.ToString() + " p = " + p.x.ToString() + " xdif = " + (transform.position.x - p.x).ToString());
        transform.position = p;
    }
}
```

大まかに説明すると、PlayerとBoxにCollidableレイヤーを設定し、FixedUpdateの中でMovingPlatformの進行方向にBoxCastを飛ばす。
Collidableなオブジェクトにぶつかったら、移動する距離をぶつかる手前までに変更する。

const float margin = 1f;からの部分で何をしているかというと、Collidableオブジェクトが既にMovingPlatformの内部にめり込んでいることを考えて、BoxCastの開始位置をmargin分手前の位置からにしている。

また、const float BoxSizeDiff = 0.02f;からの部分では、BoxCastのsizeの進行方向以外の成分を少し小さくしている。これによって、下の画像のように地面からはみ出たBoxの下面とMovingPlatformの上面の高さが同じ時、PlatformがBoxの下に入り込まずに止まってしまうことを防いでいる。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/a5f96f1f-8b14-c892-ed67-3c01754abc3d.png"/>

# 完成

MovingPlatformがPlayerやBoxを乗せて一緒に移動する。

![q3_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/f9bf03c7-9b93-fddf-4088-e6a716f44d9c.gif)

上下にも動く。

![q4_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/1bf7b392-fe60-3ad9-e272-72d630951d48.gif)

PlayerやBoxにぶつかると止まる。

![q5_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37640/bb7175d8-708c-c106-46bf-b775ba5e45d0.gif)

# 参考サイト

https://futabazemi.net/unity/move_floor
https://kurokumasoft.com/2022/06/07/unity-moving-platform/
https://www.unitygamebox.com/?p=621
https://tsubakit1.hateblo.jp/entry/2016/02/25/025922
https://easings.net/ja#