i-Vinci TechBlog
株式会社i-Vinciの技術ブログ

Unityでスイカゲームを作る

こんにちは。yyです。
今回のブログのテーマはUnityでスイカゲームを作るです!
新木さんからテーマを引き継いでやらせてもらえることになりました。
以下の記事でスイカゲームを作るための準備について記事にしてくださっているのでよろしければ読んでみてください!

Unityでスイカゲームを作るための準備

さて、今回は「Unityでスイカゲームを作るための準備」の続きとして、スイカゲーム(雛形程度)の作成を行いました。
今回のスイカゲームでは以下の機能を採用しています!

採用機能

・三つの種類でランダムに選択されるオブジェクト
・次に落下するオブジェクトをプレ表示
・同じ大きさ、同じ形ならオブジェクトが合体、nextPrefabが生成される
・オブジェクト合体による点数機能
・GameOver機能

それでは順番にUnityの機能とコードを交えて解説していきます!

三つの種類でランダムに選択されるオブジェクト

これは以下のコードで実装しています!

/// <summary>
/// ランダムで取得したオブジェクトを返す
/// </summary>
public GameObject GetRandom()
{
    var values = Enum.GetValues(typeof(ObjectPrefabType));
    var randomType = (ObjectPrefabType)values.GetValue(UnityEngine.Random.Range(0, values.Length));
    return objectGameObjectSet.GetGameObject(randomType);
}

細かく解説いたします。
コード一行目のvaluesに入っているのはEnum型(意味のある名前をもつ数値の集まり)で定義したObject達のList(Capsule,Circle,Squareが入ってます!)です。
それをコード二行目のrandomTypeにランダムでいれ、コード三行目のGetGameObjectメソッドに型を渡して、渡した型に対応するオブジェクトを持ってきてもらう仕組みになっています!

補足ですがUnityでは以下のようにUnityのエディタ上からオブジェクトをクラスに対して選択、登録できます。
objectGameObjectSetも下記のようにオブジェクトを登録しています!

photo

落下するオブジェクトをプレ表示

/// <summary>
/// ランダムに選んだプレハブを生成し、
/// 画面の左上付近に配置して返す。
/// 生成直後に物理挙動(Rigidbodyなど)を無効化する。
/// </summary>
/// <returns>生成されたGameObject</returns>
public GameObject GetRandomAndShowTopLeft()
{
    // ランダムなPrefabを取得
    GameObject prefab = GetRandom();

    // カメラの左上付近のワールド座標を指定(仮で固定値)
    Camera cam = Camera.main;
    Vector3 topLeft = new Vector3(-8, 3, 0);

    // Z軸は2Dゲーム前提で0に固定
    topLeft.z = 0;

    // ZenjectのDIコンテナからPrefabを生成
    GameObject obj = _container.InstantiatePrefab(prefab, topLeft, Quaternion.identity, null);

    // 生成直後に物理演算を無効化(例:Rigidbodyを停止)
    DisablePhysics(obj);

    return obj;
}

ここではUnity独自のコードを多数使用しています。いくつかピックアップして解説します!

// カメラの左上付近のワールド座標を指定(仮で固定値)
Camera cam = Camera.main;
Vector3 topLeft = new Vector3(-8, 3, 0);

こちらコメントの通り画面左上の特定の箇所の座標を取得するコードです。
こちらのコードを確認してなぜ2DゲームなのにVector3を用いて三次元座標を取得しているのかと思った方もいらっしゃるかも知れません。
実はUnityでは2Dでゲームでも内部的には3Dゲームと同じシステムを採用しています。
そのためオブジェクトを配置したり、動かしたりする場合は座標についても意識しないと思わぬバグが生まれてしまいます。実際私も当たり判定がある枠の中にオブジェクトを配置したはずなのにすり抜けてしまうという挙動を起こしたことがありました。
原因はz軸に値を入れ、画面上では問題ないが内部的には当たり判定の後ろにオブジェクトが配置され、当たり判定に引っかからないということが原因でした。

// ZenjectのDIコンテナからPrefabを生成
GameObject obj = _container.InstantiatePrefab(prefab, topLeft, Quaternion.identity, null);

こちらは少々難しい部分です。

簡単に言うとこのコードではオブジェクトの生成と事前生成インスタンスの設定を行っています。
事前生成インスタンスの設定とはこのコードで生成されたオブジェクトに事前にアタッチされたSharpクラス内にオブジェクト作成前に作成しておいたServiceクラスなどのインスタンスを渡すことを指しています。
InstantiatePrefabを使用することで余分なインスタンス化を挟むことなく簡潔な書き方でオブジェクトを作成することができるようになっています。
補足ですが事前にSharpクラスがアタッチされているのは今回生成されるオブジェクトがobjectGameObjectSetに登録される段階で、すでにSharpクラスをアタッチしているからです。
GetRandom()メソッドはobjectGameObjectSetからオブジェクトを取得してきているため、必然的にSharpクラスは事前にアタッチされることになっています。

事前生成インスタンスの設定はDI注入と呼ばれています。
以下のリンクから分かりやすい説明だったので、気になる方はこちらをご確認ください!

UnityのDI超ざっくり入門 1 - そもそもUnityのDIって何?

// 生成直後に物理演算を無効化(例:Rigidbodyを停止)
DisablePhysics(obj);

これはUnity独自の仕組みで、Rigidbodyが物理演算をしてもらうコンポーネントです。これをONにしているとプレ表示のオブジェクトが落下していってしまうのでOFFにしています。逆にオブジェクト配置時はRigidbodyをONにして落下やオブジェクト同士の衝突を計算して画面で表現してもらっています。

同じ大きさ、同じ形ならオブジェクトが合体、nextPrefabが生成されるとオブジェクト合体による点数機能

/// <summary>
/// 他の <see cref="Shape"/> と合体(マージ)し、次の形状を生成する処理。
/// </summary>
/// <param name="other">衝突して合体するもう一方の <see cref="Shape"/>。</param>
private void Merge(Shape other)
{
    // 衝突した2つのオブジェクト(自分と相手)を削除
    Destroy(other.gameObject);
    Destroy(this.gameObject);

    // 次のレベルのプレハブが設定されていれば生成
    if (nextPrefab != null)
    {
        GameObject newObj = _container.InstantiatePrefab(
            nextPrefab,
            transform.position,
            Quaternion.identity,
            null
        );

        // 生成したオブジェクトが Shape コンポーネントを持っていれば初期化とスコア加算
        if (newObj.TryGetComponent<Shape>(out var shape))
        {
            // レベルを1上げて初期化
            shape.Initialize(Level + 1);

            // スコアを加算
            _gameManager.AddScore(shape.ScoreValue);
        }
    }
}

この箇所はsummaryとコメントの通りです。
OnCollisionEnter2D(Collision2D collision)メソッドがオブジェクト同士がぶつかった瞬間に自動的に呼ばれ、その中にあるmergeメソッドが合体の条件(Levelと形が同じ場合)に合致した場合に呼ばれます。
mergeメソッドは既存オブジェクトの消去とオブジェクトの生成、アタッチされているsharpクラスのプロパティの設定、スコアの加算を一挙に行ってくれます。

GameOver機能

/// <summary>
/// トリガー領域に入った <see cref="Shape"/> を監視し、
/// 一定時間(<see cref="requiredTime"/>)触れ続けていた場合にゲームオーバーを発動する。
/// </summary>
private void OnTriggerEnter2D(Collider2D collision)
{
    // トリガーに入ったオブジェクトが Shape コンポーネントを持っている場合のみ処理
    if (collision.TryGetComponent<Shape>(out var shape))
    {
        // 既に監視コルーチンが動作中なら新たに開始しない(多重起動防止)
        if (checkCoroutine == null)
            checkCoroutine = StartCoroutine(CheckStay(shape));
    }
}

/// <summary>
/// トリガー領域から <see cref="Shape"/> が離れた際に呼ばれる。
/// まだゲームオーバー判定までの時間に達していない場合は監視を中止する。
/// </summary>
private void OnTriggerExit2D(Collider2D collision)
{
    // トリガーを離れたオブジェクトが Shape の場合のみ処理
    if (collision.TryGetComponent<Shape>(out var shape))
    {
        // コルーチンが動作中であれば停止してリセット
        if (checkCoroutine != null)
        {
            StopCoroutine(checkCoroutine);
            checkCoroutine = null;
        }
    }
}

/// <summary>
/// 指定された <see cref="Shape"/> が一定時間トリガー内に留まっているかを監視するコルーチン。<br/>
/// 時間経過中に離れた場合は中断し、指定時間経過した場合はゲームオーバーを発動する。
/// </summary>
/// <param name="shape">監視対象の <see cref="Shape"/>。</param>
private IEnumerator CheckStay(Shape shape)
{
    float timer = 0f;

    while (timer < requiredTime)
    {
        timer += Time.deltaTime;
        yield return null;

        // Shape が破棄・離脱した場合は中断
        if (shape == null)
            yield break;
    }

    // 指定時間トリガー内に留まった場合 → ゲームオーバー
    _gameManager.GameOver();

    // コルーチン終了を明示
    checkCoroutine = null;
}

この機能はコルーチンを使用して事前配置した枠外の透明な棒に一定時間以上触れるとGameOver、途中でオブジェクトが破棄、もしくは離れた場合GameOver判定をリセットするという挙動を実装しています。

コルーチンについて簡単に説明すると、コルーチンとは「1度に全部実行されず、時間をかけて少しずつ処理する動作」のことです。
今回のコルーチンの流れはGameOver判定オブジェクトがsharpオブジェクトに触れられるとcheckCoroutine = StartCoroutine(CheckStay(shape));が実行されコルーチンが始まり、CheckStayメソッドが動き出します。
CheckStayは内部的に時間を計測しyield return null;に到達するとそこで一時停止し、次のフレームでまた動作を再開します。
while文の内部で動きが再開されるので繰り返し時間を計測し続けます。
もし計測が継続されrequiredTimeで設定した値を上回ったらwhileの外に出てGameOver()メソッドが呼ばれ、ゲーム終了になります。
逆に、接していたsharpオブジェクトが離れた場合StopCoroutine(checkCoroutine);でコルーチンを停止されcheckCoroutine = null;で初期化されます。

いかがだったでしょうか?
これらの動きを実装することで簡単にスイカゲームの雛形を作ることができました(実装時間計7時間くらいでした)
皆さんも良ければUnityでゲーム作ってみてください。

ちなみにですがこのスイカゲーム遊んでみると私は最高記録910点でした。
皆さんはどれくらいの点数を出せるのでしょうか?
下記リンクで実際にゲームを試せるのでよかったら遊んでみてください!
スイカゲーム

ソースコードについて興味のある方は下記リンクからクローン出来ます。
GitHubURL