tpc18-2

TOPIC 18|Unityで都市を爆走するミニゲームを作る[2/2]|音やエフェクトをつけてゲームらしくする

3D都市を車で走り回るミニゲームを作ります。ここからは爆発音やエフェクトをつけたり、建物の高さや種類に応じて得点を変える仕組みを実装したりするなど、よりゲームらしくします。

Share

【目次】

18.6   衝突時に爆発のエフェクトをつける

18.7   爆発音をつける

18.8   衝突時にスコアをつける

 18.8.1   スコアのUIを作る

 18.8.2   スコアを表示する

 18.8.3   地物の種類に応じてスコアを変える

18.9   ゲームらしくする

 18.9.1   実装する内容

 18.9.2   ゲームモードを切り替えるためのクラス

 18.9.3   UIの実装

 18.9.4   コードの配置と紐付け

 18.9.5   ビルドして実行する

18.10   今後の展開

3D都市を車で走り回るミニゲームを作ります。ここからは爆発音やエフェクトをつけたり、建物の高さや種類に応じて得点を変える仕組みを実装したりするなど、よりゲームらしくします。

18.6 _ 衝突時に爆発のエフェクトをつける

次に、衝突したときに、ただ消えるのではなく、爆発のエフェクトを付けていきます。

まずは、衝突したときに、爆発の煙幕を表示します。煙幕の表示には、Unity社が配布しているパーティクルセットを使います。

【手順】爆発のパーティクルを付ける

[1]爆発パーティクルセットをダウンロードする

ここでは、下記の「Legacy Particle Pack」を使います。ブラウザから[マイアセットに追加する]を選択して、プロジェクトに追加してください。

【Legacy Particle Pack】

https://assetstore.unity.com/packages/vfx/particles/legacy-particle-pack-73777

図 18-32 「Legacy Particle Pack」を追加する

[2]爆発のループを解除する

Legacy Particle Packに含まれるエフェクトのうち、BigExplosionEffectを利用します。このエフェクトは、デフォルトでは爆発がループになっていて使いにくいので、Loopingをオフにして、ループしないようにします。

このエフェクトは、[Assets]―[Unity Technologies]―[EffectExamples]―[FireExplosionEffects]―[Prefabs]―[BigExplosionEffect.prefab]です。[Project]からこのファイルをダブルクリックして開きます。

開いたら、すべてのオブジェクトを選択し、[Looping]のチェックを外します。

図 18-33 BigExplosionEffect.Prefabを開く
図 18-34 Loopingをオフにする

[3]煙幕を出すコードに変更する

先ほどのExplodeCollidedObjectのコードを次のように変更します。

このコードでは、衝突したときにexplosionParticleをparticlePositionで指定した場所に表示するという処理をしています。

using UnityEngine;

public class ExplodeCollidedObject : MonoBehaviour
{
    [SerializeField] private LayerMask targetLayers;
    [SerializeField] private ParticleSystem explosionParticle;
    [SerializeField] private Transform particlePosition;

    private void OnTriggerEnter(Collider other)
    {
        // 衝突対象のレイヤがtargetLayersに含まれている場合のみ実行
        if ((targetLayers & (1 << other.gameObject.layer)) == 0) return;
        // 衝突対象を破壊
        Destroy(other.gameObject);
        // パーティクルを再生して一定時間後に破壊
        var particle = Instantiate(explosionParticle, particlePosition);
        particle.transform.position = particlePosition.position;
        particle.Play();
        Destroy(particle.gameObject, 2f);
    }
}

[4]ExplosionParticleを設定する

InspectorからExplosionParticleを設定します。先ほどインポートしたBigExplosionEffectを指定します。

図 18-35 ExplosionParticleを設定する

[5]Particle Positionを設定する

次にParticle Positionを設定します。これはエフェクトを表示する場所を指定するプロパティとして構成しています。

これは[Hierarchy]ウィンドウで[Car]の子として、空のGameObjectを作成し、それを指定することにします。オブジェクト名はParticlePositionという名前にします。

図 18-36 Particle Positionを設定する

以上で完成です。衝突すると、爆発エフェクトが表示されるようになります。

図18-37 爆発エフェクトが表示された

18.7 _ 爆発音をつける

同様にして、爆発音をつけてみましょう。

【手順】爆発音をつける

[1]爆発音のアセットをダウンロードする

ここでは、Olivier Girardotの「Free Sound Effects Pack」を使います。ブラウザから[マイアセットに追加する]を選択して、プロジェクトに追加してください。

【Free Sound Effects Pack】

https://assetstore.unity.com/packages/audio/sound-fx/free-sound-effects-pack-155776

図 18-38 「Free Sound Effects Pack」を追加する

[2]音を出すコードに変更する

先ほどのExplodeCollidedObjectのコードを次のように変更します。
爆発音を出すのは、最後の行の次の部分です。

// 爆発音を再生
explosionAudioSource.PlayOneShot(audioClip);
using UnityEngine;

public class ExplodeCollidedObject : MonoBehaviour
{
    [SerializeField] private LayerMask targetLayers;
    [SerializeField] private ParticleSystem explosionParticle;
    [SerializeField] private Transform particlePosition;
    [SerializeField] private AudioClip audioClip;
    [SerializeField] private AudioSource explosionAudioSource;

    private void OnTriggerEnter(Collider other)
    {
        // 衝突対象のレイヤがtargetLayersに含まれている場合のみ実行
        if ((targetLayers & (1 << other.gameObject.layer)) == 0) return;
        // 衝突対象を破壊
        Destroy(other.gameObject);
        // パーティクルを再生して一定時間後に破壊
        var particle = Instantiate(explosionParticle, particlePosition);
        particle.transform.position = particlePosition.position;
        particle.Play();
        Destroy(particle.gameObject, 2f);
        // 爆発音を再生
        explosionAudioSource.PlayOneShot(audioClip);
    }
}

[3]AudioSourceを作成する

Inspectorから[Car]の直下に「ExplosionAudio」というAudioSourceオブジェクトを作成します。そのままだと爆発音が大きすぎるので、Volumeを0.1に下げます。

図 18-39 ExplosionAudioという名前のAudioSourceオブジェクトを作る

[4]Audio ClipとExplosion Audio Sourceを設定する

ExplodeCollidedObjectスクリプトのプロパティで、Audio ClipとExplosion Audio Sourceのそれぞれのプロパティを設定します。

Audio ClipインポートしたCannon impact 9を設定します
Explosion Audio Source手順[3]で作成したExplosionAudioを設定します
図 18-40 ExplodeCollidedObjectスクリプトのプロパティ

以上で設定完了です。実行すると、衝突したときに、爆発音が出ることを確認できます。

18.8 _ 衝突時にスコアをつける

次に、スコアを表示できるようにします。

図 18-41 完成品

18.8.1 _ スコアのUIを作る

Canvasを新たに作り、「GameCanvas」と名付けます。その子オブジェクトにText(TextMeshではないレガシーなText)を置いてScoreTextと名付けます。フォントの色や文字の配置位置はお好みでかまいません。一例として今回は、次のように設定しています。

Text:
  Color: 37D0F8
  FontSize: 40

Rect Transform:
  Pos X : 0
  Pos Y : 0
  Width: 350
  Height: 50
  Anchor Preset: top left
  Pivot: X=0, Y=1

18.8.2 _ スコアを表示する

次に、スコアを表示する仕組みを実装します。ここでは、スコアを表示するためのGameObjectを作り、そのGameObjectを通じて、スコア表示するように実装します。

【手順】スコアを表示する

[1]空のGameObjectを作る

空のGameObjectを作ります。ここでは「BombCarGame」という名前として作ります。

[2]スクリプトをアタッチする

作成したGameObjectを選択し、Inspectorで、[Add Component]―[New Script]を選択して、新規のスクリプトをアタッチします。スクリプト名は、GameObjectと同名のBombCarGameとします。

図 18-42 GameObjectを作りスクリプトをアタッチする

[3]スコア表示のコードを書く

手順[2]で作成したスクリプトに、次のコードを記述します。

このコードは、scoreTextプロパティで設定されたTextオブジェクトに対して、「Score:得点」の文字列を設定するものです。

AddScoreメソッドを呼び出すとスコアが加算され、RefreshScoreDisplayメソッドを呼び出すと、得点がTextオブジェクトに表示されます。

using UnityEngine;
using UnityEngine.UI;

public class BombCarGame : MonoBehaviour
{
    private int score;
    [SerializeField] private Text scoreText = 0;

    public void AddScore(int scoreAdder)
    {
        this.score += scoreAdder;
        RefreshScoreDisplay();
    }

    private void RefreshScoreDisplay()
    {
        this.scoreText.text = $"Score: {score.ToString()}";
    }
}

[4]Score Textプロパティを設定する

手順[3]のコードで参照しているスコアの表示先のTextオブジェクトをScore Textプロパティに設定します。ここで「18.8.1 スコアのUIを作る」で作成したScoreTextを設定します。

図 18-43 ScoreTextプロパティを設定する

[5]衝突時にスコアが加算されるようにする

衝突時の処理をしているExplodeCollidedObjectスクリプトを変更し、スコアを加算する処理を実装します。

ここでは、Gameプロパティで指定したBombCarGameオブジェクトのAddメソッド(これは先ほど手順[3]で実装したものです)を呼び出すことで、100点加算しています。

using UnityEngine;

public class ExplodeCollidedObject : MonoBehaviour
{
    [SerializeField] private LayerMask targetLayers;
    [SerializeField] private ParticleSystem explosionParticle;
    [SerializeField] private Transform particlePosition;
    [SerializeField] private AudioClip audioClip;
    [SerializeField] private AudioSource explosionAudioSource;
    [SerializeField] private BombCarGame game;

    private void OnTriggerEnter(Collider other)
    {
        // 衝突対象のレイヤがtargetLayersに含まれている場合のみ実行
        if ((targetLayers & (1 << other.gameObject.layer)) == 0) return;
        // 衝突対象を破壊
        Destroy(other.gameObject);
        // パーティクルを再生して一定時間後に破壊
        var particle = Instantiate(explosionParticle, particlePosition);
        particle.transform.position = particlePosition.position;
        particle.Play();
        Destroy(particle.gameObject, 2f);
        // 爆発音を再生
        explosionAudioSource.PlayOneShot(audioClip);
        // スコアを加算
        game.AddScore(100);
    }
}

[6]BombCarGameを割り当てる

ExplodeCollidedObjectスクリプトのプロパティで、GameプロパティをBombCarGameに設定します。

図 18-44 Gameプロパティを変更する

以上でスコアが加算されるようになりました。実行すると、衝突したときに、100点ずつ加算されることを確認できます。

図 18-45 スコアが表示されるようになった

18.8.3 _ 地物の種類に応じてスコアを変える

次に、地物の種類に応じて、スコアを変える処理を実装します。
ここからは地物の情報を取得するためにPLATEAU SDKのC# APIを利用します。

C# APIの仕様は、PLATEAU SDK for Unity Ver 2.0.0-alphaで大きく変わりました。このチュートリアルでは、v2.0.0-alpha向けの方法を扱います。それ以前のバージョンについては、コラムを参照してください。

◾️コードの変更

ExplodeCollidedObjectスクリプトを、次のように変更します。

using PLATEAU.CityGML;
using PLATEAU.CityInfo;
using UnityEngine;

public class ExplodeCollidedObject : MonoBehaviour
{
    [SerializeField] private LayerMask targetLayers;
    [SerializeField] private ParticleSystem explosionParticle;
    [SerializeField] private Transform particlePosition;
    [SerializeField] private AudioClip audioClip;
    [SerializeField] private AudioSource explosionAudioSource;
    [SerializeField] private BombCarGame game;

    private void OnTriggerEnter(Collider other)
    {
        // 衝突対象のレイヤがtargetLayersに含まれている場合のみ実行
        if ((targetLayers & (1 << other.gameObject.layer)) == 0) return;
        // 衝突対象を破壊
        Destroy(other.gameObject);
        // パーティクルを再生して一定時間後に破壊
        var particle = Instantiate(explosionParticle, particlePosition);
        particle.transform.position = particlePosition.position;
        particle.Play();
        Destroy(particle.gameObject, 2f);
        // 爆発音を再生
        explosionAudioSource.PlayOneShot(audioClip);
        // スコアを加算
        int scoreAdder = CalcScore(other.gameObject);
        game.AddScore(scoreAdder);
    }


    // 衝突対象のスコアを計算する。
    private int CalcScore(GameObject target)
    {
        // 地物に関する情報は、コンポーネントPLATEAUCityObjectGroupに入っているので取得する。
        var cityObjGroup = target.GetComponent<PLATEAUCityObjectGroup>();
        if (cityObjGroup == null || cityObjGroup.CityObjects.rootCityObjects.Count == 0)
        {
            Debug.Log("地物ではないので0点");
            return 0;
        }

        // PLATEAUCityObjectGroupの中に入っている主要地物の数は、地域単位でインポートしていれば複数があり得るが、
        // 今回は主要地物単位でインポートしているので1つのみである。
        // その1つを取得する。
        var cityObj = cityObjGroup.CityObjects.rootCityObjects[0];

        var type = cityObj.CityObjectType;

        if (type == CityObjectType.COT_SolitaryVegetationObject)
        {
            Debug.Log("木は5点");
            return 5;
        }

        if (type == CityObjectType.COT_CityFurniture)
        {
            Debug.Log("都市設備は20点");
            return 20;
        }

        if (type != CityObjectType.COT_Building)
        {
            Debug.Log("木でも都市設備でも建物でもないものは1点");
            return 1;
        }

        // 高さに関する情報があれば、それをスコアにする。
        // どのような情報を利用できるかは、PLATEAUウィンドウの属性情報表示モードから確認できる。
        if (cityObj.AttributesMap.TryGetValue("bldg:measuredheight", out var attribute))
        {
            int height = Mathf.RoundToInt((float)attribute.DoubleValue);
            int score = height * 10;
            Debug.Log($"建物の高さが約{height}mなので{score}点");
            return score;
        }
        
        Debug.Log($"建物の高さが取得できなかったので10点");
        return 10;
        
    }
}

◾️コードの動作

このコードは、地物の種類に応じて、次のように点数を加算しています(この根拠はDebug.Logで書き出しているので、ログで確認できます)。実際に実行して、その挙動を確認してみてください。

建物の場合高さが取得できた場合は、高さ[メートル]×10点
取得できない場合は10点
木の場合5点
都市設備の場合20点
木でも都市設備でもない場合1点
地物として認識できなかった場合0点

点数の取得は、各地物ゲームオブジェクトに付与されているコンポーネントPLATEAUCityObjectGroupから取得できる、地物の種類や建物の高さをもとに場合分けで実装しています。この処理は、CalcScoreメソッドにまとめています。

【メモ】

 属性を取得するメソッドについては「都市情報へのアクセス」も参照してください。

①種類の判定

地物の種類は、cityObj.CityObjectTypeで判別しています。

②高さなどの属性の取得

高さは、属性として取得します。属性は、cityObj.AttributesMapから取得します。

AttributesMapはキーとバリューのペアです。バリューは具体的な値、もしくは入れ子のAttributesMapで構成されています。今回は、TryGetValueメソッドに、キー「bldg.:measuredheight」を指定することで、建物の高さを取得しています。

PLATEAU SDK for Unity Ver 2.0.0-alphaよりも以前では、属性情報を取得するためには、GMLファイルのパースが必要でした。これは重い処理であり、その重さに対処するために非同期処理を書く必要があり、また、効率化のため、取得結果のキャッシュなども考慮する必要がありました。

しかしPLATEAU SDK for Unity Ver 2.0.0-alphaでは、属性情報はコンポーネント内に保存されているため、以前よりも高速に属性情報を取得できるようになりました。したがって非同期処理やキャッシュを考慮する必要もありません。

どのような属性を取得できるのかは、PLATEAU SDK for UnityのClickToShowAttributesサンプルを確認してください。PLATEAUの3D都市モデル標準製品仕様書の「4 データの内容および構造」にも情報があります。

コラム:PLATEAU SDK for Unity バージョン1の場合

PLATEAU SDK for Unity バージョン1の場合、次に示すコードとして実装します。

バージョン2ではシーン中のコンポーネントに属性情報が保存されるのに対し、バージョン1では保存されません。そのためバージョン1では、GMLファイルをパースして地物IDから属性情報を取得する処理が必要です。GMLファイルのパースには時間がかかるため、下記のコードでは、その処理を、非同期処理として実装しています。

using System.Threading.Tasks;
using PLATEAU.CityInfo;
using UnityEngine;

public class ExplodeCollidedObject : MonoBehaviour
{
    [SerializeField] private LayerMask targetLayers;
    [SerializeField] private ParticleSystem explosionParticle;
    [SerializeField] private Transform particlePosition;
    [SerializeField] private AudioClip audioClip;
    [SerializeField] private AudioSource explosionAudioSource;
    [SerializeField] private BombCarGame game;

    private void OnTriggerEnter(Collider other)
    {
        // 衝突対象のレイヤーがtargetLayerに含まれている場合のみ実行
        if ((targetLayers & (1 << other.gameObject.layer)) == 0) return;
        // 衝突対象を破壊
        game.Disappear(other.gameObject);
        // パーティクルを再生して一定時間後に破壊
        var particle = Instantiate(explosionParticle, particlePosition);
        particle.transform.position = particlePosition.position;
        particle.Play();
        Destroy(particle.gameObject, 2f);
        // 爆発音を再生
        explosionAudioSource.PlayOneShot(audioClip);
        // スコアを加算
        var task = CalcScoreAsync(other.gameObject);
        task.ContinueWith((t) =>
        {
            int scoreAdder;
            if (t.IsFaulted)
            {
                Debug.Log("高さを読み込めなかったので10点");
                scoreAdder = 10;
            }
            else
            {
                scoreAdder = t.Result;
            }

            game.AddScore(scoreAdder);
        }, TaskScheduler.FromCurrentSynchronizationContext());
    }

  // 衝突対象のスコアを計算する。非同期。
    // 最初はGMLファイルのパースに時間がかかるが、2回目以降はキャッシュされるので速い。
    private async Task<int> CalcScoreAsync(GameObject target)
    {
        // 地物なら、ヒエラルキーの親に".gml"と名前のつくものがあるはずなので探す
        var currentTrans = target.transform;
        GameObject gml = null;
        while (currentTrans != null)
        {
            if (currentTrans.gameObject.name.EndsWith(".gml"))
            {
                gml = currentTrans.gameObject;
                break;
            }

            currentTrans = currentTrans.parent;
        }

        // gmlの名称によって種類を判別できる
        if (gml == null)
        {
            Debug.Log("地物ではないので0点");
            return 0;
        }

        if (gml.name.Contains("veg"))
        {
            Debug.Log("木は5点");
            return 5;
        }

        if (gml.name.Contains("frn"))
        {
            Debug.Log("都市設備は20点");
            return 20;
        }

        if (!gml.name.Contains("bldg"))
        {
            Debug.Log("木でも都市設備でも建物でもないものは1点");
            return 1;
        }

        // 以下は建物の場合に実行される

        PLATEAUInstancedCityModel cityModelComponent = null;
        // 親にPLATEAUInstancedCityModelがあるはずなので探す
        while (currentTrans != null)
        {
            cityModelComponent = currentTrans.GetComponent<PLATEAUInstancedCityModel>();
            if (cityModelComponent != null)
            {
                break;
            }

            currentTrans = currentTrans.parent;
        }

        if (cityModelComponent == null)
        {
            Debug.Log("地物ではないので0点");
            return 0;
        }

        // gml名をもとにGMLファイルをパースする。
        // 重い処理なので非同期メソッドになっている。
        var cityModel = await cityModelComponent.LoadGmlAsync(gml.transform);
        // ゲームオブジェクト名をもとにcityObjectを取得する
        var cityObj = cityModel.GetCityObjectById(target.name);
        // 高さに関する情報があれば、それをスコアにする。
        // どのような情報を利用できるかは、SDK付属のClickToShowAttributesサンプルを参照。
        if (cityObj.AttributesMap.TryGetValue("bldg:measuredheight", out var attribute))
        {
            int height = Mathf.RoundToInt((float)attribute.AsDouble);
            int score = height * 10;
            Debug.Log($"{target.name}の建物の高さが約{height}mなので{score}点");
            return score;
        }

        Debug.Log($"{target.name}の建物の高さが取得できなかったので10点");
        return 10;
    }
}
        // 高さに関する情報があれば、それをスコアにする。
        // どのような情報を利用できるかは、PLATEAUウィンドウの属性情報表示モードから確認できる。
        if (cityObj.AttributesMap.TryGetValue("bldg:measuredheight", out var attribute))
        {
            int height = Mathf.RoundToInt((float)attribute.DoubleValue);
            int score = height * 10;
            Debug.Log($"建物の高さが約{height}mなので{score}点");
            return score;
        }
        
        Debug.Log($"建物の高さが取得できなかったので10点");
        return 10;
        
    }
}

地物のファイル名

PLATEAU SDK for Unityバージョン1で3D都市モデルをインポートした場合、「52385628_bldg_6697_op.gml」のように「.gml」で終わるPLATEAUのCityGMLファイルの命名規則に基づくオブジェクト名からの階層的な構造となります。この「_bldg_」の部分は、属性に基づく名称です。

【メモ】

PLATEAUの命名規則は、「3D都市モデル標準製品仕様書」で定められています。このドキュメントの「7. データ製品配布」に、地物ごとの「接頭辞」が定義されています。

図 18-46 階層的な構造

② 高さなどの属性の取得

高さは、属性として取得します。属性は、PLATEAUInstancedCityModelオブジェクトから取得します。

AttributesMapはキーとバリューのペアです。バリューは具体的な値、もしくは入れ子のAttributesMapで構成されています。今回は、TryGetValueメソッドに、キー「bldg.:measuredheight」を指定することで、建物の高さを取得しています。このオブジェクトはインポートしたとき、階層上にスクリプトとして設定されています。

図 18-47 PLATEAUInstancedCityModelオブジェクト

18.9 _ ゲームらしくする

最後に「タイトル画面」「ゲーム本編」「リザルト画面」を切り替えられるようにして、ゲームらしくします。

18.9.1 _ 実装する内容

この節では、次の3つのモードを切り替えることで、ゲームらしい流れを作ります。

タイトル画面

起動時にタイトル画面を表示します。タイトル画面で[Enter]キーを押すと、タイトル画面からゲーム本編に遷移します。

図 18-48 タイトル画面

ゲーム本編

ゲーム本編の画面です。制限時間をカウントする機能を新たに実装します。

図 18-49 制限時間をカウントする

リザルト画面

制限時間が切れたら、リザルト画面が表示されるようにします。[Enter]キーを押すと、タイトル画面に戻ります。

図 18-50 リザルト画面

これらはUnityを利用した一般的なプログラムであり、PLATEAUと直接の関係はありません。「タイトル」「ゲーム本編」「リザルト」のようなゲームモードの切り替えについての実装例を知りたいという方には参考になりますが、PLATEAUについてのみ知れればよいという方は、これでチュートリアルを終了してかまいません。

18.9.2 _ ゲームモードを切り替えるためのクラス

タイトル、ゲーム本編、リザルトを実装するにあたり、それぞれのモードをクラスとして実装します。

◾️基底クラス(GameModeBase.cs)

まずは、共通となる機能をGameModeBase.csとして実装します。このクラスでは、各ゲームモードは、ゲーム開始時の初期化処理があることと、ゲームモード開始時の処理を実装するMonoBehaviourであることを定義しています。

using UnityEngine;

/// <summary>
/// ゲームモードの基底クラス
/// </summary>
public abstract class GameModeBase : MonoBehaviour
{
    protected GameModeManager manager;
    
    /// <summary>
    /// ゲーム開始時の処理
    /// </summary>
    public void Init(GameModeManager managerArg)
    {
        manager = managerArg;
        OnGameInit();
    }
    
    /// <summary>
    /// ゲーム開始時の処理で、子クラスで実装するもの
    /// </summary>
    public abstract void OnGameInit();
    
    /// <summary>
    /// ゲームモード開始時の処理で、子クラスで実装するもの
    /// </summary>
    public abstract void OnGameModeStart();
}

◾️ ゲームモードマネージャ(GameModeManager.cs)

ゲームモードを切り替えるにあたって、ゲームモードマネージャを実装します。ゲームモードを配列の要素として保持し、切り替えるクラスです。

using UnityEngine;

/// <summary>
/// ゲームモードの切り替えを担います。
/// </summary>
public class GameModeManager : MonoBehaviour
{
    [SerializeField, Tooltip("ゲームモードの配列")]
    private GameModeBase[] gameModes;
    
    [SerializeField, Tooltip("最初のゲームモードのインデックス")]
    private int initialGameModeId;
    
    private int currentModeId;
    void Awake()
    {
        // 各ゲームモードで定義された、ゲーム開始時の初期化処理を実行する。
        foreach(var mode in gameModes)
        {
            mode.Init(this);
        }
        // 最初のゲームモードに遷移する。
        SetGameMode(initialGameModeId);
 }


    /// <summary>
    /// 次のゲームモードに移動する。
    /// 次とは、ゲームモード配列における現在の次である。次がない(最後の)場合は0番目である。
    /// </summary>
    public void NextGameMode()
    {
        SetGameMode((currentModeId + 1) % gameModes.Length);
    }
    
    /// <summary>
    /// 型によってゲームモードを検索して返す。
    /// </summary>
    public T GetGameMode<T>() where T : GameModeBase
    {
        foreach (var gameMode in gameModes)
        {
            if (gameMode is T found)
            {
                return found;
            }
        }


        return null;
    }


    /// <summary>
    /// ゲームモードを遷移させる。
    /// </summary>
    private void SetGameMode(int gameModeId)
    {
        currentModeId = gameModeId;
        // 現在のゲームモードを有効化し、他を無効化する。
        for (int i = 0; i < gameModes.Length; i++)
        {
            gameModes[i].gameObject.SetActive(i == gameModeId);
        }
        // ゲームモードで定義された、ゲームモード開始時処理を実行する。
        gameModes[gameModeId].OnGameModeStart();
    }
}

◾️タイトル画面のゲームモード(GameModeTitle.cs)

続いて、タイトル画面のゲームモードを実装します。初期化のときにtitleUIで指定されるタイトルのUIを表示します。[Enter]キーが押されたときは、titleUIを非表示にして、次のゲームモード(これはゲームマネージャに対して、ゲーム本編となるよう、あとで設定します)に進むようにします。

GameModeTitle:
using UnityEngine;

/// <summary>
/// タイトル画面を表示するゲームモード
/// </summary>
public class GameModeTitle : GameModeBase
{
    [SerializeField] private GameObject titleUI;


    public override void OnGameInit()
    {
    }

    /// <summary>
    /// タイトル画面の開始時、タイトルのUIを表示する。
    /// </summary>
    public override void OnGameModeStart()
    {
        titleUI.SetActive(true);
    }

    void Update()
    {
        // エンターキーが押されたとき、タイトルのUIを非表示にして次のゲームモードへ遷移する。
        if (Input.GetKeyDown(KeyCode.Return))
        {
            titleUI.SetActive(false);
            manager.NextGameMode();
        }
    }
}

◾️リザルト画面のゲームモード(GameModeResult.cs)

続いて、リザルト画面のゲームモードを実装します。ゲームが開始されるときは、resultUIで指定されるリザルト画面を非表示にします。そして、リザルト画面に切り替わったら、非表示にします。[Enter]キーが押されたときは、次のモード(タイトル画面)に遷移するようにしています。

using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// リザルト画面のゲームモード
/// </summary>
public class GameModeResult : GameModeBase
{
    [SerializeField] private GameObject resultUI;
    [SerializeField] private Text scoreText;

    /// <summary>
    /// ゲーム開始時、リザルト画面は非表示にする。
    /// </summary>
    public override void OnGameInit()
    {
        resultUI.SetActive(false);
    }

    /// <summary>
    /// リザルト画面の開始時、リザルト画面を表示する。
    /// </summary>
    public override void OnGameModeStart()
    {
        resultUI.SetActive(true);
    }

    public void SetResultScoreUI(int score)
    {
        scoreText.text = $"Score : {score}";
    }

    private void Update()
    {
        // エンターキーが押されたとき、リザルト画面を非表示にしてタイトル画面に戻る。
        if (Input.GetKeyDown(KeyCode.Return))
        {
            resultUI.SetActive(false);
            manager.NextGameMode();
        }
    }
}

◾️ゲーム本編のゲームモード(BombCarGame.cs)

ゲーム本編のゲームモードを実装します。このゲームモードは、すでに実装したBombCarGame.csを改変します。
主な変更点は、①GameModeBaseを継承するようにした、②制限時間を実装した、③ゲーム終了時の処理を実装した、の3点です。

なお、ゲーム終了時のメソッドEndGame()では、次のようにdisappearedObjectsList.AppearAllメソッドを呼び出していますが、これは、ゲーム中に衝突して消した地物を、次のプレイのとき戻すための処理です。すぐあとに説明します。

disappearedObjectsList.AppearAll()
using PLATEAU.Samples;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// メインのゲームモード
/// </summary>
public class BombCarGame : GameModeBase
{
    private int score;
    private int Score
    {
        get => score;
        set
        {
            score = value;
            RefreshScoreDisplay();
        }
    }

    [SerializeField] private CarController car;
    [SerializeField] private GameObject gameUI;
    [SerializeField] private Text scoreText;
    [SerializeField] private Text timeLimitText;
    [SerializeField] private float timeLimitSec = 20f;
    [SerializeField] private Transform carStartPosition;
    private float timeLeft;
    private DisappearedObjectsList disappearedObjectsList = new();
    
    /// <summary>
    /// ゲーム開始時はタイトル画面を表示したいので、車とゲームUIは非表示にする。
    /// </summary>
    public override void OnGameInit()
    {
        car.gameObject.SetActive(false);
        gameUI.SetActive(false);
    }

    /// <summary>
    /// ゲーム開始時に車とゲームUIを表示する。スコアと残り時間を初期値にする。
    /// </summary>
    public override void OnGameModeStart()
    {
        Score = 0;
        timeLeft = timeLimitSec;
        car.gameObject.SetActive(true);
        ResetCarPosition();
        gameUI.SetActive(true);
    }
    
    private void Update()
    {
        // 制限時間をカウントダウンする。時間切れでゲームを終了する。
        timeLeft -= Time.deltaTime;
        RefreshTimeLimitDisplay();
        if (timeLeft <= 0f)
        {
            EndGame();
        }
    }

    /// <summary>
    /// ゲームオブジェクトを非表示にし、それを覚えておく。
    /// </summary>
    public void Disappear(GameObject obj)
    {
        disappearedObjectsList.Disappear(obj);
    }

    public void AddScore(int scoreAdder)
    {
        Score += scoreAdder;
    }

    private void RefreshScoreDisplay()
    {
        scoreText.text = $"Score: {score.ToString()}";
    }

    private void RefreshTimeLimitDisplay()
    {
        timeLimitText.text = $"Time: {timeLeft:F1}";
    }

    private void ResetCarPosition()
    {
        car.transform.SetPositionAndRotation(carStartPosition.position, carStartPosition.rotation);
    }

    /// <summary>
    /// ゲーム終了の処理。
    /// </summary>
    private void EndGame()
    {
        // 車とゲームUIを非表示にする。
        car.gameObject.SetActive(false);
        gameUI.gameObject.SetActive(false);
        // リザルト画面のゲームモードにスコアを渡す。
        manager.GetGameMode<GameModeResult>().SetResultScoreUI(Score);
        // 車の位置を戻す。
        ResetCarPosition();
        // ゲーム中に爆発させたゲームオブジェクトをもとに戻す。
        disappearedObjectsList.AppearAll();
        // ゲームモード遷移
        manager.NextGameMode();
        Score = 0;
    }
}

◾️プレイ前に、破壊したものを戻すようにする

ここまでの実装では、車が地物と衝突したとき、その地物を削除してしまうので、2回目にプレイしたときは、1回目で消した地物がもう存在しません。そこで再プレイのときは、プレイ中に消した地物を元に戻せるようにします。

まずは、消滅させたGameObjectを覚えておき、それをゲーム終了時に復元できるようにするDisappearedObjectListクラスを実装します。これはオブジェクトを削除するのではなくて、非表示にして、あとでAppearAllメソッドを呼び出したときは、それらを表示するように戻す機能を持つものです。

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ゲーム中に消滅させたゲームオブジェクトは、ゲーム終了時に復元する必要がある。
/// そこで消したゲームオブジェクトを覚えておき、復元する機能をこのクラスで実装する。
/// </summary>
public class DisappearedObjectsList
{
    private readonly List<GameObject> disappearedObjs = new();

    /// <summary>
    /// ゲームオブジェクトを非表示にする。それを覚えておく。
    /// </summary>
    public void Disappear(GameObject obj)
    {
        obj.SetActive(false);
        disappearedObjs.Add(obj);
    }

    /// <summary>
    /// 非表示にしたゲームオブジェクトをすべて復元する。
    /// </summary>
    public void  AppearAll()
    {
        foreach (var obj in disappearedObjs)
        {
            obj.SetActive(true);
        }
        disappearedObjs.Clear();
    }
}

現在、衝突したときに地物を消すコードは、ExplodeCollidedObject.csにあり、次のようにDestroyメソッドで削除する処理にしています。

// 衝突対象を破壊
Destroy(other.gameObject);

これを削除ではなく、いま実装したDisappearedObjectListのDisappearメソッドの呼び出しに置き換え、非表示にするように変更します。

// 衝突対象を非表示
game.Disappear(other.gameObject);

18.9.3 _ UIの実装

コードができたら、次にUIを実装します。

タイトル画面

下記の図 18-51のようにタイトル画面を作成します。Canvasを作ってTitleCanvasと名付けます。その子ゲームオブジェクトとしてVerticalLayoutGroupという名前のゲームオブジェクトを作り、そこに同名のコンポーネントであるVerticalLayoutGroupを付与します。その子に、1行ずつ、2つのUI Text(Legacy Text)を並べています。

具体的には、VerticalLayoutGroupは次のように設定します。

Rect Transform:
Anchor Presets: stretch
Left, Top, Right, Bottom = 0
Vertical Layout Group:
Child Alignment: Middle Center
Control Child Size: Width, Heightにチェック
Use Child Scale: Width, Heightにチェック
Child Force Expand: Width, Heightでチェックを外す

Legacy Textは次のように設定します。

1行目:

名前: TitleText
Text: BombCarGame

フォントサイズや色はお好みですが、スコア表示と同じで良いでしょう。

2行目:

名前: PushEnterText
Text: Push Enter to Start

色は1行目と同じで、フォントサイズは1行目より少し小さいくらいが格好良いと思います。

図 18-51 タイトル画面

制限時間のUI

制限時間を表示するUIを作ります。

すでにスコアを表示するUI(GameCanvas)を作ったところですが、そのゲーム画面のUIに、新たに制限時間を表示するLegacy UI TextをScoreの下に追加します。今回は簡易的に、スコア表示のテキストを複製して下に移動し、そのテキストを “Time: 0” とします。そのゲームオブジェクトの名前をTimeLimitTextにします。

図 18-52 制限時間をカウントするテキストを付ける

リザルト画面

下図のように、リザルト画面を作成します。リザルト画面はタイトル画面と似ていて、画面中央に数行のテキストが表示される構成にします。そこで、簡易的なやり方ではありますが、TitleCanvasを複製してResultCanvasと名付けます。

テキストは3行、1行ずつ1つのLegacy Textとします。1行目のテキストは”Result”と記載し、2行目は”Score: 0”、3行目は”Push Enter to back to title”と記載します。

図 18-53 リザルト画面

各Textのゲームオブジェクト名は、上から「TextResultTitle」「TextResultScore」「TextBackToTitle」とします。

2行目のTextResultScoreのフォントサイズを大きくすることで、見栄えを整えます。

18.9.4 _ コードの配置と紐付け

次に、コードを配置して紐付けていきます。

【手順】コードの配置と紐付け

[1]新しいGameObjectを3つ作る

[Hierarchy]ウィンドウから、新しいGameObjectを3つ作ります。それぞれの名前を「GameModeTitle」「GameModeResult」「GameModeManager」とします。これらのGameObjectは、それぞれ順に、以降、タイトル画面、リザルト画面、ゲームマネージャとして構成していきます。

場所はどこでも良いですが、今回はルート直下に作ります。

図 18-54 3つのGameObjectを作る

[2]タイトル画面を構成する

手順[1]で作成したGameModeTitleに、GameModeTitleスクリプトをアタッチします。Title UIに、タイトルとして作成したCanvas(ここではTitleCanvas)を指定します。

図 18-55 タイトル画面(GameModeTitle)を構成する

[3]リザルト画面を構成する

手順[1]で作成したGameModeResultに、GameModeResultスクリプトをアタッチします。Result UIに、リザルト画面として作成したCanvas(ここではResultCanvas)を指定します。Score Textには、リザルト画面上でスコアを表示するテキスト(ここではTextResultScore)を指定します。

図 18-56 リザルト画面(GameModeResult)を構成する

[4]車や制限時間に関するプロパティを設定する

BombCarGameのプロパティを開き、次のものを設定します。

Car制御する車を示します。
シーン上のCarオブジェクトを設定してください。
Game UIゲーム画面のCanvasを指定します。
ここでは「18.8.1 スコアのUIを作る」で構成したCanvasである「GameCanvas」を指定します。
Score Textスコアを表示するUIです。
「18.8.1 スコアのUIを作る」で構成したScoreTextを指定します。
Time Limit Text制限時間を表示するUIです。
「18.8.3 UIの実装」で構成したTimeLimitTextを指定します。
Time Limit Sec制限時間です。好みで設定してください。
Car Start Position車の初期位置です。
適当な場所(例えば駅前の広場)に空のGameObjectを作り、そのGameObjectを指定してください。
なお、CarStartPositionの前方(選択したときのZ軸の青い矢印の向き)が車の初期の向きになります。
図 18-57 制限時間に関するプロパティを設定する

[5]ゲームモードマネージャを構成する

手順[1]で作成したGameModeManagerに、GameModeManagerスクリプトをアタッチして、ゲームモードマネージャとして動くようにします。

プロパティでGameModesの配列数を3にし、それぞれの要素のゲームモードを設定します。その要素の値として、0番目にタイトル画面の「GameModeTitle」、1番目がゲーム本編の「BombCarGame」、2番目にリザルト画面の「GameModeResult」を設定します。

図 18-58 ゲームモードマネージャ(GameModeManager)を構成する

18.9.5 _ ビルドして実行する

メニューの[File]―[Build Settings]を開きます。

[Add Open Scenes]ボタンをクリックしてシーンを追加し、[Build And Run]ボタンをクリックします。するとゲームがビルドされ、遊べるようになります。

図 18-59 シーンを追加してビルドする

18.10 _ 今後の展開

このチュートリアルでは、複雑になるのを避けるため、ビルをすぐに消しましたが、ビルを傾けたり、窓や壁が飛び散ったりするようなエフェクトも作れます。

またPLATEAU SDK for Unityには、エクスポート機能もあります。3D都市モデルを3Dモデルファイル(OBJ、FBX、glTF)として出力できます。エクスポートしたファイルをBlenderなどの3DCGソフトで編集すれば、現実の都市をベースにして、より高度なアートワークを作るのにも活用できるでしょう。

【文】

鈴木智貴(株式会社シナスタジア)、大澤文孝