ゴマちゃんフロンティア

アザラシが大好きなエンジニアの開発日記です

【Unity】画面奥に無限にスクロールするゲームシーンの作成方法を考えたお話

time 2019/02/11

というわけで、今回はUnityの「スクロールするゲーム画面」のお話で、中でも「ゲームの終了まで延々とループしてスクロールさせる」というタイプのゲームに関してです。

レースゲームのように終わりのあるスクロールであればステージに沿ってカメラとキャラクターを動かせば良いのですが、無限ループの場合はそうもいかず、静的に配置したステージではいずれ終わりが来てしまいます。
なので、どこまでスクロールしても途切れることのないゲームシーンの実現方法について考えてみました!

実現方法

「考えてみました!」とか言いつつある程度目途は付いていまして、以前読んだUnityの本で「地形の生成→移動→削除を繰り返す」という手法が乗っていたことを思い出し、その方法を真似てみることにしました。
「延々と続くスクロール」っぽいサンプルということで、今回は「雪道を滑る」というコンセプトのシーンを作ってみます。

地形モデルの作成

Blenderで前から見て中央に窪みのある地形を作成します。左右は少し凹凸を付けて雪っぽくしました。

この状態でUnityへインポートします。
デフォルトのマテリアルなら真っ白になるので、特にあれこれせずとも雪っぽくて楽です。

1ブロック分のプレハブの作成

後述しますが、シーンの1ブロックを延々と生成・移動させることでスクロールを実現します。
なのでその1ブロック分を使い回せるようにプレハブ化しておきます。

最近のアップデートでプレハブの編集がやりやすくなりましたね。
Unityはバージョンアップの度により進化して使いやすくなるので安心です。改悪を続けるTwitterやLINEとかも見習って欲しいところですね。

一定方向への移動と一定時間後の削除を行うスクリプトの作成

「生成後に一定方向に移動させ続ける」方法はいくつかありますが、ここでは簡単なスクリプトを作成し、生成後にAddComponent()で追加する方法でやってみました。
ということであらかじめ以下のようなスクリプトを作成しました。

using UnityEngine;

public class ObjectTransformar : MonoBehaviour
{
    public Vector3 translate;

    void Update()
    {
        if (translate != Vector3.zero) {
            transform.Translate(translate, Space.World);
        }
    }
}

また生成したブロックを放置すると増え続けてしまうため、一定時間後に消すスクリプトも用意します。

using UnityEngine;

public class AutoDestroy : MonoBehaviour
{
    public float time;

    void Start()
    {
        Destroy(gameObject, time);
    }
}

スクロールを制御するスクリプトの作成

スクロールさせる1ブロックのプレハブを生成・移動させるスクリプトを作成します。

using UnityEngine;

public class ScrollTestManager : MonoBehaviour
{
    /// <summary>
    /// スクロール時の1ブロックとなるプレハブ
    /// </summary>
    [SerializeField]
    protected GameObject scrollBlockObject;

    /// <summary>
    /// ブロックの生成開始位置
    /// </summary>
    [SerializeField]
    protected Transform blockPopPoint;

    /// <summary>
    /// ブロックの移動方向
    /// </summary>
    [SerializeField]
    protected Vector3 blockMoveForward;

    /// <summary>
    /// あらかじめブロックを生成しておく数
    /// </summary>
    [SerializeField]
    protected int before_block_create_count = 0;

    /// <summary>
    /// 最後の生成したブロックのRendererコンポーネント(処理用)
    /// </summary>
    private Renderer beforeBlockRenderer;

    void Start()
    {
        // 初期化時に指定数分ブロックを生成する
        if (0 < before_block_create_count) {
            // 生成対象ブロックのBounds
            Bounds blockRendererBounds = scrollBlockObject.GetComponent<Renderer>().bounds;
            blockRendererBounds.center = blockPopPoint.position;

            for (int i = 0; i < before_block_create_count; i++) {
                // 移動方向が指定されている軸のみをBounds.size分ずらした位置に生成する
                Vector3 createPosition = blockPopPoint.position + new Vector3(
                    GetBinarizationFloat(blockMoveForward.x) * (blockRendererBounds.size.x * i),
                    GetBinarizationFloat(blockMoveForward.y) * (blockRendererBounds.size.y * i),
                    GetBinarizationFloat(blockMoveForward.z) * (blockRendererBounds.size.z * i)
                );
                CreateBlock(createPosition);
            }
        }
    }

    private void FixedUpdate()
    {
        // 次のブロックの生成判定用のBoundsインスタンス作成
        Bounds beforeBounds = beforeBlockRenderer.bounds;
        beforeBounds.size = beforeBlockRenderer.bounds.size * 2;
        beforeBounds.center += blockMoveForward;

        // 生成位置から判定用のBounds内から出ているか判定
        if (!beforeBounds.Contains(blockPopPoint.position)) {
            CreateBlock(blockPopPoint.position);
        }
    }

    private void CreateBlock(Vector3 createPosition)
    {
        GameObject blockObject = Instantiate(scrollBlockObject, createPosition, scrollBlockObject.transform.rotation);

        // 移動と削除を行うコンポーネントを設定
        blockObject.AddComponent<AutoDestroy>().time = 5f;
        blockObject.AddComponent<ObjectTransformar>().translate = blockMoveForward;

        beforeBlockRenderer = blockObject.GetComponent<Renderer>();
    }

    private float GetBinarizationFloat(float value)
    {
        if (0 < value) {
            return 1;
        } else if (value < 0) {
            return -1;
        } else {
            return 0;
        }
    }
}

適当な空オブジェクトにこのスクリプトを設定し、インスペクターで「生成するブロックのプレハブ」「生成する位置」「生成後の移動方向」「デフォルトで生成しておくブロック数」を設定します。

Start()FixedUpdate()内からCreateBlock()を呼び出してブロックを生成し、先ほど作成したAutoDestroyObjectTransformarをコンポーネントとして追加します。

そして今回重要なポイントは「どのようにして隙間なくブロックを生成するか」ということです。ブロックが「単一の大きさのCubeしか使わない」とかなら話は早いのですが、実際はそうもいかないケースがほとんどでしょう。
そこで「前に生成したRenderer.boundsを取得しておき、そのBoundsから次の生成タイミングを判定する」方法を取ってみました。

まず1つは普通に生成し、その生成したオブジェクトのRendererを保持しておきます。
2つ目以降は前回生成したブロックのBoundsを元に計算を行います。具体的には「Bounds.sizeを2倍、Bounds.centerblockMoveForwardを足したBoundsが生成位置から外れたかどうか」を判定します。

Bounds.sizeを2倍にする理由
Bounds.sizeを2倍することで、生成位置の中心から見た1ブロック全体の幅を得ることができます。
以下の一連の画像を見ていただくとイメージしやすいと思います。中心の赤い点が生成位置、赤線がブロックのBoundsの大きさで、青線がそれを2倍した大きさです。

下の画像は生成直後の状態です。

オブジェクトに沿って青線を移動させ、赤線と見比べてみると、ちょうど生成位置まで半分のところに来ていることが分かります。

そのまま残り半分を移動させると下の画像のようになります。

次のフレームで青線が生成位置から出るため新しいブロックオブジェクトが生成されますが、赤線の範囲からピッタリ出ているので、2つのブロックが重ならずに生成できます。

Bounds.centerblockMoveForwardを足す理由
「特定の地点がBounds外か」の判定にはBounds.Contains()を使いますが、これは前述のような「Boundsと生成地点がピッタリ重なっている」状態ではtrueが返ってきます。
なのでブロック生成が次のフレームになってしまうわけですが、その頃には先に出したブロックがblockMoveForward分だけ動いてしまうので、その分の隙間ができてしまいます。
それを防ぐため、判定用のBounds.centerの位置をあらかじめずらしておくことで、少し早めに判定・生成することができるようになります。

その他、ブロック生成をFixedUpdate()内で行うことも重要です。Update()内ではタイミングが不安定なのか、ブロック間に隙間ができてしまうことが多くなります。

ちなみにStart()でのブロック生成も、コードにすると複雑なことをやっているように見えますが、実際は「Bounds分だけ移動方向にずらしながらブロックを生成する」ことをやっているので、「Boundsを使って生成位置を判断する」という本質は同じです。
ただし「どの方向に対して初期生成しておくか」を判別するため、blockMoveForwardのXYZをGetBinarizationFloat()で「-1」「0」「1」のどれかにした値をVector3の各軸に乗算しています。

動作

長々と話しましたが、実際に実行すると以下のような感じになります。

さすがにウサギが棒立ちだと違和感が強いですね…。
キャラクターやカメラ位置をしっかり調整すればよりゲームっぽくなると思いますよ!

down

コメントする