20191202のUnityに関する記事は19件です。

[Unity,AR]Unityを使って超簡単にiOS向けARファイルを作成する

はじめに

皆さんはUSDZファイルをご存じでしょうか?
簡単にいってしまうと3Dシーン(モデル等)を保存したファイルです。
このファイルの特徴の一つにiOS12以上のiPhoneであれば追加でアプリをインストールすることなくARプレビュー可能というものがあります。1
追加でアプリをインストールする必要がなく、ブラウザさえあればプレビュー可能なのでお手軽にAR体験が可能です。

参考:Quick Lookギャラリー - 拡張現実 - Apple Developer

昨年USDZファイルを作成する記事を書いたのですが、このときはまだ情報やツールが出そろっておらず大変面倒でした。
今年になってUnityを使用すれば超簡単にUSDZファイルを作成することができるということが分かったのでその方法をご紹介します。

この記事を読めば以下のようなものを作成することができます。

注:以下の内容はWindowsでのみ動作確認を行っております。

モデルの準備(Unityインポートまで)

AR化するためのモデルを用意します。
今回は『禍つヴァールハイト』バージョンのシロちゃんを使用させていただきました。

siro.jpg

https://www.magatsu-wahrheit.com/news/387/ より引用

©App Land ©KLabGames

!!必ず使用するモデルの利用規約に目を通してください。また、詳細なインポート方法については解説しません。!!

FBXをエクスポートする際には以下の点に気を付けてください。
(基本的にBlenderベースの解説です。)

  1. Unityにインポートしたときにスケルトンの回転が0になるようにする
  2. 1メッシュ1マテリアルになるようにする
  3. 頂点数を抑える
  4. ブレンドシェイプを焼き付ける
  5. テクスチャを同梱する

1. Unityにインポートしたときにスケルトンの回転が0になるようにする

これをしておかないとアニメーションをつける際に面倒なことになります。2
アニメーションをつけない場合は特に問題はありません。

Blenderの場合は以下の操作で実行できます。

1.ArmatureをObjectModeでX-90度に回転する

image.png

2.Ctrl-Aで出てくるメニューからApply Rotationを実行する

image.png

実行後にArmatureのRotationが0,0,0になっていることを確認してください。

3.ArmatureをObjectModeでX90度に回転する

前回の操作でRotationが0,0,0になっているはずなので90,0,0になるように回転します。
このときにApply Rotationを実行してはいけません。

image.png

4.キャラクタのすべてのメッシュを選択しApply Rotationを実行する

image.png

この状態でFBXエクスポートを行うとArmatureの回転が0になっています。
参考:Blender to Unity with Correct Scale and Rotation

2. 1メッシュ1マテリアルになるようにする

これについてはよくわかりませんが1メッシュに複数のマテリアルが付いていると表示されません。
1メッシュ1マテリアルになるように分割しましょう。

3. 頂点数を抑える

特に説明することはありませんが、あまりにも頂点数が多いと古い端末ではモデルが表示されないことがあります。

4.ブレンドシェイプを焼き付ける

iOSのAR表示ではブレンドシェイプが適用されません。3
表情をつけたい場合はエクスポートする段階でブレンドシェイプの情報をモデルに焼き付けておく必要があります。

Blenderの場合は以下の操作で実行できます。

この操作はモデルを直接編集するので実行する前にバックアップを取っておいてください

1.オブジェクトモードでシェイプキーの値を変更する。

image.png

2.Mixキーを作る

複数のシェイプキーを使用する場合はMixキーを作成する必要があります。
複数のシェイプキーの値を設定した後、右側のボタンからNew Shape From Mixを実行してください。

image.png

その後、最初に設定したシェイプキーの値を0に戻しMixキーの値を1にします。
そうすると最初に設定した状態の頂点になります。

image.png

3.Mixキー以外のキーを削除する

この状態でMixキー以外のすべてのキーを削除します。
するとFBX書き出しの際にデフォルトの頂点情報がMixキーで設定した状態になります。

image.png

image.png

5.テクスチャを同梱する

これは必須ではありませんがやっておくとテクスチャの設定が楽になります。

Blenderの場合は以下の操作で実行できます。
  • FBXをエクスポートする際のオプションでPathModeをCopyにして右のアイコンが以下の画像の状態になるようにする。

モデルの準備(Unity上での操作)

Unity上では以下の操作を行います。

  1. テクスチャの設定
  2. マテリアルの設定
  3. メッシュの設定
  4. スケルトンの確認
  5. Transformの確認
  6. (必要に応じて)DynamicBoneの設定

1. テクスチャの設定

FBXにテクスチャを同梱している場合、以下のボタンからテクスチャをアセットとしてUnityに取り込むことができます。

取り込んだテクスチャの名前は必ずアルファベットのみで構成されるようにしてください。
日本語が使用されているとうまく表示されない可能性があります。4

2. マテリアルの設定

今回紹介する方法では基本的にはStandardシェーダーを使う必要があります。
それ以外のシェーダを使用している場合はStandardシェーダーでうまく見えるように調整してください。

3. メッシュの設定

モデルのメッシュは必ずRead/Write Enabledにしておく必要があります。

4. スケルトンの確認

ヒエラルキー上にモデルを配置し、スケルトンのRotationが0,0,0になっていることを確認してください。

必ずUnityにインポートした時点で既にRotationが0,0,0になるようにしてください。
ヒエラルキーやプレハブのRotationをあとから編集した場合はアニメーションを行った際に正しく表示されない可能性があります。
0,0,0になっていない場合はUnityにインポートしたときにスケルトンの回転が0になるようにするを参考に設定してください。

5. Transformの確認

Transformの名前がすべてアルファベットのみで構成されるようにしてください。
日本語が使用されている場合うまく表示されない可能性があります。

6. (必要に応じて)DynamicBoneの設定

アニメーションをつける場合はDynamicBoneがあるかないかで見栄えの差が大きくなります。
DynamicBoneは以下のページから購入できます。

Dynamic Bone - Asset Store

どうしても無料がいい場合はSpringBoneを使用するという選択肢もあります。
DynamicBone/SpringBoneの設定についてはWebに素晴らしい記事がありますので検索してみてください。

スカートをいい感じに見せるためには以下の記事が参考になります。

[VRChat]ふわっとしたスカートで座っても安心のDynamicBone吊りスカート

アニメーションしないUSDZファイル出力

まずはアニメーションしないバージョンのUSDZファイルを作成します。
UnityのパッケージマネージャーからUSDをインストールしてください。
(2019/12/1時点では)USDはプレビューパッケージなのでShow preview packagesにチェックを入れていないと出てこないので注意してください。

image.png

インストールが完了するとUnityのメニューにUSDが追加されます。
ヒエラルキー上に配置したモデルを選択した状態でUSD > Export Selected as USDZを実行します。

するとファイルの保存先が聞かれるので適当な場所に保存します。
これで完了です:rocket:
すごく簡単ですね:relaxed:

保存したファイルをiPhoneでダウンロードすればプレビューすることができます。

サムネイルの状態で前後が逆になっている場合、
以下のようなヒエラルキーにした状態でRootを選択してエクスポートを実行します。


もしテクスチャが貼られていない場合や一部のメッシュが表示されていない場合はテクスチャ名、Transform名に日本語が使用されていないかどうかチェックしてください。

PCにpythonがインストールされている場合は以下のコマンドで簡単にHTTPサーバーを立てることができます。

cd path/to/usdz/directory
# python2系
python -m SimpleHTTPServer
# python3系
python -m http.server

アニメーションするUSDZファイル出力

アニメーションするUSDZファイルを出力するのはしないものに比べて多少面倒になります。
また、USDパッケージだけでもやろうと思えばできますが簡単にするためのユーティリティを作成したのでそちらを使用します。

yaegaki/UnityUsdzUtil

リリースのページからUnityUsdzUtil.unitypackageをダウンロードしてください。

UnityUsdzUtil.unitypackageには以下のファイルが入っています。

image.png

パッケージをインポートしたらUsdzRecordStand.prefabをシーンに配置してください。
そしてエクスポートしたい対象をUsdzRecordStand > Rootの子オブジェクトとしに設定してください。
以下の画像のような状態になれば大丈夫です。

image.png

その後、Unityを実行状態にしてモデルにアニメーションを適用します。
エクスポートしたい場所でUsdzRecordStandRecordボタンをクリックします。

image.png

するとRecordボタンを押してから既定では約5秒後にusdzファイルの出力が完了します。
このファイルをiPhoneでダウンロードすれば作業は完了です:point_up:

おまけ1 - UsdzRecordStandのインスペクタ項目について

項目 説明
ExportDirectory usdzファイルを作成するディレクトリ
ExportFileName usdzファイルの名前。空白の場合Rootの最初の子の名前になる。
CreateUsdaFile 中間ファイルとしてusdaを作成する。主にデバッグ用。
ThumbnailCamera サムネイルを撮影するカメラ
ExportRoot エクスポートするルート
FrameRate 出力するアニメーションのフレームレート
RecordSec 出力するアニメーションの時間
FlipZ 出力時にY+180度回転する。
PauseWhenFinished Recordが終わった時にエディタを一時停止する。
CurrentFrame 現在記録中のフレーム
Snapshot/Record 出力ボタン。プレイ中かそうでないかでSnapshotかRecordが変わる。
ExportFromFile CreateUsdaFileで作成したファイルを使ってusdzファイルを作成する。主にデバッグ用。

おまけ2 - UsdzHttpServer

UsdzRecordStand.prefabにはもう一つUsdzHttpServerというスクリプトが付いています。
これはその名の通りHttpサーバースクリプトです。

StartボタンをクリックするとUsdzファイルの配信サーバーが立ち上がります。
http://Unityを起動しているマシンのIP:19900にiPhoneでアクセスすると以下のようにサムネイル付きでUsdzファイルを確認することができます。

トラブルシューティング

USDZファイルの中身がおかしい

USDZファイルの中にモデルが出力されない場合以下の原因が考えられます。

  • テクスチャ名に日本語を使用している
  • Transform名に日本語を使用している
  • モデルのRead/Write Enabledがオンになっていない
  • 1メッシュに複数のマテリアルが使用されている

これらの設定を確認してみてください。

アニメーションを適用したモデルが90度倒れて表示される場合はスケルトンに回転が掛かっている可能性があります。
スケルトン(Armature)の回転が0,0,0になっていることを確認してください。

iPhoneで表示する際にエラーが発生する場合はモデルの頂点数が多すぎる、長すぎるアニメーション時間などが考えられます。
頂点数の削減や記録時間を短くするなどの対策をしてください。

どうしても原因が分からない場合はUSD Toolsetを使って原因を調査します。
ただし、USD Toolsetはソースでしか配布されていないのでビルドする必要があるので少し手間です。

USDZのファイルサイズが大きすぎる

usdzファイルのファイルサイズが大きすぎる場合、テクスチャの設定を見直すことで改善される可能性があります。
テクスチャのサイズやフォーマットを変更してみてください。
少しでもファイルサイズを小さくしたい場合はモデルの頂点数を削減する、アニメーションの時間を短くするなどを行ってください。

DynamicBoneが暴れる

Unityを普通に実行したときとアニメーション付きUSDZを出力しながら再生したときでDynamicBoneの挙動に差がある場合があります。
アニメーション付きUSDZファイルの出力にはそこそこマシンパワーが必要なため、
1フレームの時間が長くなることによってDynamicBoneが暴れます。
その場合はDynamicBoneのUpdateRateを調節してみてください。
それでもうまくいかない場合はUnityのTimestepを変更するという最終手段があります。
Timestepを以下のように設定すると1フレームにかかる現実の時間は長くなりますが、いい感じに出力できる可能性があります。

image.png

UsdzHttpServerにアクセスできない

一度Stopしてからもう一度Startしてみてください。
それでもアクセスできない場合はPCのファイアーウォールの設定を確認してください。

出力時にエラーが出て失敗する

エラーメッセージを見て対処してください。
エラーメッセージが解決の参考にならない、どうしても解決できない場合はPCを再起動してみてください。
解決する可能性があります。

おわりに

モデルやアニメーションの設定が少し手間ですが、設定してしまえばすごく手軽にAR表示までもっていくことができます。
アニメーション付きのモデルをAR表示させると思っていた以上に良いので皆さんもぜひ試してみてください。


  1. iPhone6sより前の端末では使用できません。またiPhoneX系の端末とそれ以前の端末で多少機能に違いがあります。 

  2. iOSで表示する際にスケルトンの回転情報が無視されます。usdviewで見ると正常なのでおそらくバグです。iOSのバージョンが上がることで問題なくなるかもしれません。 

  3. usdの仕様上は存在していますがunity-usdが対応しておらず、iOS側も対応していません。将来的には対応されるかもしれません。 

  4. Windowsで作業している場合は反映されません。Macで作業した場合は反映されるかもしれませんが未確認です。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityとスポナーとTerrainとわたし

はじめに

いなたつアドカレの二日目の記事です。

今回はUnityのTerrainでフィールドを生成し、地面の高さにかかわらず地面から一定距離に敵をスポーンさせる方法を紹介します。

じっそー

spawner.cs
int posx = 500;
int posy = 500;
float height = Terrain.activeTerrain.terrainData.GetInterpolatedHeight(
                posx / Terrain.activeTerrain.terrainData.size.x,
                posz / Terrain.activeTerrain.terrainData.size.z);

これで、x座標:500,z座標:500 でのTerrainの地面の高さ(y座標)を変数heightに取得できます。

これを利用し、posx,posyを乱数などで指定することでTerrain上の(x,y,z)を取得し、Vector3型で三次元座標で値を返すことで、

Instantiate(target, spawnPos(), transform.rotation);

このようにすることで、targetという変数にはいった敵をスポーンさせることができますね。
第二引数がさきほどの、Vector3型の値を返す関数ですね。

注意

返すVector3型の値をreturn new Vector3(posx,height,posy) のようにしてしまうと、オブジェクトのサイズによっては埋まってしまう可能性が考えられるため、heightに少し値を加算し余裕を持たせる方が、安心できます。

良きUnityライフを

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】シーンの重複読み込みをLINQで防ぐ

Unityのシーンの重複読み込み対応をLINQでやる

これは、 C# その2 Advent Calendar 2019 の5日目の記事です。

Unityで、2つのシーンをまたいで共通する処理がある場合、追加読み込み専用の共通シーンを作ったりすることがあります。

ただし、追加読み込みは気を付けないと、重複して読み込んだりして、バグのもとになります。

今回は、LINQを使って、そのあたりを効率よくコーディングする手法を紹介します。

結論だけ知りたい方へ

以下のような静的クラスを作っておいて、シーン読み込みする際に、すでに読み込まれているかどうかを検査すれば、重複読み込みを避けることができます!

SceneController.cs
using UnityEngine.SceneManagement;
using System.Linq;

public static class SceneController
{
    /** 既にシーンが読み込まれているかどうか */
    public static bool AlreadyLoadScene(string name)
    {
        return SceneManager.GetAllScenes()
            .Any(scene => scene.name == name);
    }
}

この結論だけ読んでもピンと来ない方もいらっしゃると思いますので、順番に説明しますね!

サンプルプロジェクト

サンプルプロジェクトを以下に置きましたので、必要に応じてご確認ください。

GitHub / segurvita / UnityScenePractice

TitleシーンとMainシーンを行き来したい

ゲームを作る際に、タイトル画面からメイン画面に遷移するといった機能を実装することがよくあります。

たとえば、

  • タイトル画面は Title.unity
  • メイン画面は Main.unity

のように、別々のシーンファイルにするのが一般的です。

image.png

上記の図のように Mキーを押したらMainシーンに遷移 したい場合は、以下のようなスクリプトを書くかと思います。

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            SceneManager.LoadScene("Main");
        }
    }
}

Mainシーンの方にも同じようなスクリプトを設置すると思います。

これで、とりあえずシーン遷移をすることはできるようになりました!

Commonシーンを共通で読み込むことになった

以下の図のように Common シーンを読み込むことになったとします。

image.png

Titleシーンが起動した際に、Commonシーンを読み込むようにすれば、できそうです。

以下のように Awake() を追加してみました。

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Awake()
    {
        SceneManager.LoadScene("Common", LoadSceneMode.Additive);
    }

    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            SceneManager.LoadScene("Main");
        }
    }
}

LoadSceneMode.Additive を指定することで、シーン遷移をせずにCommonシーンを追加で読み込むことができます。

これと同じような変更をMainシーン側にもしてみます。

これによって、Titleシーン、Mainシーン、どちらに遷移してもCommonシーンを読み込むことができるようになりました!

Commonシーンを破棄しちゃダメ!って言われた場合

ここまでのコードだと、シーン遷移する度に、Commonシーンも破棄されています。

もし、Commonシーンで音楽の再生等をしていた場合は、シーン遷移するときに音楽も止まってしまいますね!

これだと困るので、以下のように、シーン遷移も LoadSceneMode.Additive でやってしまいましょう!

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Awake()
    {
        SceneManager.LoadScene("Common", LoadSceneMode.Additive);
    }

    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            SceneManager.LoadScene("Main", LoadSceneMode.Additive);
        }
    }
}

これと同じような変更をMainシーン側にもしてみます。

するとどうなるでしょうか?

実はこれ、非常に危険なコードです!

このままだと、シーン遷移する度に、Commonシーン・Mainシーン・Titleシーンの3つが重複して読み込まれてしまうんです!

しかも、性質の悪いことに指数関数的に累積していくので、あっという間にメモリがあふれます。

シーンがすでに読み込まれているか確認する

重複してシーンが読み込まれるのを防ぐためには、同じ名前のシーンがすでに読み込まれているか確認する必要があります。

以下のような静的クラスを作りましょう。

SceneController.cs
using UnityEngine.SceneManagement;

public static class SceneController
{
    /** 既にシーンが読み込まれているかどうか */
    public static bool AlreadyLoadScene(string name)
    {
        for (int i = 0; i < SceneManager.sceneCount; i++)
        {
            if (SceneManager.GetSceneAt(i).name == name)
            {
                return true;
            }
        }
        return false;
    }
}

SceneManager.sceneCount で、現在アクティブなシーンの数を数え、 SceneManager.GetSceneAt(i) でそのシーンのデータを取得しています。

すべてのシーンの name を確認していき、一致したものがあれば、そのシーンはすでに読み込まれているということになります。

この静的クラスを適当なフォルダーに設置した上で、さきほどの TitleScene.cs を以下のように改修しましょう。

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Awake()
    {
        if (!SceneController.AlreadyLoadScene("Common"))
        {
            SceneManager.LoadScene("Common", LoadSceneMode.Additive);
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            if (SceneController.AlreadyLoadScene("Title"))
            {
                SceneManager.UnloadSceneAsync("Title");
            }
            SceneManager.LoadScene("Main", LoadSceneMode.Additive);
        }
    }
}

さきほど作った SceneController.AlreadyLoadScene() で、シーンがすでに読み込まれているかを確認して、それによって、 LoadSceneUnloadSceneAsync を呼ぶようにしました。

これで、メモリがあふれる心配はなくなりました!

LINQで改良してみる

せっかく作った SceneController.AlreadyLoadScene() ですが、 for 文を使ってる部分が、少し冗長な気がします。

LINQで改良したものがこちらです。

SceneController.cs
using UnityEngine.SceneManagement;
using System.Linq;

public static class SceneController
{
    /** 既にシーンが読み込まれているかどうか */
    public static bool AlreadyLoadScene(string name)
    {
        return SceneManager.GetAllScenes()
            .Any(scene => scene.name == name);
    }
}

解説をすると、まず、 SceneManager.GetAllScenes() で、アクティブなシーンの一覧を取得しています。

その次に、 .Any(scene => scene.name == name) で名前の一致するシーンが存在するかどうか確認しています。

コード量が減ってかなりスッキリしましたね!

さいごに

今回ご紹介した方法のほかにも、 DontDestroyOnLoad を使った方法等があると思います。

そのあたりは、ご自身の趣味趣向に合わせて、使いたいものを使うのがよいかと思います。

本記事作成にあたり、以下のページを参考にさせていただきました。ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity + PlayFab + ADX2で、Asset Bundleを介さないサウンドデータの配信を実装する

ADX2音声データをネット経由でサーバーからダウンロードする

スマートフォンゲーム開発において、アプリの初期インストールサイズを小さく抑えることは必須課題です。
ストアに置くアプリは最小限のデータを持たせ、ゲーム起動後にリソースを外部のサーバーからダウンロードする手法はよく見ます。ゲームアプリの中で特にファイルの容量を大きく占めるデータは画像と音声データです。

そこで今回は、Unityゲーム開発アプリにおいて、音声データをビルド結果から切り離し、ゲーム起動後に外部のサーバーからダウンロードする仕組みを作ってみます。
サーバーとCDNを介したデータの配信には、Microsoftのゲーム向けBaaSである「PlayFab」を使います。また、サウンド再生ライブラリとして「CRI ADX2 LE」を使います。
本記事では、WindowsとiOSでの動作を確認しています。

※サーバーから直接ストリーミング再生をするのなく、ゲーム用データとして端末ストレージにダウンロード・保存して、そこからサウンドを再生する仕組みです。

PlayFab

アプリから外部リソースを取得する場合、何らかの方法でネット経由でデータを渡さなくてはなりません。
ある程度の規模のある開発現場では、AWSやGCP、Azureのサービス群でデータをホストし、CDNサービスを通じてデータ配信をするネットワークシステムを構築することが通例です。

ただ、個人ゲームアプリや小規模なプロジェクトの場合は、でそうしたバックエンドを構築するのは手間が大きく、効率的ではありません。かといってGoogle DriveやDropboxなどのクラウドドライブやアップローダーは、こうしたゲーム用データ配布には使用できません。不特定多数のユーザーから大量かつ同時にアクセスされる使われ方を想定していないため、すぐにアクセス制限がかかります。

playfab.png

そこで、データ配信のCDN機能を併せ持つゲーム向けバックエンドサービスである「Microsoft PlayFab」を使います。

PlayFab
https://developer.playfab.com/

実装にはこちらの記事を参考にしました。

UnityでPlayFabのFileContentをDownloadしてSaveする
https://qiita.com/simplestar/items/47dcfaa213a62a7aa360

CRI ADX2 LE

Unityでは外部からリソースをダウンロードして利用する場合、Asset Bundleを経由する必要があります。画像などバイナリから直接インポートできるものもありますが、
Unity標準サウンドのAudio ClipはAsset Bundle化する必要があります。Asset Bundleの運用にはひと手間かかります。
(oggやmp3を直接読むNetworking.UnityWebRequestMultimedia.GetAudioClip()は、キャッシュの機能が無いので不向き?)

そこで、統合型サウンドミドルウェアの「CRI ADX2」を使います。今回は無償版である「CRI ADX2 LE」を利用しました。バージョンは v2.10.05です。

LELOGO_512_t.png

ADX2 LE
https://game.criware.jp/products/adx2-le/

CRI ADX2は、圧縮音声データをUnityとは別のツール(SDKに含まれているCRI Atom Craft)で生成し、StreamingAssetsからバイナリとしてロード・マウントします。
Unityのアセット管理の管轄外データになるため、Asset Bundleを経由せずにサーバーから追加のデータをダウンロードし、利用できます。
また、ADX2のデータはSceneやPrefabが参照を持たないため、自動でビルドに含まれてしまう、ということがありません。ゲームアプリ本体に含めるデータと、外部からダウンロードしてもらうデータを切り分けることが容易になります。

導入までのチュートリアル、Atom Craftの基本的な使い方についてはこちらをご覧ください。

Unityのサウンド機能をADX2で強化する
https://qiita.com/Takaaki_Ichijo/items/16e6501fc07f5b3b3377

実装手順

実装の手順としては次のようになります。

0.Unity開発環境にPlayFab, CRI ADX2を組み込む
1.Atom Craftでサーバーアップロード用のデータを作る
2.PlayFabのファイル管理(FileContent)にアップロードする
3.UnityからPlayFabのファイルURLを取得
4.ファイルをダウンロードし、端末ストレージに保存する
5.ストレージからファイルをADX2にマウントする
6.再生

Atom Craftでサーバーアップロード用のデータを作る

まずはCRI Atom Craftを起動して、配信用のキューシートを作成し、再生するキューを用意します。

新しいキューシート・キューを作る.png

例では「NewBGM」というキューシートとしています。キューシートの中にはキューID「1」のキュー「BGM_House」が登録されています。
キューシート名、キュー名は任意の名前で問題ありません。DLCや配信イベントの名前など、外部サーバーから渡すデータであることが分かりやすい名前にするとよいでしょう。

Atom Craftの上では、配信用のデータとゲームアプリ本体に収録するデータの違いはありません。キューを作成したら、通常通りビルドを行います。
ビルドを完了したら、出力したデータの保存先ディレクトリを開いておきます。

デフォルトでは C:\Users[ユーザー名]\Documents\CRIWARE\CriAtomCraft[プロジェクト名]\Public[ワークユニット名]\の中です。

PlayFabのファイル管理(FileContent)にアップロードする

PlayFabの管理画面にログインし、ゲームのプロジェクトのダッシュボードを開いてから、左メニューから「コンテンツ」をクリックします。
「ファイル管理」タブ(英語版インタフェースではFileContent)を開きます。ここに配布するデータをアップロードします。

「新しいフォルダー」をクリックして音楽データ保存用のmusicフォルダを作り、その中で「ファイルをアップロード」を選びます。
先ほどビルドしたファイルから、配布したいキューシートデータをアップします。この場合はNewBGM.acbをアップロードします。

playfabadx2data.png

ストリーミング再生(スマホのストレージからメモリへストリーミングするの意味、ネットストリームではない)を使う場合は、ファイルがacbとawbの2種類必要になります。
その際は両方のファイルをアップロードするようにします。

UnityからPlayFabのファイルURLを取得

アップロードしたファイルをダウンロードしてみましょう。
PlayFabのファイルにアクセスするためのURLを取得するコードを用意します。

PlayFabController.cs
    public IEnumerator GetFileCdnUrl(string key)
    {
        string url = string.Empty;

        PlayFabClientAPI.GetContentDownloadUrl(new GetContentDownloadUrlRequest()
            {
                Key = key,
                ThruCDN = true
            }, result =>
            {
                url = result.URL;
            },
            error => Debug.LogError(error.GenerateErrorReport()));

        while (string.IsNullOrEmpty(url))
        {
            yield return null;
        }

        yield return url;
    }

PlayFabCrientAPI.GetContentDownloadUrlメソッドをもちいて、指定の名称のファイルのURLを取得します。
このメソッドを実行する前にユーザーとしてログインする必要があります。

keyにはPlayFabのコンソール上で作ったディレクトリ名とファイル名を入れます。例では、music/NewBGM.acbを指定します。また、CDN経由の配信であるフラグThruCDNにtrueを指定します。

ファイルをダウンロードし、端末ストレージに保存する

UnityWebRequestを用いて先ほどのURLにアクセスし、ストレージにファイルをダウンロードします。

ExternalDataManager.cs
public class ExternalDataManager : MonoBehaviour
{
    private const string NoBackUpDirectory = "GameData";
    private string deviceGameDataPath;

    private void Awake()
    {
        deviceGameDataPath = Path.Combine(Application.persistentDataPath, NoBackUpDirectory);

        if (Directory.Exists(deviceGameDataPath) == false)
        {
            Directory.CreateDirectory(deviceGameDataPath);

         //NoBackUpDirectoryはiCloudバックアップから外す//
#if UNITY_IOS
        UnityEngine.iOS.Device.SetNoBackupFlag(Path.Combine(Application.persistentDataPath, NoBackUpDirectory));
#endif
        }
    }

    public IEnumerator DownLoadDataToStorage(string fileName, string url)
    {
        Debug.Log("start download " +url);
        UnityWebRequest www = UnityWebRequest.Get(url);
        yield return www.SendWebRequest();

        if (www.isNetworkError || www.isHttpError)
        {
            Debug.Log(www.error);

            yield return null;
        }
        else
        {
            byte[] results = www.downloadHandler.data;

            var filePath = Path.Combine(deviceGameDataPath, fileName);

            string directoryName = Path.GetDirectoryName(filePath);

            if (directoryName != null && Directory.Exists(directoryName) == false)
            {
                Directory.CreateDirectory(directoryName);
            }

            File.WriteAllBytes(filePath, results);

            yield return filePath;
        }
    }
}


上記の例では、ファイルはApplication.persistentDataPathで取得できる領域に、「GameData」ディレクトリを用意してから保存しています。
PCの場合はC:\Users[ユーザー名]\AppData\LocalLow[組織名][アプリ名]\ になるので、この例ではGameData/musicディレクトリの下に acbファイルが保存されます。

また、iOS用の設定として、Awakeの中でGameDataファイルをiCloud管理下から外す処理を行っています。これは巨大なゲームデータを渡した場合、すべてiCloud Backupに含まれてしまうのを防ぐためです。

ストレージからファイルをADX2にマウントする

ストレージに保存できたら、ファイルをADX2にキューシートとしてマウントします。

ExternalDataManager.cs
  public string GetDataPathFromStorage(string fileName)
    {
        var di = new DirectoryInfo(deviceGameDataPath);
        var fileInfo = di.EnumerateFiles(fileName, SearchOption.AllDirectories).FirstOrDefault();

        return fileInfo?.FullName;
    }

上記の例は、指定のストレージデータフォルダ内にファイルが存在するか確認し、あればそのパスを渡すメソッドです。
ファイルパスが無事取得できたら、CriAtom.AddCueSheetAsyncメソッドを通じてマウントします。

BGMPlayer.cs
    private CriAtomExAcb cueSheet;
    private CriAtomExPlayer criAtomExPlayer;

    private void Awake()
    {
        criAtomExPlayer = new CriAtomExPlayer();
    }

    public IEnumerator LoadCueSheetCoroutine(string cueSheetName, string path)
    {
        CriAtom.AddCueSheetAsync(cueSheetName, path, "");

        while (CriAtom.CueSheetsAreLoading == true)
        {
            yield return null;
        }

        cueSheet = CriAtom.GetCueSheet(cueSheetName).acb;
    }

    public void Play(int cueId)
    {
        criAtomExPlayer.SetCue(cueSheet, cueId);
        criAtomExPlayer.Start();
    }

呼び出し側は次の通りです。

まずストレージに必要なファイルがあるか確認し、なければPlayFabからURLを取得してストレージにダウンロードする、という処理順です。

LoadExternalMusicData.cs
    public string externalAcbFileName = "NewBGM.acb";
    public string externalAcbDirectoryName = "music";

    IEnumerator LoadExternalBGM()
    {
        var path = externalAcbDirectoryName + "/" + externalAcbFileName;

        var externalFileDataPath  = externalDataManager.GetDataPathFromStorage(path);

        if (string.IsNullOrEmpty(externalFileDataPath))
        {
            //ログイン後に呼ぶ
            IEnumerator getUrl = playFabController.GetFileCdnUrl(path);

            yield return getUrl;

            if (getUrl.Current != null)
            {
                string url = getUrl.Current.ToString();

                IEnumerator dataLoad =  externalDataManager.DownLoadDataToStorage(path, url);

                yield return dataLoad;

                object current = dataLoad.Current;

                externalFileDataPath = current.ToString();
            }
        }

        yield return bgmPlayer.LoadCueSheetCoroutine(externalAcbFileName, externalFileDataPath);
    }

再生

acbをマウントしてしまえば、再生方法は通常のADX2の利用方法と全く同じです。
BGMPlayer.csの例では、CriAtomExPlayerクラスを介して、キューIDを使って再生しています。

運用の方針

Unity + PlayFab + ADX2で、AssetBundleを介さないサウンドデータ配信が可能となりました。
ゲームアプリ内にはメニューボタン音は最初のBGMなど最低限のデータを用意しておき、起動後の追加リソースダウンロードして、その他のBGMやボイスデータをダウンロードする運用が可能になります。

今回はシンプルにストレージへ保存しましたが、1回しか再生しないイベントシーンで使うボイスデータは使ったらすぐストレージから消してもかまいませんし、プレイヤーにキャッシュを選ばせることも可能です。
最近のトレンドはゲーム機同時に「BGMやボイスデータを一括ダウンロードする / しない」を選ばせるスタイルですので、このオプションがあると一番良いかと思います。

また、ロードしたデータのアンロードについても管理が必要です。
その操作については、次の記事を参照にしてください。

Unity + ADX2におけるサウンドデータの読み込みと破棄
https://qiita.com/Takaaki_Ichijo/items/c7e14234f799fdca3e68

おまけ:メタデータを埋め込み

acbファイルを単体で配信すると、そのデータが何のために使うものなのか、把握しづらい場面が想定できます。そこで、このファイルの中に文字情報を埋め込んでしまいましょう。

ADX2は音の単位を「キュー」という単位で処理しますが、キューにはさまざまな再生用設定の他、「ユーザーデータ」という領域に任意の文字列を保存し、スクリプトから読みだすことができます。
たとえば追加楽曲をADX2を使って配信するとき、作曲者や歌手などの曲にまつわるメタデータを「ユーザーデータ領域」に埋め込んで配信できます。

ユーザーデータの埋め込み.png

ユーザーデータは、次の手続きでアクセスできます。

AccessAcbUserData.cs
private CriAtomExAcb eventVoiceAcb;

public string GetVoiceUserData(int cueId)
{
    CriAtomEx.CueInfo cueInfo;
    eventVoiceAcb.GetCueInfo(cueId, out cueInfo);

    return cueInfo.userData;
}

これで、ある程度のメタデータは埋め込むことができます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity + PlayFab + ADX2でAsset Bundleを介さないサウンドデータの配信を実装する

Unityゲームアプリのサウンドデータをサーバーからダウンロードする

スマートフォンゲーム開発において、アプリの初期インストールサイズを小さく抑えることは必須課題です。
ストアに置くアプリは最小限のデータを持たせ、ゲーム起動後にリソースを外部のサーバーからダウンロードする手法はよく見ます。ゲームアプリの中で特にファイルの容量を大きく占めるデータは画像と音声データです。

そこで今回は、Unityゲーム開発アプリにおいて、音声データをビルド結果から切り離し、ゲーム起動後に外部のサーバーからダウンロードする仕組みを作ってみます。
サーバーとCDNを介したデータの配信には、Microsoftのゲーム向けBaaSである「PlayFab」を使います。また、サウンド再生ライブラリとして「CRI ADX2 LE」を使います。
本記事では、WindowsとiOSでの動作を確認しています。

※サーバーから直接ストリーミング再生をするのなく、ゲーム用データとして端末ストレージにダウンロード・保存して、そこからサウンドを再生する仕組みです。

PlayFab

アプリから外部リソースを取得する場合、何らかの方法でネット経由でデータを渡さなくてはなりません。
ある程度の規模のある開発現場では、AWSやGCP、Azureのサービス群でデータをホストし、CDNサービスを通じてデータ配信をするネットワークシステムを構築することが通例です。

ただ、個人ゲームアプリや小規模なプロジェクトの場合は、でそうしたバックエンドを構築するのは手間が大きく、効率的ではありません。かといってGoogle DriveやDropboxなどのクラウドドライブやアップローダーは、こうしたゲーム用データ配布には使用できません。不特定多数のユーザーから大量かつ同時にアクセスされる使われ方を想定していないため、すぐにアクセス制限がかかります。

playfab.png

そこで、データ配信のCDN機能を併せ持つゲーム向けバックエンドサービスである「Microsoft PlayFab」を使います。

PlayFab
https://developer.playfab.com/

実装にはこちらの記事を参考にしました。

UnityでPlayFabのFileContentをDownloadしてSaveする
https://qiita.com/simplestar/items/47dcfaa213a62a7aa360

CRI ADX2 LE

Unityでは外部からリソースをダウンロードして利用する場合、Asset Bundleを経由する必要があります。画像などバイナリから直接インポートできるものもありますが、Unity標準サウンド機能のAudio ClipはAsset Bundle化して渡す必要があります。Asset Bundleの運用にはひと手間かかります。
(oggやmp3を直接読むNetworking.UnityWebRequestMultimedia.GetAudioClip()は、キャッシュの機能が無いので不向き?)

そこで、統合型サウンドミドルウェアの「CRI ADX2」をUnityプロジェクトに組み込んで使います。今回は無償版である「CRI ADX2 LE」を利用しました。バージョンは v2.10.05です。

LELOGO_512_t.png

ADX2 LE
https://game.criware.jp/products/adx2-le/

CRI ADX2は、圧縮音声データをUnityとは別のツール(SDKに含まれているCRI Atom Craft)で生成し、StreamingAssetsからバイナリとしてロード・マウントします。
Unityのアセット管理の管轄外データになるため、Asset Bundleを経由せずにサーバーから追加のデータをダウンロードし、利用できます。
また、ADX2のデータはSceneやPrefabが参照を持たないため、自動でビルドに含まれてしまう、ということがありません。ゲームアプリ本体に含めるデータと、外部からダウンロードしてもらうデータを切り分けることが容易になります。

導入までのチュートリアル、Atom Craftの基本的な使い方についてはこちらをご覧ください。

Unityのサウンド機能をADX2で強化する
https://qiita.com/Takaaki_Ichijo/items/16e6501fc07f5b3b3377

実装手順

実装の手順としては次のようになります。

0.Unity開発環境にPlayFab, CRI ADX2を組み込む
1.Atom Craftでサーバーアップロード用のデータを作る
2.PlayFabのファイル管理(FileContent)にアップロードする
3.UnityからPlayFabのファイルURLを取得
4.ファイルをダウンロードし、端末ストレージに保存する
5.ストレージからファイルをADX2にマウントする
6.再生

Atom Craftでサーバーアップロード用のデータを作る

まずはCRI Atom Craftを起動して、配信用のキューシートを作成し、再生するキューを用意します。

新しいキューシート・キューを作る.png

例では「NewBGM」というキューシートとしています。キューシートの中にはキューID「1」のキュー「BGM_House」が登録されています。
キューシート名、キュー名は任意の名前で問題ありません。DLCや配信イベントの名前など、外部サーバーから渡すデータであることが分かりやすい名前にするとよいでしょう。

Atom Craftの上では、配信用のデータとゲームアプリ本体に収録するデータの違いはありません。キューを作成したら、通常通りビルドを行います。
ビルドを完了したら、出力したデータの保存先ディレクトリを開いておきます。

デフォルトでは C:\Users[ユーザー名]\Documents\CRIWARE\CriAtomCraft[プロジェクト名]\Public[ワークユニット名]\の中です。

PlayFabのファイル管理(FileContent)にアップロードする

PlayFabの管理画面にログインし、ゲームのプロジェクトのダッシュボードを開いてから、左メニューから「コンテンツ」をクリックします。
「ファイル管理」タブ(英語版インタフェースではFileContent)を開きます。ここに配布するデータをアップロードします。

「新しいフォルダー」をクリックして音楽データ保存用のmusicフォルダを作り、その中で「ファイルをアップロード」を選びます。
先ほどビルドしたファイルから、配布したいキューシートデータをアップします。この場合はNewBGM.acbをアップロードします。

playfabadx2data.png

ストリーミング再生(スマホのストレージからメモリへストリーミングするの意味、ネットストリームではない)を使う場合は、ファイルがacbとawbの2種類必要になります。
その際は両方のファイルをアップロードするようにします。

UnityからPlayFabのファイルURLを取得

アップロードしたファイルをダウンロードしてみましょう。
PlayFabのファイルにアクセスするためのURLを取得するコードを用意します。

PlayFabController.cs
    public IEnumerator GetFileCdnUrl(string key)
    {
        string url = string.Empty;

        PlayFabClientAPI.GetContentDownloadUrl(new GetContentDownloadUrlRequest()
            {
                Key = key,
                ThruCDN = true
            }, result =>
            {
                url = result.URL;
            },
            error => Debug.LogError(error.GenerateErrorReport()));

        while (string.IsNullOrEmpty(url))
        {
            yield return null;
        }

        yield return url;
    }

PlayFabCrientAPI.GetContentDownloadUrlメソッドをもちいて、指定の名称のファイルのURLを取得します。
このメソッドを実行する前にユーザーとしてログインする必要があります。

keyにはPlayFabのコンソール上で作ったディレクトリ名とファイル名を入れます。例では、music/NewBGM.acbを指定します。また、CDN経由の配信であるフラグThruCDNにtrueを指定します。

ファイルをダウンロードし、端末ストレージに保存する

UnityWebRequestを用いて先ほどのURLにアクセスし、ストレージにファイルをダウンロードします。

ExternalDataManager.cs
public class ExternalDataManager : MonoBehaviour
{
    private const string NoBackUpDirectory = "GameData";
    private string deviceGameDataPath;

    private void Awake()
    {
        deviceGameDataPath = Path.Combine(Application.persistentDataPath, NoBackUpDirectory);

        if (Directory.Exists(deviceGameDataPath) == false)
        {
            Directory.CreateDirectory(deviceGameDataPath);

         //NoBackUpDirectoryはiCloudバックアップから外す//
#if UNITY_IOS
        UnityEngine.iOS.Device.SetNoBackupFlag(Path.Combine(Application.persistentDataPath, NoBackUpDirectory));
#endif
        }
    }

    public IEnumerator DownLoadDataToStorage(string fileName, string url)
    {
        Debug.Log("start download " +url);
        UnityWebRequest www = UnityWebRequest.Get(url);
        yield return www.SendWebRequest();

        if (www.isNetworkError || www.isHttpError)
        {
            Debug.Log(www.error);

            yield return null;
        }
        else
        {
            byte[] results = www.downloadHandler.data;

            var filePath = Path.Combine(deviceGameDataPath, fileName);

            string directoryName = Path.GetDirectoryName(filePath);

            if (directoryName != null && Directory.Exists(directoryName) == false)
            {
                Directory.CreateDirectory(directoryName);
            }

            File.WriteAllBytes(filePath, results);

            yield return filePath;
        }
    }
}


上記の例では、ファイルはApplication.persistentDataPathで取得できる領域に、「GameData」ディレクトリを用意してから保存しています。
PCの場合はC:\Users[ユーザー名]\AppData\LocalLow[組織名][アプリ名]\ になるので、この例ではGameData/musicディレクトリの下に acbファイルが保存されます。

また、iOS用の設定として、Awakeの中でGameDataファイルをiCloud管理下から外す処理を行っています。これは巨大なゲームデータを渡した場合、すべてiCloud Backupに含まれてしまうのを防ぐためです。

ストレージからファイルをADX2にマウントする

ストレージに保存できたら、ファイルをADX2にキューシートとしてマウントします。

ExternalDataManager.cs
  public string GetDataPathFromStorage(string fileName)
    {
        var di = new DirectoryInfo(deviceGameDataPath);
        var fileInfo = di.EnumerateFiles(fileName, SearchOption.AllDirectories).FirstOrDefault();

        return fileInfo?.FullName;
    }

上記の例は、指定のストレージデータフォルダ内にファイルが存在するか確認し、あればそのパスを渡すメソッドです。
ファイルパスが無事取得できたら、CriAtom.AddCueSheetAsyncメソッドを通じてマウントします。

BGMPlayer.cs
    private CriAtomExAcb cueSheet;
    private CriAtomExPlayer criAtomExPlayer;

    private void Awake()
    {
        criAtomExPlayer = new CriAtomExPlayer();
    }

    public IEnumerator LoadCueSheetCoroutine(string cueSheetName, string path)
    {
        CriAtom.AddCueSheetAsync(cueSheetName, path, "");

        while (CriAtom.CueSheetsAreLoading == true)
        {
            yield return null;
        }

        cueSheet = CriAtom.GetCueSheet(cueSheetName).acb;
    }

    public void Play(int cueId)
    {
        criAtomExPlayer.SetCue(cueSheet, cueId);
        criAtomExPlayer.Start();
    }

呼び出し側は次の通りです。

まずストレージに必要なファイルがあるか確認し、なければPlayFabからURLを取得してストレージにダウンロードする、という処理順です。

LoadExternalMusicData.cs
    public string externalAcbFileName = "NewBGM.acb";
    public string externalAcbDirectoryName = "music";

    IEnumerator LoadExternalBGM()
    {
        var path = externalAcbDirectoryName + "/" + externalAcbFileName;

        var externalFileDataPath  = externalDataManager.GetDataPathFromStorage(path);

        if (string.IsNullOrEmpty(externalFileDataPath))
        {
            //ログイン後に呼ぶ
            IEnumerator getUrl = playFabController.GetFileCdnUrl(path);

            yield return getUrl;

            if (getUrl.Current != null)
            {
                string url = getUrl.Current.ToString();

                IEnumerator dataLoad =  externalDataManager.DownLoadDataToStorage(path, url);

                yield return dataLoad;

                object current = dataLoad.Current;

                externalFileDataPath = current.ToString();
            }
        }

        yield return bgmPlayer.LoadCueSheetCoroutine(externalAcbFileName, externalFileDataPath);
    }

再生

acbをマウントしてしまえば、再生方法は通常のADX2の利用方法と全く同じです。
BGMPlayer.csの例では、CriAtomExPlayerクラスを介して、キューIDを使って再生しています。

運用の方針

Unity + PlayFab + ADX2で、AssetBundleを介さないサウンドデータ配信が可能となりました。
ゲームアプリ内にはメニューボタン音は最初のBGMなど最低限のデータを用意しておき、起動後の追加リソースダウンロードして、その他のBGMやボイスデータをダウンロードする運用が可能になります。

今回はシンプルにストレージへ保存しましたが、1回しか再生しないイベントシーンで使うボイスデータは使ったらすぐストレージから消してもかまいませんし、プレイヤーにキャッシュを選ばせることも可能です。
最近のトレンドはゲーム機同時に「BGMやボイスデータを一括ダウンロードする / しない」を選ばせるスタイルですので、このオプションがあると一番良いかと思います。

また、ロードしたデータのアンロードについても管理が必要です。
その操作については、次の記事を参照にしてください。

Unity + ADX2におけるサウンドデータの読み込みと破棄
https://qiita.com/Takaaki_Ichijo/items/c7e14234f799fdca3e68

おまけ:メタデータを埋め込み

acbファイルを単体で配信すると、そのデータが何のために使うものなのか、把握しづらい場面が想定できます。そこで、このファイルの中に文字情報を埋め込んでしまいましょう。

ADX2は音の単位を「キュー」という単位で処理しますが、キューにはさまざまな再生用設定の他、「ユーザーデータ」という領域に任意の文字列を保存し、スクリプトから読みだすことができます。
たとえば追加楽曲をADX2を使って配信するとき、作曲者や歌手などの曲にまつわるメタデータを「ユーザーデータ領域」に埋め込んで配信できます。

ユーザーデータの埋め込み.png

ユーザーデータは、次の手続きでアクセスできます。

AccessAcbUserData.cs
private CriAtomExAcb eventVoiceAcb;

public string GetVoiceUserData(int cueId)
{
    CriAtomEx.CueInfo cueInfo;
    eventVoiceAcb.GetCueInfo(cueId, out cueInfo);

    return cueInfo.userData;
}

これで、ある程度のメタデータは埋め込むことができます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity + C# でゲームを作るのは意外と簡単だというお話

本記事は、何か布教したいエンジニア AdventCalendar 3日目の記事です!!!
周りの人に声をかけまくってみたものの、既に2度目の私の出番が到来してしまいました。
僕は皆の頭の中を覗きたくてこのカレンダーを作ったんだ……!
お願いです!誰か記事を書いて!!!!

概要

ゲーム好きな人であれば、1度は「ゲームを作ってみたい」と思ったことがあるのではないでしょうか。
かく言う私もその1人です。
大昔は、プログラムで1から、描画やBGMを鳴らすといった機能まで作らなければならず、
ソフトウェア面、ハードウェア面、数学、物理の知識など、幅広く、深い知識が求められていたとかいないとか。
ですが、現在はUnityやUnreal Engineといったソフトウェア達に基本的な機能や処理は全て実装されていて、
自分はゲームの仕組み作りに熱中できるのです!
本記事では、自身がゲーム作りに挑戦した時の事を振り返りながら、
意外とゲームを作るのは簡単だぜっていう事を広められたらと思います。

1. 参考書を読む

最初は、いきなり作りたいものに取り掛かるのではなく基礎を固めようと参考書に手を出しました。
ネットにもチュートリアル記事は沢山ありますが、体系的にまとまっているものは多くありません。
今の時代、本のほうが逆にかかる労力が少ない…!と思って、以下の本を読みました(1勝2敗)

Unityの教科書 Unity 2018完全対応版

初心者向けに画像を多く、出来るだけ平易な言葉でゲーム開発について教えてくれます。
ゲーム開発に必要な素材もダウンロード出来ますし、手堅い一冊でしょう。
この本の特徴ではありませんが、この本の著者は、Unityの開発者を手助けするようなブログを書かれています。その内容が結構使えました。
まず、Unityを扱う上で必要な考え方の基礎は、しっかりとこの1冊でついたと思います。

Unityの教科書 Unity 2018完全対応版 2D&3Dスマートフォンゲーム入門講座

Unity&宴「ノベルゲーム」開発入門 (I・O BOOKS)

ノベルゲーム及び、ゲームの会話部分を自分で開発するのではなく、パッケージに任せてしまおうと思って、宴を購入しました。
紹介している参考書は、その宴の入門本です。
中身は、公式サイトで紹介されていることがそのまま載っているイメージです。
今まで開発をやったことが無い人間、例えばシナリオライターが宴を利用して文字起こしを担当するような事があれば有用かもしれません。

Unity&宴「ノベルゲーム」開発入門 (I・O BOOKS)

ゲーム作りのはじめかた Unityで覚える企画からレベルデザインまで

あまりにも内容が薄くて倒れるかと思った一冊。
本書のサブタイトルは、「Unityで覚える企画からレベルデザインまで」である。
つまり、ゲームの企画部分からゲームを開発して、その難易度調整までの一連の流れ全てを解説してくれる本という訳だ。
ゲーム開発を行っていく上で、どのように企画を練って実装に落としていくのか知りたかった私にはうってつけの本だと思って当時は購入した。
しかし、だ。本書は解説する部分が広すぎて、何もかも浅かった。
全てにおいて、「何故、どうしてそうするのか?」といった部分がおいてけぼりになっていて、言われるがままに「こうした方が面白そうですよね!」的な言葉で企画を作った気になり、「おまじないとして覚えてくださいね!」で全て流されているから、気付いたら言われたままのゲームが出来た。でも何故どうしてそうなったのかは全く分からない。といった状態に陥る。
小学生や中学生くらいの時点で、プログラムを利用してゲームを作りたい子が居れば有用かもしれない。
ただ、もし最初のステップとして本書を選ぼうとしているのであれば、RPGツクールなりなんなりを利用して、まずは形のあるゲームを完成させる所から始めたほうが良いと思う。

ゲーム作りのはじめかた

2. Unityの公式チュートリアルを行う

遠回りもしたものの、Unityの基礎は固まった。
ただ、今までの参考書では本当に簡単なゲームしか開発していなかったため、
実際に自分がゲームを開発していくビジョンが見えなかった。
その為、Unity公式が出しているチュートリアルに手を出しました。
今後開発していくにあたって、3Dゲームの場合は素材の入手が大変だろうということで2Dのチュートリアルです。
また、ゲーム開発においてシューティングゲームは入門に丁度いいと聞いたことがあったので、シューティングゲームを選びました。
残念な事に、Unity公式のアップデートによって私が触れたチュートリアルは無くなってしまったようです。
もし貴方がチュートリアルを行うのであれば、自分が漠然と開発してみたいと思っているゲームジャンルに近い物を選ぶと、オリジナルゲームを開発する時の大きな助けになるでしょう。

1つ開発に関するアドバイスをしておくと、作成したゲームをビルドすると文字が表示されない現象が発生します。
文字を出力するオブジェクトには、フリーでダウンロードしてきたフォントを選択することをオススメします。

3. 実際にゲームを作ってみる

実際に作成したゲームがこれです。
ブラザーを救え!

私はバーチャルYoutuberの富士葵さんが大好きで、またゲームを作ろうとしていた当時は彼女の活動1周年と丁度近い時期だったこともあり、彼女にまつわるゲームを作ろうと思い立ちました。

【富士葵】なんでもないや/RADWIMPS 『君の名は。』 【2018 Cover】

歌劇団と呼ばれる、同じく富士葵さんが好きな知り合いも多くいましたが、成功するかも分からない初めて作成するゲームに彼、彼女達を巻き込めないと思い、素材を始め全て自身で作成しました。
絵だけではなく、ゲームバランスも皆無の内容に今見ると笑ってしまいますが、当時は結構真剣でした。
敵ごとに移動パターンやライフ、射撃パターンを用意したり、ボスっぽい演出方法を考えたりなど、結構工夫を凝らすのは楽しかったですね。
チュートリアルで作成したのも、実際に作成したのもシューティングゲームだったこともあり、作品自体は1,2週間程で完成しました。

4. 制作してみて思ったこと

オブジェクトに動作を割り当てていく感じなので、感覚としてはクラスにメソッドを作成していっているような感じでした。
ただ動かすだけなら簡単ですが、ゲームにおいて何が面白い要素なのか考えることが面白かったと同時に非常に難しかったです。
また、目立たない、自分が当たり前だと思っているオブジェクトの動作が意外と難しいというか、実現できなかったりで、もどかしい。
ゲーム規模が大きくなってきたら、60FPSを保つために創意工夫したりしないといけないし、様々な能力が要求される世界なんだなぁと思いました。

5. まとめ

端的にまとめると、
1. 読みやすいUnityの入門書を1冊読みながら手を動かす
2. Unityのチュートリアルを1本やる。

上記の2STEPさえ踏めば、簡単なゲームは作れるようになってるハズ。

6. あとがき

シンプルなゲームを作成するだけであれば、少し本を読めば結構簡単に作成出来るんだ。というのが、一連の制作を通してみての感想でした。
ただ、世の中に出ているようなゲームを作成するためには、やっぱり非常に高度な技術が要求されるし、イラストやBGMなど自分一人で賄うのは非常に大変でしょう。
しかし、大変だと分かっていても、周りをなんとか巻き込んでもっとゲームを作ってみたいと私は思いました。
まだシンプルなゲームの制作を続けていて、あまり人も巻き込めていませんが、少しずつ前に進んでいきたいです。
本記事の情報を通して、僅かな人数でも手を動かしてみようと思って、その中のたった1人でもゲーム開発をしてみようと踏み出すことが出来たのであれば幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GameLift RealTimeServerで遊んでみよう for Unity(AWS設定編)

はじめに

GameLiftとは

Amazon GameLift は、クラウド内でセッションベースのマルチプレイヤーゲームサーバーをデプロイ、操作、スケーリングする完全マネージド型サービスです。

とのこと。ここでは説明を割愛するので詳しくはこちらを参照してください。
イメージは掴んでおいてください。

簡単にオンラインマルチプレイが実装できるのなら嬉しい。

対象者

  • AWSのアカウントを持っている方
  • GameLiftでとりあえず遊んでみたいと考えているUnityエンジニアの方
  • AWSの無料利用枠がまだある方、もしくは使用金額を払ってでもやりたい方(この辺りは自己責任で)
  • 今回作成するものをきちんとクリーンアップできる方

このページで行うこと

主にAWSの設定周りになります。

GameLiftでの設定

  • スクリプトの作成
  • フリートの作成
  • エイリアスの作成

IAMでの設定

  • アクセスするユーザーの作成

スクリプトの作成

sever.js
'use strict';

var session;
var logger;

function init(rtSession) {
}

function onProcessStarted(args) {
    return true;
}

function onStartGameSession(gameSession) {
}

function onProcessTerminate() {
}

function onPlayerConnect(connectMsg) {
    return true;
}

function onPlayerAccepted(player) {
}

function onPlayerDisconnect(peerId) {
}

function onPlayerJoinGroup(groupId, peerId) {
    return true;
}

function onPlayerLeaveGroup(groupId, peerId) {
    return true;
}

function onSendToPlayer(gameMessage) {
    return true;
}

function onSendToGroup(gameMessage) {
    return true;
}

function onMessage(gameMessage) {
}

function onHealthCheck() {
    return true;
}

exports.ssExports = {
    init: init,
    onProcessStarted: onProcessStarted,
    onStartGameSession: onStartGameSession,
    onProcessTerminate: onProcessTerminate,
    onPlayerConnect: onPlayerConnect,
    onPlayerAccepted: onPlayerAccepted,
    onPlayerDisconnect: onPlayerDisconnect,
    onPlayerJoinGroup: onPlayerJoinGroup,
    onPlayerLeaveGroup: onPlayerLeaveGroup,
    onSendToPlayer: onSendToPlayer,
    onSendToGroup: onSendToGroup,
    onMessage: onMessage,
    onHealthCheck: onHealthCheck
};
  • 作成したスクリプトをGameLiftにアップロードしていきます

  • メニューから「スクリプトを作成」を選択

スクリーンショット 2019-12-02 1.15.43のコピー.png

  • スクリプト設定
    名前 : 任意で
    バージョン : 0.1.0とか

  • スクリプトコード
    スクリプトタイプ : Zipファイル
    Zipファイル : 作成したファイルを設定(zipに圧縮して)

  • 上記を設定し「送信」を押下、アップロードしていく

スクリーンショット 2019-12-02 1.35.00.png

フリートの作成

  • メニューから「フリートの作成」を選択
    スクリーンショット 2019-12-02 1.15.43のコピー2.png

  • フリートの詳細に設定する

  • 名前 : 任意の名前

  • スクリプト : 先ほど作ったスクリプト
    スクリーンショット 2019-12-02 1.29.15.png

  • インスタンスタイプはデフォルトのc5.large(その時の安いやつ選んでね)

  • 起動パス : 今回作成したスクリプトのパス

  • 同時プロセス数 : 任意(今回は10を指定)
    スクリーンショット 2019-12-02 1.33.30.png

  • 「フリートの初期化」を押下し、作成する
    ※少し時間がかかる。

エイリアスの作成

  • メニューから「エイリアスの作成」を選択
    スクリーンショット 2019-12-02 1.15.43のコピー4.png

  • フリートは先ほど作成したものを設定し、「エイリアスを設定」を押下
    (選択できない場合は作成に時間がかかってる場合があり、しばし待つ)

スクリーンショット 2019-12-04 11.46.51.png

アクセスするユーザーを作成

  • サービスからIAMを選択

  • まずはポリシーを作成する

  • ポリシーを選択し、「ポリシーの作成」を押下
    スクリーンショット 2019-12-02 17.50.52のコピー2.png

  • JSONを選択、下記の内容をポリシーに追加し「ポリシーの確認」を押下

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "gamelift:CreateGameSession",
                "gamelift:DescribeGameSessionDetails",
                "gamelift:CreatePlayerSession",
                "gamelift:SearchGameSessions"
            ],
            "Resource": "*"
        }
    ]
}
  • 名前 : 任意で
  • 「ポリシーを作成」を押下
    スクリーンショット 2019-12-02 18.13.48.png

  • GameLiftにアクセスするユーザーを作成していく

スクリーンショット 2019-12-02 17.50.52のコピー.png

  • ユーザー名 : 任意
  • アクセスの種類 : プログラムによるアクセスを選択
  • 次のステップへ進んでいく
    スクリーンショット 2019-12-04 12.16.30.png

  • 「既存のポリシーを直接アタッチ」を押下

  • 先ほどのポリシーを検索しチェック、アタッチする

スクリーンショット 2019-12-02 18.21.37.png

  • そのまま進み情報を確認し作成する
  • ユーザーを作成したらcsvをダウンロード スクリーンショット 2019-12-02 18.22.22.png

本日はここまで。次回につづく。

注意点

作成した

  • スクリプト
  • フリート
  • エイリアス

に関して使わないときは削除するようにしてください。
でないとお金がかかっちゃうので。

おわりに

明日は @e73ryo さんの
「MonKey - Productivity Commands」のカスタムコマンドでさらにUnity上の作業を効率化する」です!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

1からHololensプロジェクトをビルドするには

はじめに

Hololensアプリの作り方の記事は沢山ありますが、割と情報がバラバラになっているイメージがあるので、導入からビルドするまでの工程を全て1つの記事にまとめました。
MixedRealityToolkitのバージョン(特に無印とv2)によって使い方が変わるので、気をつけてください。

準備〜コンテンツを作る

  1. Unityのダウンロード。Unity Hubを使うと複数のバージョンのUnityを同時に扱うことができるので便利。
  2. Unityをインストールするときに、Universal Windows Platform(UWP)の名前がついたBuild TargetとVisual Studioをダウンロード。
  3. Visual Studioでは、「C++によるデスクトップ開発」「ユニバーサルWindowsプラットフォーム開発」「Unityによるゲーム開発」のワークロードをダウンロード。
  4. Hololens Toolkitの任意のバージョンをダウンロード。個人的にHoloToolkit 2017.4.3.0 - Refreshが安定しており使いやすいと感じるが、MixedRealityToolkit v2も正式版がリリースされたので、これから始める人はそちらの方がいいかもしれない(少なくとも2020年のベータ版Unityと2017年版のHoloToolkitは相性が悪いのでやめた方がいい)。ダウンロードできるアセットはAssetsの中にあり、無印やFoundationが最低限開発に必要なアセットなのでこれをダウンロードする。
  5. Unityを起動してプロジェクトを作成し、File>Build SettingsからBuild TargetをUWPに変える(後からでもできるが、開発後に変えると時間がかかる)。
  6. UnityのメニューバーからAssets>Import Package>Custom Packageで落としてきたMixedRealityToolkitのUnityパッケージを選択して、インポートする。
  7. Hololens Toolkitのドキュメントに従って、プロジェクトとシーンの設定を行う(バージョンによって行う設定方法が異なり、ほぼワンクリックのはずなので説明は省略)。これを行うことでHololensを使えようになる。
  8. Assets>Import Package>Custom Packageでexampleシーンをダウンロードしたり、コンテンツを作る。作り方は通常の3Dゲームと同じように作ればいいが、カメラは原点から動かさないことに注意し、Hololensの画角が小さいことにも気をつける。

ビルドする方法

  1. File>Build Settings>Player Settings>Publishing SettingsのCapabilitiesで、なにか通信やマイクなど機能を使う場合はそれにチェックする必要がある。同じくPlayer Settings>XR SettingsでVirtual Reality Supportedにチェックが入っているかの確認も必要になる。また、Player Settings>Other SettingsのScriptipting Runtime Versionは”.Net 4.x Equivalent”、Api Compatibility Levelは”.Net 4.x”を確認し、必要に応じてScripting Backendを変えることができる。
  2. File>Build SettingsからBuildする。そのとき、”Add Open Scenes”で現在開いているシーンをビルド対象に入れ、Target Deviceは”Hololens”、Architectureはx86”を選ぶ。
  3. 生成されたフォルダの中にある.sln(ソリューションファイル)をダブルクリックして、Visual Studioを起動する。
  4. Visual StudioでBuildの下のドロップダウンメニューから”Release”, “x86”, “Device”に設定し、Hololensを繋いだ状態でDeviceのボタンを押すとビルドを行うことができる。
  5. 初めてのビルドではHololensとのペアリングを求められるが、HololensでSetting>Update&Security>For Developpersの中に”Pair”というボタンをクリックすると認証コードが出現し、それをPC側に入力するとペアリングを行うことができる。
  6. ビルド中にエラーが生じた場合の多くの原因は以前のアプリを消してないことにより起こるが、再ビルドすれば解決する。アプリを再インストールする時は事前に昔のアプリをアンインストールしておくとスムーズにビルドが実行できる。これで解決しないエラーはどこかで問題が起こっている。

Deviceが表示されないとき

Scripting BackendをIL2CPPにしたとき、ビルドする方法の4番の工程で"Device"が表示されないことがあるが、以下の記事の通りに実行すれば解決します。
Unity Hololens(UWP)をIL2CPPでビルドするときにDeviceが出てこないときにすること

最後に

もし間違っている箇所や分かりづらい箇所があればコメントください。最後まで読んで頂きありがとうございました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Unity]コンポーネントとは?

コンポーネント(Components) とは

オブジェクトの機能を定義する部品のこと。

インスペクタービューに表示される。
例. トランスフォーム, レンダラー, コライダー

// componentの訳は、部品, 構成要素。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unityプログラマは個人開発だとプログラマ領域以外のアセット買ったほうがいいと思った話+おすすめアセット

はじめに

今, 個人開発をしています。
帰っては家で開発をして、休日は予定がなければまったり開発をしています。

結果いくつかの有料アセットを買うことになり…
あんまり使わなくてもいいかなーというアセットと
あ、これめっちゃ使う!!というアセットが出てきました。

本当は個人開発をしてリリースしたよ!!みたいな記事を出したかったのですが, 多分アドベントカレンダーぶっちしそうなのであきらめて今回は「Unityプログラマ的に」使えたアセット, 多分なくてよかったアセットを紹介してみます。

使えるアセットと使えないアセットの違い

タイトルにある通りでプログラマはプログラムのことは自己解決できる!!と気が付きました。
逆に絵も描けないし素敵なデザインは無理!!ということも実感しました。

つまりプログラマとして使えるアセットというのは

  1. 素敵なデザインとかアニメーションとかのアセット
  2. UIとかを簡単に作れるようになるアセット
  3. かわいいキャラクターとか素敵な3Dモデルのアセット

このあたりです。
少なくとも僕にはこのあたりのスキルがないので数年かけて手に入れるか買うしかないのです。買ったら数千円です。買ったら早い!!
僕は #わかばちゃんと学ぶ シリーズの作者さんではないのです!!正直めちゃくちゃうらやましい!!

逆になくてもいいかなというのは

  1. プログラムの補助ツール
  2. ノードエディタとかノードスクリプトとか

こういうアセットはなくても自分で何とかできる!!はずです。

というわけでいくつか実際に使ってみてよかったプログラマではどうにもならないタイプのアセット紹介です。

プログラマなら買って損をしないアセット

DoozyUI

UIの特に押したときのアニメーション等がプリセットで入っていてなんかすごいかっこいい!!ってなりました。

アイコン画像サンプルとかも少し入っているのでそれもとても助かりました。
なにせかっこいいUIはモチベーション上がるからね!!

少し高いけどちょくちょくセールになるので狙うといいですよ!

こんなアニメーションたくさんプリセットで用意されててるのいいですよね。

animation.gif

Essential UI Pack 等アイコン素材系

UIのアイコンとかのプリセットです。
安いのでひとまずこれ選びましたがお財布や作りたいUIと相談していいやつを買うといいと思います

  • Ultimate Clean GUI Pack (高級品だけどかっこいいUIいっぱい入ってそう)
  • Clean Flat Icons (白1色のシンプルなアイコンImageのColorプロパティで色は変えられます)

Animated 2D Monster Pack等 かっこいい/かわいいキャラクターとかの素材

お絵描きできないのでお絵描きできる人から買いましょう

こちらももちろん用途に合わせて選択してください。
少し探した中だと

  • Customize Dolls_N.0
  • 2000 Fantasy Icons

等が都合よさそうですし。
昔懐かし?キャラクターなんとか機とかでもありかも?

SRDebugger等ユーティリティ系

これも自分で書けばいいといえばいいんですけど…正直個人開発でメインのもの以外書きたくねーよ!!って感じなのでこういうアセットはあっていいと思います。

テストメニューのUIとか考えたくないですしね!!

なくてもいいかなって思ったアセット

逆に自分で何とかできるタイプはいらないなって思いました。

PlayMaker

あってもいいけど多分自分でコード書いたりすれば十分かなと思いました。

課金系アセット

Unity IAP出たからもういらないよ
ちなみにUnity IAPはメチャクチャ簡単だよ

結論

個人開発においては, 自分でできない部分を補完する目的でAssetを買い漁るのがいいかなと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RiderのEditorConfig設定・有効化方法

RiderでEditorConfigを有効化するための手順備忘録

手順

  • File > Settings

image.png

  • Editor > Inspection Settings

image.png

  • General > Enable code analysis > Read settings from editorconfig, project settings and rule sets を有効化する。
  • 既に、Read settings from editorconfig, project settings and rule sets へ、チェックが入っていればそのままで大丈夫です。

image.png

  • 設定を保存

  • 完了

  • プロジェクトへ .editorconfig が追加されていれば、適用されるはずです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity2D】Flappy Bird を作るチュートリアル (3/3)

今回やること

  1. プロジェクトの作成
  2. プレイヤーの作成
  3. ブロック(障害物)の作成
  4. ブロック管理の作成
  5. スコアの実装 ←③
  6. ゲームオーバーの実装 ←③
  7. リトライの実装 ←③

最後にゲーム的な要素である「スコア」の実装と、ゲームオーバー表示、リトライを実装します。

ページのリンク

スコアの実装

ゲーム管理オブジェクトの作成

スコアなどゲーム全体を管理するオブジェクトを作成します。
最初にダウンロードした素材フォルダから「nasu.png」を Projectビューにドラッグ&ドロップします。
スクリーンショット_2019_11_27_21_42.png

続けて、作成した「nasu」スプライトを、Hierarchyビューにドラッグ&ドロップします。
スクリーンショット_2019_11_27_21_45.png

「nasu」ゲームオブジェクトが作られるので、名前を「GameMgr」に変更します。
038.gif
nasuオブジェクトを選択した状態で、Windows環境であれば「F2」、MacOSX環境であれば「Enter」で変更できます。

GameMgrオブジェクトは画面中央にあると邪魔なので、Sceneビューでドラッグ&ドロップして画面端に移動させておきましょう。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png

スクリプトでスコア表示をする

スクリプトでスコア表示の実装をします。
まずはスクリプトコンポーネントを追加します。GameMgrオブジェクトを選択し、Inspectorビューから、Add Component > New Script を選び、スクリプト名は「GameMgr」とします。
039.gif

スクリプトが追加できたら、GameMgr.cs を開いて以下のように記述します。

GameMgr.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameMgr : MonoBehaviour {
  // ①スコア
  int _score = 0;

  void Start() {
  }

  void Update() {
  }

  private void FixedUpdate() {
    // ②スコア上昇
    _score += 1;
  }

  private void OnGUI() {
    // ③スコアを描画
    _DrawScore();
  }

  // ④スコアの描画
  void _DrawScore() {
    // 文字を大きくする
    GUI.skin.label.fontSize = 32;
    // 左揃え
    GUI.skin.label.alignment = TextAnchor.MiddleLeft;
    Rect position = new Rect(8, 8, 400, 100);
    GUI.Label(position, string.Format("score:{0}", _score));
  }
}

スコア用の変数を追加して、時間経過でスコアを上昇させ、その値を表示します。

①ではスコア用の変数を追加しています。
cs:GameMgr.cs
// ①スコア
int _score = 0;

②でスコア加算用に FixedUpdate() を追加してその中でスコアを加算しています。

GameMgr.cs
  private void FixedUpdate() {
    // ②スコア上昇
    _score += 1;
  }

FixedUpdate() を使用するのは、決まった間隔で呼び出しが行われるためとなります。

③で OnGUI() を定義しています。スコアなどのGUIの描画はこの関数で行います。

GameMgr.cs
  private void OnGUI() {
    // ③スコアを描画
    _DrawScore();
  }

本来であれば uGUI を使うべきですが、テスト・実験用に作る場合、OnGUI() を使った方が素早く作れるので、今回は OnGUI() を使います。

④が実際の描画処理です。

GameMgr.cs
  // ④スコアの描画
  void _DrawScore() {
    // 文字を大きくする
    GUI.skin.label.fontSize = 32;
    // 左揃え
    GUI.skin.label.alignment = TextAnchor.MiddleLeft;
    Rect position = new Rect(8, 8, 400, 100);
    GUI.Label(position, string.Format("score:{0}", _score));
  }

初期状態だとフォントが小さいので GUI.skin.label.fontSize = 32 で大きくします。
GUI.Label() で文字を描画するのですが、第一引数には描画領域として Rect で左上の位置と幅・高さを指定します。
数値を文字として描画する場合、string.Format() を使うと自由な書式で描画できます。

では実行して動作を確認します。
040.gif
時間経過によりスコアが上昇していくのがわかります。

ゲームオーバーの実装

プレイヤーがブロックに当たったらゲームオーバーとします。
そしてゲームオーバーになったらスコアを増加しないようにします。

処理の流れとしては以下の通りです。
名称未設定.png

①プレイヤーとブロックの衝突、②プレイヤー消滅、この2つはすでに実装しているので、PlayerからGameMgrにゲームオーバーになったことを通知するようにします(②から③の部分)。

GameMgrスクリプトを以下のように修正します。(修正部分は7箇所)

GameMgr.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameMgr : MonoBehaviour {
  // ①状態定数
  enum State {
    Main, // メインゲーム
    GameOver, // ゲームオーバー
  }

  // スコア
  int _score = 0;
  // ②状態
  State _state = State.Main;

  // ③ゲームオーバーの開始
  public void StartGameOver() {
    _state = State.GameOver;
  }

  void Start() {
  }

  void Update() {
  }

  private void FixedUpdate() {
    if(_state == State.Main) {
      // ④メインゲーム中のみスコア上昇
      _score += 1;
    }
  }

  private void OnGUI() {
    // スコアを描画
    _DrawScore();

    // ⑤画面の中心座標を計算する
    float CenterX = Screen.width / 2;
    float CenterY = Screen.height / 2;
    if(_state == State.GameOver) {
      // ⑥ゲームオーバーの描画
      _DrawGameOver(CenterX, CenterY);
    }
  }

  // ⑦ゲームオーバーの描画
  void _DrawGameOver(float CenterX, float CenterY) {
    // 中央揃え
    GUI.skin.label.alignment = TextAnchor.MiddleCenter;
    float w = 400;
    float h = 100;
    Rect position = new Rect(CenterX - w / 2, CenterY - h / 2, w, h);
    GUI.Label(position, "GAME OVER");
  }

  // スコアの描画
  void _DrawScore() {
    // 文字を大きくする
    GUI.skin.label.fontSize = 32;
    // 左揃え
    GUI.skin.label.alignment = TextAnchor.MiddleLeft;
    Rect position = new Rect(8, 8, 400, 100);
    GUI.Label(position, string.Format("score:{0}", _score));
  }
}

①で「メインゲーム中」「ゲームオーバー」を判定するための enum定数を定義しています。

GameMgr.cs
  // ①状態定数
  enum State {
    Main, // メインゲーム
    GameOver, // ゲームオーバー
  }

②では、状態を格納する変数を定義しています。

GameMgr.cs
  // ②状態
  State _state = State.Main;

③では外部からゲームオーバーに切り替えるための関数 StartGameOver を定義しています。

GameMgr.cs
  // ③ゲームオーバーの開始
  public void StartGameOver() {
    _state = State.GameOver;
  }

④では FixedUpdate 関数でのスコア上昇の判定を修正しました。状態変数 _state がメインゲーム中だったときだけスコア上昇するようにしています。

GameMgr.cs
  private void FixedUpdate() {
    if(_state == State.Main) {
      // ④メインゲーム中のみスコア上昇
      _score += 1;
    }
  }

⑤・⑥は GUI表示である OnGUI() を修正しています。

GameMgr.cs
  private void OnGUI() {
    // スコアを描画
    _DrawScore();

    // ⑤画面の中心座標を計算する
    float CenterX = Screen.width / 2;
    float CenterY = Screen.height / 2;
    if(_state == State.GameOver) {
      // ⑥ゲームオーバーの描画
      _DrawGameOver(CenterX, CenterY);
    }
  }

Screen.width / Screen.height で幅と高さを取得し、その半分の値を中心として文字の表示をする関数 _DrawGameOver を呼び出しています。

実際のゲームオーバーの描画を行なっているのが⑦の _DrawGameOver() です。

GameMgr.cs
  // ⑦ゲームオーバーの描画
  void _DrawGameOver(float CenterX, float CenterY) {
    // 中央揃え
    GUI.skin.label.alignment = TextAnchor.MiddleCenter;
    float w = 400;
    float h = 100;
    Rect position = new Rect(CenterX - w / 2, CenterY - h / 2, w, h);
    GUI.Label(position, "GAME OVER");
  }

少し計算がややこしいので図で説明します。
名称未設定.png
CenterX / CenterY は画面の中心です。文字を描画するには文字の左上座標と描画矩形(Rect)の幅(w)と高さ(h)が必要となります。左上の座標を求めるには、幅を2で割った値、高さを2で割った値を中央の座標から引き算します。

長くなりましたが、これで GameMgrスクリプトの準備はできました。
続けて、Playerスクリプトを修正します。

Player.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// ■プレイヤー
public class Player : MonoBehaviour {
  // スプライト番号定義
  const int SPR_FALL = 0; // 落下中
  const int SPR_JUMP = 1; // ジャンプ中

  [SerializeField]
  float JUMP_VELOCITY = 1000; // ジャンプ力の定義
  public Sprite[] SPR_LIST; // アニメーション用スプライトの保持
  public GameObject gameMgr; // ①ゲーム管理

  Rigidbody2D _rigidbody; // 物理挙動コンポーネント保持用
  SpriteRenderer _renderer; // スプライト描画
  GameMgr _gameMgr; // ②ゲーム管理スクリプト

  // 開始処理
  void Start() {
    // 物理挙動コンポーネントを取得
    _rigidbody = GetComponent<Rigidbody2D>();
    // スプライト描画コンポーネントを取得
    _renderer = GetComponent<SpriteRenderer>();
    // ③ゲーム管理スクリプトを取得
    _gameMgr = gameMgr.GetComponent<GameMgr>();
  }

  
  
  

  // 衝突判定
  private void OnTriggerEnter2D(Collider2D collision) {
    // 衝突したので消滅
    Destroy(gameObject);
    // ④ゲームオーバーを通知
    _gameMgr.StartGameOver();
  }
}

①でGameMgrオブジェクトを格納する変数を定義します。
②でGameMgrスクリプトを格納する変数を定義します。
③は Start() でGameMgrオブジェクトからスクリプトを取り出して、_gameMgr に格納しています。
④でブロックにぶつかって消滅する時に _gameMgr.StartGameOver() を呼び出すことでゲームオーバーを通知します。

では、Unityエディタに戻って、Player オブジェクトを選択し、Inspectorビューの Spriteの Game Mgr に GameMgrオブジェクトを指定します。
041.gif

では実行して、ブロックに衝突すると画面中央に「GAME OVER」表示がされ、スコアのカウントアップが停止しているのを確認します。
042.gif

リトライの実装

最後にリトライ処理を実装して終わりとします。
ゲームオーバー時に「Retry」ボタンを押すと、ゲームに再挑戦できるようにします。

シーンの登録

リトライ処理は、シーンを作り直す方法で実装します。
Unityエディタのメニューから File > Build Settings... を選びます。
File_と_Menubar.png

するとビルド設定画面が表示されるので、「Add Open Scenes」をクリックして、「SampleScene」をビルド対象に設定します。
Build_Settings_と_Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
追加したら、×ボタンを押して設定画面を閉じます。

なお、「SampleScene」とは今までに編集をしていたメインゲームとなるシーンです。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png

ファイルは Projectビューの Assets > Scenes に存在していて、通常はわかりやすい名称(例えば「MainScene」など)にリネームしておくべきですが、今回は "SampleScene" のままにしておきます。

リトライボタンの実装

リトライボタンは GameMgrスクリプトで実装します。
GameMgrスクリプトを開いて以下のよう修正します。

GameMgr.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// ①シーンの読み直しに必要
using UnityEngine.SceneManagement;

public class GameMgr : MonoBehaviour {
  // 状態定数
  enum State {
    Main, // メインゲーム
    GameOver, // ゲームオーバー
  }

  // スコア
  int _score = 0;
  // 状態
  State _state = State.Main;

  // ゲームオーバーの開始
  public void StartGameOver() {
    _state = State.GameOver;
  }

  void Start() {
  }

  void Update() {
  }

  private void FixedUpdate() {
    if(_state == State.Main) {
      // メインゲーム中のみスコア上昇
      _score += 1;
    }
  }

  private void OnGUI() {
    // スコアを描画
    _DrawScore();

    // 画面の中心座標を計算する
    float CenterX = Screen.width / 2;
    float CenterY = Screen.height / 2;
    if(_state == State.GameOver) {
      // ゲームオーバーの描画
      _DrawGameOver(CenterX, CenterY);
      // ②リトライボタンの描画
      if(_DrawRetryButton(CenterX, CenterY)) {
        // ③クリックしたらやり直しする
        SceneManager.LoadScene("SampleScene");
      }
    }
  }

  // ④リトライボタンの描画
  bool _DrawRetryButton(float CenterX, float CenterY) {
    float ofsY = 40;
    float w = 100;
    float h = 64;
    Rect rect = new Rect(CenterX - w / 2, CenterY + ofsY, w, h);
    if (GUI.Button(rect, "RETRY")) {
      // ボタンを押した
      return true;
    }
    return false;
  }

  // ゲームオーバーの描画
  void _DrawGameOver(float CenterX, float CenterY) {
    // 中央揃え
    GUI.skin.label.alignment = TextAnchor.MiddleCenter;
    float w = 400;
    float h = 100;
    Rect position = new Rect(CenterX - w / 2, CenterY - h / 2, w, h);
    GUI.Label(position, "GAME OVER");
  }

  // スコアの描画
  void _DrawScore() {
    // 文字を大きくする
    GUI.skin.label.fontSize = 32;
    // 左揃え
    GUI.skin.label.alignment = TextAnchor.MiddleLeft;
    Rect position = new Rect(8, 8, 400, 100);
    GUI.Label(position, string.Format("score:{0}", _score));
  }
}

修正箇所は、
1. using の追加
2. OnGUI() の修正
3. _DrawRetryButton() の追加

の3つです。

まずファイルの先頭で

GameMgr.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// ①シーンの読み直しに必要
using UnityEngine.SceneManagement;

というように using UnityEngine.SceneManagement; を追加し、シーン管理である SceneManager を使えるようにします。

次に、OnGUI() の修正です。

GameMgr.cs
  private void OnGUI() {
    // スコアを描画
    _DrawScore();

    // 画面の中心座標を計算する
    float CenterX = Screen.width / 2;
    float CenterY = Screen.height / 2;
    if(_state == State.GameOver) {
      // ゲームオーバーの描画
      _DrawGameOver(CenterX, CenterY);
      // ②リトライボタンの描画
      if(_DrawRetryButton(CenterX, CenterY)) {
        // ③クリックしたらやり直しする
        SceneManager.LoadScene("SampleScene");
      }
    }
  }

②で _DrawRetryButton() を呼び出し、戻り値が true なら③のやり直し処理を行います。 SceneManager.LoadScene() には「シーン名」を文字列で渡します。

④でリトライボタンの描画(とクリック)を行う _DrawRetryButton() を実装しています。

SceneMgr.cs
  // ④リトライボタンの描画
  bool _DrawRetryButton(float CenterX, float CenterY) {
    float ofsY = 40;
    float w = 100;
    float h = 64;
    Rect rect = new Rect(CenterX - w / 2, CenterY + ofsY, w, h);
    if (GUI.Button(rect, "RETRY")) {
      // ボタンを押した
      return true;
    }
    return false;
  }

ofsY とは 「Offset Y座標」の略で、Y座標を少しずらす値としてよく使われる略語です。
GUI.Button() はボタンの描画とクリックしたかどうかを判定する関数です。true を返した場合はボタンをクリックしたと判定されます。

では実行して動作を確認します。
043.gif

ゲームオーバー時に「RETRY」ボタンを押すことでゲームを遊び直すことができるようになりました。

BlockMgr / GameMgr のスプライト表示を消す

BlockMgr(xbox) / GameMgr(nasu) がスプライト表示されていますが、俺は不要なので消しておきます。

Inspectorビューから Sprite Renderer のチェックを外すことで表示されなくなります。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
再び表示したい場合は、ここにチェックを入れ直すと表示されるようになります。

最後に

まだゲームとして物足りない部分はありますが、これでひとまずゲームは完成です。

  • ブロックの出現パターンをランダムでなく決まった形にする
  • 接触すると得点になるアイテム
  • 一定距離進むとゲームクリア

など色々な要素を加えて、より面白いゲームに作り替えていくと、ゲームプログラムの勉強、ゲームデザインの練習になって良いかもしれません。

なお、今回作成したプロジェクトは以下の場所にアップロードしています。
http://syun777.sakura.ne.jp/tmp/Unity/FlappyBird/TestFlappyBird.zip

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity2D】Flappy Bird を作るチュートリアル (2/3)

今回やること

  1. プロジェクトの作成
  2. プレイヤーの作成
  3. ブロック(障害物)の作成 ←②
  4. ブロック管理の作成 ←②
  5. スコアの実装
  6. ゲームオーバーの実装
  7. リトライの実装

プレイヤーの作成まで終わったので、②ブロック(障害物)に関連する実装を進めていきます。

ページのリンク

ブロックの作成

Flappy Bird はジャンプで障害物をタイミング良く回避するゲームです。今回は障害物として、ブロックが右から流れてくるようにします。

ブロック画像の追加

ダウンロードした素材フォルダの「5box.png」を Projectビューの Assetsフォルダにドラッグ&ドロップします。
スクリーンショット_2019_11_22_22_11.png
以下のように "5box" のスプライトが作られればOKです。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
作成した 5box スプライトを Hierarchyビューにドラッグ&ドロップします。
スクリーンショット_2019_11_22_22_15.png
作成した「5box」ゲームオブジェクトを Inspectorビューから「Block」にリネームします。
021.gif

動きをつける

まずは Block オブジェクトを右端に移動させます。
Hierarchyビューで Blockオブジェクトをダブルクリックすると、Sceneビューで中心に表示されます。そうしたら Sceneビューで Blockオブジェクトを右の方へドラッグすると右側に移動できます。
022.gif

ゲームオブジェクトに動きをつけるには Rigidbody が必要となります。Blockゲームオブジェクトを選択して、Inspectorビューの「Add Component」から Rigidbody 2Dを追加します。
UnityEditor_AdvancedDropdown_AddComponentWindow_と_Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
Blockオブジェクトは重力を無視したいので、 Gravity Scale0 にして重力を無効にします。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
念のため実行して Blockオブジェクトが動かないことを確認しておきます。

続けて、Scriptコンポーネントを追加します。
023.gif

スクリプトの名前は「Block」とし、以下のように記述します。

Block.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// ブロック
public class Block : MonoBehaviour {
  Rigidbody2D _rigidbody;
  float _speed = -100; // 移動速度

  void Start() {
    // 物理挙動コンポーネントを取得
    _rigidbody = GetComponent<Rigidbody2D>();
    // 力を加える
    _rigidbody.AddForce(new Vector2(_speed, 0));  
  }

  void Update() {
  }
}

メンバ変数に _rigidbody / _speed を追加し、Start() で Rigidbody2D コンポーネントを取得して、左向きに力を加えています。

では実行してブロックが動くことを確認します。

024.gif

プレイヤーとの衝突判定を行う

次にプレイヤーとの衝突判定を実装します。プレイヤーがブロックにぶつかったら衝突するようにします。

まずは Blockオブジェクトを選び、Add Component > Circle Collider 2D を追加します。
025.gif
Blockオブジェクトをダブルクリックすると、Sceneビューで周りに丸い線(当たり判定)があるのが確認できます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
そして Inspectorビュー から、Circle Collider 2Dコンポーネントの Is Trigger にチェックを入れておきます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
これにより衝突した際に物理挙動(跳ね返ったり、転がったりする)を行わずに、「当たったかどうか」だけを判定できるようになります。
例えば、プレイヤーがブロックにぶつかったときに、ブロックが跳ね返ってふっとんだりしなくなります。
例えば Is Trigger にチェックを入れないと以下のような挙動となります。
026.gif
これはこれで面白い挙動ですが、FlappyBirdを作るには不適切なので、Is Triggerで衝突を扱います。

Playerオブジェクトにも「Circle Collider 2D」を追加して、Is Triggerにチェックを入れておき、Radius の値を 0.4 に減らしておきます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
Radius とは円の当たり判定の半径で、この値が小さくなると当たり判定が小さくなります。プレイヤーの当たり判定は小さめにすることでゲームを遊びやすくするためです。

では衝突したら Playerオブジェクトが消えるようにします。
Playerスクリプトを開いて、OnTriggerEnter2D 関数を追加します。

Player.cs
  // 衝突判定
  private void OnTriggerEnter2D(Collider2D collision) {
    // 衝突したので消滅
    Destroy(gameObject);
  }

これは Is Trigger が有効なコリジョンに衝突したときに発生する処理となります。DestroygameObject を渡すと削除処理が呼び出されます。

では実行してプレイヤーがブロックにぶつかると消滅することを確認します。
027.gif

Blockオブジェクトをプレハブ化する

ブロックが1つだけでは簡単すぎるので、たくさん発生するようにします。たくさん発生させるには、Blockオブジェクトをプレハブ化すると、複製しやすくなります。

プレハブを使う理由を簡単に説明すると、ここまでのプレイヤーやブロックは実体化という処理が行われており、ゲーム画面(シーン)で利用可能な状態になっています。
名称未設定.png
それに対して、プレハブ化を行うと、ゲーム外にオブジェクトが配置されます。
名称未設定.png
そして「実体化」を後から行うことで、ゲーム内にたくさん配置することが可能になります。これにより間違って複製したいオブジェクトをゲーム中に消してしまう心配はなくなります。また、Unityがプレハブを使って複製する仕組みを提供しているのでそれに従ったほうが楽、ということもあります。

では、Blockオブジェクトをプレハブ化します。
プレハブ化は簡単で、HierarchyビューにあるBlockオブジェクトを、Projectビューにドラッグ&ドロップするだけです。
028.gif
プレハブを作ったら、Hierarchyビューにある Blockオブジェクトは不要なので消しておきます。

ブロック生成管理オブジェクトを作成する

プレハブ化したブロックの生成を行うオブジェクトを作成します。
素材画像の「xbox.png」を Projectビューにドラッグ&ドロップします。
スクリーンショット_2019_11_25_10_10.png
追加された xbox スプライトを Hierarchyビューにドラッグ&ドロップします。
スクリーンショット_2019_11_25_10_12.png
そして xboxオブジェクトを「BlockMgr」にリネームします。
Windows環境であればF2、MacOSX環境であればEnterでリネームできます。Inspectorビューからリネームしてもよいです。
030.gif
ちなみに「Mgr」とは「Manager」の省略語です。人によっては「Mng」と省略するケースもあります。省略語が好きでない場合は「BlockManager」としてもよいです。

ブロック生成スクリプトの作成

BlockMgrオブジェクトを選択したまま、Inspectorビューから Add Component > New Script > Name に "BlockMgr" と入力 > Create and Add を選び BlockMgrスクリプトを追加します。
031.gif

BlockMgrスクリプトは以下のように記述します。

BlockMgr.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BlockMgr : MonoBehaviour {
  // 生成するBlockオブジェクト
  public GameObject block;

  // 0になったらBlockオブジェクトを生成
  float _timer = 0;

  void Start() {
  }

  void Update() {
    // ①経過時間を差し引く
    _timer -= Time.deltaTime;
    if(_timer < 0) {
      // ②0になったのでBlock生成
      // ③BlockMgrの場所から生成
      Vector3 position = transform.position;
      // ④プレハブをもとにBlock生成
      GameObject obj = Instantiate(block, position, Quaternion.identity);
      // ⑤1秒後にまた生成する
      _timer += 1;
    }
  }
}

スクリプトの説明です。
メンバ変数には block を用意し、Blockオブジェクトのプレハブをここに入れておきます。また _timer は生成を繰り返すためのタイマーです。この値が 0 以下になったとき Blockオブジェクトを生成します。

生成処理は Update 関数に記述しています。

BlockMgr.cs
    // ①経過時間を差し引く
    _timer -= Time.deltaTime;

Time.deltaTime は前回の Update からの経過時間です。この値で _timer から引き算をすることで一定時間ごとに何らかの処理を行う、という判定が可能となります。

BlockMgr.cs
    if(_timer < 0) {
      // ②0になったのでBlock生成

_timer は時間が経過するごとに減少する値となるため、これが 0 になったら何らかの処理を行う、という判定をしています。

BlockMgr.cs
      // ③BlockMgrの場所から生成
      Vector3 position = transform.position;

③ではまず BlockMgrオブジェクトの位置を取得しています。オブジェクトを生成するには座標が必要となるため、ひとまずBlockMgrの位置を基準に生成するようにします。

BlockMgr.cs
      // ④プレハブをもとにBlock生成
      GameObject obj = Instantiate(block, position, Quaternion.identity);

④が実際にBlockオブジェクトを生成している処理となります。 Instantiate という関数にプレハブ(ここでは block)を渡すことで、Blockオブジェクトを生成できます。Instantiate() の二番目の引数 Quaternion.identity は回転値です。今回は回転を加えないので identity (初期値=ゼロ)の値としています。

BlockMgr.cs
      // ⑤1秒後にまた生成する
      _timer += 1;

⑤ではタイマーを再設定しています。1 を足し込むことで、「1秒後」にもう一度生成を行います。

Blockプレハブの設定

このまま実行すると、エラーとなりブロックの生成は行われません。block に Blockプレハブが設定されていないためです。
そのためプレハブの設定を行います。
設定方法は簡単で、Unityエディタに戻って、
1. Hierarchyビューから「BlockMgr」オブジェクトを選択
2. Projectビューから「Blockプレハブ」を Inspectorビューの BlockMgr(Script) の Block にドラッグ&ドロップ

これで設定ができます。
032.gif

では実行して、Blockオブジェクトが次々と生成されることを確認します。
033.gif

Blockの破棄処理と移動速度の設定

一見うまくいってそうですが、実は大きな問題があります。実行中の Hierarchyビューを見てみます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
すると、このように大量の Block(Clone) というオブジェクトが生成されています。今回のようなシンプルなゲームではあまり問題にはならないのですが、これが 何千何万と生成されると、実行時のメモリが不足してゲームがクラッシュする可能性があります。
そのため、不要になった Blockオブジェクトを削除するようにします。

Blockスクリプトを開いて、画面の左端に出たら消します。

Block.cs
  void Update() {
    Vector2 position = transform.position;
    if(position.x < GetLeft()) {
      Destroy(gameObject); // 画面外に出たので消す.
    }
  }

  float GetLeft() {
    // 画面の左下のワールド座標を取得する
    Vector2 min = Camera.main.ViewportToWorldPoint(Vector2.zero);
    return min.x;
  }

画面の左端を取得する GetLeft 関数を追加して、Update() で画面外に出たかどうかチェックし、画面外に出たら Destroy() で消滅させます。

では実行して、画面外に出た Blockオブジェクトが消えるのを確認します。

Blockオブジェクトの移動速度を変化させる

時間が経過するにつれて、Blockオブジェクトの移動が速くなるようにして、少しずつ難易度を上げるようにします。

まずは Blockスクリプトを開いて速度を設定する関数 SetSpeed() を追加します。

Block.cs
  // 速度を設定
  public void SetSpeed(float speed) {
    _speed = speed;
  }

Blockスクリプトであればどこに定義しても問題ありません。SetSpeed() は公開関数にするので、
public キーワードを忘れずにつけます。

続けて、BlockMgrスクリプトを以下のように修正します。

BlockMgr.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BlockMgr : MonoBehaviour {
  // 生成するBlockオブジェクト
  public GameObject block;

  // 0になったらBlockオブジェクトを生成
  float _timer = 0;
  // ①トータルの経過時間を保持
  float _totalTime = 0;

  void Start() {
  }

  void Update() {
    // 経過時間を差し引く
    _timer -= Time.deltaTime;
    // ②トータル時間を加算
    _totalTime += Time.deltaTime;

    if(_timer < 0) {
      // 0になったのでBlock生成
      // BlockMgrの場所から生成
      Vector3 position = transform.position;
      // プレハブをもとにBlock生成
      GameObject obj = Instantiate(block, position, Quaternion.identity);
      // ③Blockオブジェクトの「Block」スクリプトを取得する
      Block blockScript = obj.GetComponent<Block>();
      // ④速度を計算して設定
      // 基本速度100に、経過時間x10を加える
      float speed = 100 + (_totalTime * 10);
      blockScript.SetSpeed(-speed); // 左方向なのでマイナス
      // 1秒後にまた生成する
      _timer += 1;
    }
  }
}

メンバ変数に _totalTime を追加し、生成したBlockオブジェクトにトータル時間を加えた速度を設定するようにしています。

①は _totalTime の定義です。
②では _totalTime に経過時間の差分である Time.deltaTime を「加算」しています。これにより経過したトータルの時間が入ることになります。

③で生成したBlockオブジェクトから、Blockスクリプトコンポーネントを取得しています。
そして④で速度を計算し、SetSpeed() で速度を設定しています。
速度の計算式は経過時間を変数としたシンプルな一次式となっています。

$速度=100 + (10 * 経過時間[秒])$

経過時間 速度
0.0秒 100+(10*0) 100
1.0秒 100+(10*1) 110
2.0秒 100+(10*2) 120
... ... ...

では実行して動きを確認します。
034.gif

時間経過によりブロックの移動速度が少しずつですが上昇しています。

ブロックを生成する高さをランダムにする

ブロックの生成位置が常に中央では単調なゲームとなってしまうので、ばらつきを出すようにします。
その前に、ブロック生成位置を右にずらしたいので、BlockMgr オブジェクトを右にずらします。

Hierarchyビューで BlockMgr オブジェクトをダブルクリックして、Sceneビューの中央に表示し、それをドラッグ&ドロップすることで動かすことができます。
035.gif

BlockMgr スクリプトを開いて、Update() を修正します。

BlockMgr.cs
  void Update() {
    // 経過時間を差し引く
    _timer -= Time.deltaTime;
    // トータル時間を加算
    _totalTime += Time.deltaTime;

    if(_timer < 0) {
      // 0になったのでBlock生成
      // BlockMgrの場所から生成
      Vector3 position = transform.position;
      // ※上下(±3)のランダムな位置に出現させる
      position.y = Random.Range(-3, 3);
      // プレハブをもとにBlock生成
      GameObject obj = Instantiate(block, position, Quaternion.identity);
      // Blockオブジェクトの「Block」スクリプトを取得する
      Block blockScript = obj.GetComponent<Block>();
      // 速度を計算して設定
      // 基本速度100に、経過時間x10を加える
      float speed = 100 + (_totalTime * 10);
      blockScript.SetSpeed(-speed); // 左方向なのでマイナス
      // 1秒後にまた生成する
      _timer += 1;
    }
  }

修正箇所は※がついている一行だけです。

BlockMgr.cs
      // ※上下(±4)のランダムな位置に出現させる
      position.y = Random.Range(-4, 4);

Random.Range() 指定した数値の範囲でのランダムな値を返す便利な関数です。ここでは「-4」と「4」を指定しているので、-4〜4 の範囲のランダムな値を返します。

では実行して動作を確認します。
036.gif
ランダムな位置からブロックが出現するようになりました。

ブロックの生成に偏りを入れる

現状でもゲームとして成立していますが、発生タイミングが均一で単調さを感じてしまうので、少し偏りを入れます。
偏りのルールは「10のうち3つは連続で出現する」とします。

BlockMgrスクリプトを開いて以下のように修正します。

BlockMgr.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BlockMgr : MonoBehaviour {
  // 生成するBlockオブジェクト
  public GameObject block;

  // 0になったらBlockオブジェクトを生成
  float _timer = 0;
  // トータルの経過時間を保持
  float _totalTime = 0;
  // ①ブロック生成回数
  int _cnt = 0;

  void Start() {
  }

  void Update() {
    // 経過時間を差し引く
    _timer -= Time.deltaTime;
    // トータル時間を加算
    _totalTime += Time.deltaTime;

    if(_timer < 0) {
      // 0になったのでBlock生成
      // BlockMgrの場所から生成
      Vector3 position = transform.position;
      // ※上下(±3)のランダムな位置に出現させる
      position.y = Random.Range(-4, 4);
      // プレハブをもとにBlock生成
      GameObject obj = Instantiate(block, position, Quaternion.identity);
      // Blockオブジェクトの「Block」スクリプトを取得する
      Block blockScript = obj.GetComponent<Block>();
      // 速度を計算して設定
      // 基本速度100に、経過時間x10を加える
      float speed = 100 + (_totalTime * 10);
      blockScript.SetSpeed(-speed); // 左方向なのでマイナス

      // ②生成回数をカウントアップ
      _cnt++;
      if(_cnt%10 < 3) {
        // 0.1秒後にまた生成する
        _timer += 0.1f;
      } else {
        // 1秒後にまた生成する
        _timer += 1;
      }
    }
  }
}

変更箇所は2つです。
①でブロック生成回数を保持する変数 _cnt を定義しています。

BlockMgr.cs
  // ①ブロック生成回数
  int _cnt = 0;

Update() 内に記述した②で生成回数をカウントアップし、下一桁が「0〜2」の場合は「0.1秒後」に生成するようにしました。

BlockMgr.cs
      // ②生成回数をカウントアップ
      _cnt++;
      if(_cnt%10 < 3) {
        // 0.1秒後にまた生成する
        _timer += 0.1f;
      } else {
        // 1秒後にまた生成する
        _timer += 1;
      }

_cnt%10 は _cnt を 10で割った時の余りの値を求める記述です。これにより下一桁を求め、3より小さい(=0〜2)の場合は、「0.1秒後」にブロックを生成するようにしています。

実行すると、ブロックの生成に少し偏りがあることが確認できます。
037.gif

これでプレイヤーとブロックについての処理の実装は終わりです。
次では、スコアと、ゲームオーバー、リトライ処理を実装して完成とします。

次回

Flappy Bird を作るチュートリアル (3/3)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity2D】Flappy Bird を作るチュートリアル (1/3)

概要

この記事は、Unity初心者向けに、Flappy Bird (みたいなゲーム)を作りながら操作方法を学べるチュートリアルです。
042.gif
こんなゲームを作ります。

ページ作成の都合でこの記事は3ページに分かれていて、ここは最初のページとなります。

ページのリンク

やることリスト

今回のチュートリアルでは以下のことを実装していきます。

  1. プロジェクトの作成 ←①
  2. プレイヤーの作成 ←①
  3. ブロック(障害物)の作成
  4. ブロック管理の作成
  5. スコアの実装
  6. ゲームオーバーの実装
  7. リトライの実装

第一回(このページ)では、①の部分まで進めていきます。

プロジェクト作成と環境設定

Unity Hub を起動して、プロジェクトを新規作成します。
001.png

  1. テンプレートは「2D」を選択
  2. プロジェクト名は「TestFlappyBird」
  3. 作成ボタンをクリックする

※保存先は任意の場所。デスクトップなどにしておくとフォルダへのアクセスがしやすいです。

レイアウトの設定

今回の説明で使用するレイアウトは「2 by 3」とします。
002.png
003.gif
Projectビューのアイコンは最小化しておくと見やすくて良いです。

素材のダウンロード

今回のゲームで使用する素材は以下の場所にアップロードしています。
http://syun777.sakura.ne.jp/tmp/Unity/FlappyBird/images.zip

■フォルダ構成
 images
  +-- 5box.png: 障害物
  +-- nasu.png: ゲーム管理の仮画像
  +-- player.png: プレイヤー
  +-- xbox.png: 障害物管理の仮画像

プレイヤーの作成

まずはプレイヤーを作成します。

スプライトの設定

素材画像の player.pngを Projectビューの Assets フォルダにドラッグ&ドロップします。
スクリーンショット_2019_11_19_10_19.png
すると、Assetsフォルダ内に "player" というスプライトリソースが作られます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal__と_「【Unity2D】Flappy_Bird_を作るチュートリアル」を編集_-_Qiita.png
player を選択して、Inspectorビューから Sprite Mode を Multiple に変更して、 Sprite Editor ボタンをクリックします。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
この際にこのようなダイアログが表示されますが、これは「 Sprite Mode を変更したのに Apply(適用する)をしていませんがよろしいですか?」という確認メッセージです。特に問題ないので、Apply をクリックして適用します。

Sprite Editor が表示されるので、Slice のドロップダウンをクリックして、設定はそのままで Slice ボタンをクリックします。
UnityEditorInternal_SpriteEditorMenu_と_Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
Slice ボタンを押しても見た目に変化がないので、何も変わっていないように見えますが、下にある画像をクリックすると良い感じにスライスされているのが確認できます。
010.gif
Sprite Editor を閉じると、以下のようにメッセージが表示されますが、これも「 Apply ボタンを押していないけれど大丈夫?」という確認のメッセージなので、Apply を押して適用します。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
ちなみに Projectビューの Assets フォルダから player スプライトを選択してもスライスした情報を確認できます。
011.gif
player_0player_1 というスプライト名でスライスされたということがわかります。
なお、Sprite Mode の Multiple という設定は、このように1つの画像から複数のスプライトを作る、というものとなります。

プレイヤーオブジェクトを作成する

早速このプレイヤーをゲーム内に配置してみます。
スクリーンショット_2019_11_19_10_49.png
player_0 を Scene ビューにドラッグ&ドロップします。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
すると Hierarchy ビューに player_0 というゲームオブジェクトが作られます。この player_0 をクリックして選択し、Inspectorビューから
`Player' という名称に変更しておきます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png

コンポーネント 「Rigidbody 2D」 を追加する

Player にコンポーネント「Rigidbody 2D」を追加します。「Rigidbody 2D」とは、オブジェクトの物理的な挙動(移動や落下)を制御するためのものです。これをゲームオブジェクトに登録することで、プレイヤーが操作したり、落下したりすることができます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal__と_「【Unity2D】Flappy_Bird_を作るチュートリアル」を編集_-_Qiita.png
ちなみにゲームオブジェクトには初期状態で「Transform」というコンポーネントが登録されており、これは「座標」「回転値」「拡大縮小値」を保持しています。
画像が表示できているのは「Sprite Renderer」が登録されているためです。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
試しに「Sprite Renderer」の隣にあるチェックボックスをクリックするとスプライトの描画が無効になって何も表示されなくなります。
ゲームオブジェクトとコンポーネントの関係のイメージとしては以下の通りです。
名称未設定.png
ゲームオブジェクトが複数のコンポーネント(Transform / Sprite Renderer / Rigidbody 2D / Collider 2D)を持つことになります。

ゲームオブジェクトとコンポーネントの関係を説明したところで、物理挙動の機能をもつ「Rigidbody 2D」を登録します。
Player ゲームオブジェクトを選択した状態で、Inspectorビューから「Add Component」をクリックして、 Rigidbody 2D を選ぶと登録できます。
012.gif
登録できたら、Unityエディタの上中央にある「▶︎(再生ボタン)」をクリックして動作を確認します。
013.gif
実行すると Playerオブジェクトは重力で落下していきます。その動きを確認したら、もう一度「▶︎(再生ボタン)」をクリックして、再生を止めた状態にしておきます。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
(凹んだ状態を解除しておく)

スクリプトを追加して動きを作る

Playerに「スクリプト」というコンポーネントを追加して動きを作ります。
014.gif

  1. Playerオブジェクトを選択
  2. Inspectorビューから「Add Component」ボタンをクリック
  3. scriptで検索して「New Script」を選ぶ
  4. スクリプト名を「Player」にして「Create And Add」をクリック

Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
これにより「Player」というC#スクリプトがプロジェクトに追加されます。

スクリプトの記述

Player スクリプトをダブルクリックしてスクリプトを開き、以下のコードを入力します。

Player.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// ■プレイヤー
public class Player : MonoBehaviour {
  [SerializeField]
  float JUMP_VELOCITY = 1000; // ①ジャンプ力の定義

  Rigidbody2D _rigidbody; // ②物理挙動コンポーネント保持用

  // 開始処理
  void Start() {
    // ③物理挙動コンポーネントを取得
    _rigidbody = GetComponent<Rigidbody2D>();
  }

  // 更新
  void Update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
      // ④Spaceキーを押したのでジャンプ処理
      _rigidbody.velocity = Vector2.zero; // 落下速度を一度リセットする
      _rigidbody.AddForce(new Vector2(0, JUMP_VELOCITY)); // 上方向に力を加える
    }
  }
}

記述する場所には番号をつけています。
①はジャンプ力の定義です。ひとまず力の大きさは 1000 としました。[SerializeField] をつけることで、Unityエディタからも値の変更が可能となります。

②は物理挙動コンポーネントである Rigidbody 2D を保持する変数の定義です。コンポーネントを取得する関数は処理が重いので、あらかじめ保持するようにしておきます。

③は Rigidbody 2D を取得して _rigidbody 変数に格納しています。 Start() はゲームオブジェクトの最初の Update() が呼び出される前に呼び出される関数です。
詳細は UnityのAPIドキュメントに記載されています。
https://docs.unity3d.com/jp/460/ScriptReference/MonoBehaviour.Start.html

Start() はこのような「一番最初に一度だけ呼び出される」処理をするのに便利な関数となります。

Update() 内に記述した④でキー入力を受け取ります。 Input.GetKeyDown(KeyCode.Space) を使うと、キーボードのSpaceキーを押した瞬間かどうかを判定できます。そして Spaceキーを押したら、_rigidbody.AddForce() で上方向に力を加えます。力を加える前に _rigidbody.velocity に速度0 の値を代入することで、落下速度を無視したジャンプ力を加えています。
現実世界で考えると、落下速度の力を無視した上昇はできないのですが、操作感はこの方法が良くなります。ゲームでは時々、このような「ウソ物理挙動」がゲームをより面白くすることがあります。

スクリプトが入力できたら▶︎(再生ボタン)を押して実行してみましょう。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
015.gif
Space キーでプレイヤーが上昇しますが、力が大きすぎて画面外へ飛び出してしまいます。

ジャンプ力の調整

ここで先ほどスクリプトで指定した [SerializeField] が役に立ちます。

Player.cs
  [SerializeField]
  float JUMP_VELOCITY = 1000; // ジャンプ力の定義

の部分です。
スクリプトに指定した値を直接書き換えても良いのですが、 [SerializeField] を指定することで、Unityエディタから修正ができます。

Hierarchyビューから「Player」オブジェクトを選択して、
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
Inspectorビューの Player(Script) の JUMP_VELOCITY を 400 に変更します。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
そうしたら▶︎(再生ボタン)をクリックして実行してみます。
016.gif
良い感じのジャンプになりました!

重力の調整

個人的に少しふんわりした挙動が気になったので、もう少し強い重力をかけるようにします。
PlayerオブジェクトのInspectorから、Rigidbody 2D > Gravity Scale の値を 1 から 2 に変更します。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
Gravity Scale は重力加速度の強さです。これを小さくすると重力が小さくなり、大きくするほど大きな重力がかかるようになります。
017.gif
重力を2倍にすることで、鋭い落下になりました。

画面外に出ないようにする

プレイヤーを画面外に出ないようにします。方法としては、画面の端の位置を計算して、そこからはみ出ていたら押し戻す、という処理を行います。
名称未設定.png
画面の端の取得には Camera.main.ViewportToWorldPoint() という関数を使用します。この関数で画面に表示されている位置を取得できます。この関数の座標系は以下の通りです。
名称未設定.png
左下が(0, 0)となり、右上が(1, 1)です。これを使って画面の上の部分と下の部分を取得するコードの記述例は以下の通りです。

// 画面上を取得する
float GetTop() {
  // 画面の右上のワールド座標を取得する
  Vector2 max = Camera.main.ViewportToWorldPoint(Vector2.one);
  return max.y
}

// 画面下を取得する
float GetBottom() {
  // 画面の左下のワールド座標を取得する
  Vector2 min = Camera.main.ViewportToWorldPoint(Vector2.zero);
  return min.y;
}

Vector2.zero は (0, 0)を、Vector2.one は (1, 1) を表します。
では、Player.cs を開いて、以下のようにコードを修正します。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png

Player.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// ■プレイヤー
public class Player : MonoBehaviour {
  [SerializeField]
  float JUMP_VELOCITY = 1000; // ジャンプ力の定義

  Rigidbody2D _rigidbody; // 物理挙動コンポーネント保持用

  // 開始処理
  void Start() {
    // 物理挙動コンポーネントを取得
    _rigidbody = GetComponent<Rigidbody2D>();
  }

  // 更新
  void Update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
      // Spaceキーを押したのでジャンプ処理
      _rigidbody.velocity = Vector2.zero; // 落下速度を一度リセットする
      _rigidbody.AddForce(new Vector2(0, JUMP_VELOCITY)); // 上方向に力を加える
    }
  }

  // 固定フレーム更新
  private void FixedUpdate() {
    // ①座標を取得
    Vector3 position = transform.position;
    // ②画面外に出ないようにする
    float y = transform.position.y;
    float vx = _rigidbody.velocity.x;
    if (y > GetTop()) {
      _rigidbody.velocity = Vector2.zero; // 速度を一度リセットする
      position.y = GetTop(); // ③押し戻しする
    }
    if (y < GetBottom()) {
      // ④下に落ちたらオートジャンプ
      _rigidbody.velocity = Vector2.zero; // 落下速度を一度リセットする
      _rigidbody.AddForce(new Vector2(0, JUMP_VELOCITY));
      position.y = GetBottom(); // 押し戻しする
    }

    // ⑤座標を反映する
    transform.position = position;
  }

  // 画面上を取得する
  float GetTop() {
    // 画面の右上のワールド座標を取得する
    Vector2 max = Camera.main.ViewportToWorldPoint(Vector2.one);
    return max.y;
  }

  // 画面下を取得する
  float GetBottom() {
    // 画面の左下のワールド座標を取得する
    Vector2 min = Camera.main.ViewportToWorldPoint(Vector2.zero);
    return min.y;
  }
}

追加した部分は、FixedUpdate()GetTop() / GetBottom() 関数です。
FixedUpdate() 一定間隔で呼び出されることが保証される Update() です。Update() は呼び出される間隔が不定なので、物理挙動を行う場合は FixedUpdate() 内に記述すると都合が良いためとなります。

では FixedUpdate() をみていきます。まずは①です。

Player.cs
    // ①座標を取得
    Vector3 position = transform.position;
    float y = transform.position.y;

ここでは Playerオブジェクトの位置を取得するために、transform コンポーネントから Y軸の値を取得しています (transform.position.y) 。Playerオブジェクトは上下のみの移動なので、Y軸の値だけで判定できます。

続いて②・③の部分です。

Player.cs
    // ②画面外に出ないようにする
    if (y > GetTop()) {
      _rigidbody.velocity = Vector2.zero; // 速度を一度リセットする
      position.y = GetTop(); // ③押し戻しする
    }

画面上からはみ出してしまったら、上昇速度をリセットして(ゼロにする)、はみ出ない位置まで押し戻します。
push.png
また速度をゼロにすることで、移動によるめり込みを防ぐようにしています。

④は画面下の判定です。

Player.cs
    if (y < GetBottom()) {
      // ④下に落ちたらオートジャンプ
      _rigidbody.velocity = Vector2.zero; // 落下速度を一度リセットする
      _rigidbody.AddForce(new Vector2(0, JUMP_VELOCITY));
      position.y = GetBottom(); // 押し戻しする
    }

押し返し処理としては同じですが、自動でジャンプ処理を行うようにしました。

最後に⑤で押し返した位置を transform に反映させます。

Player.cs
    // ⑤座標を反映する
    transform.position = position;

では実行して動きを確認してみます。
018.gif
画面上からはみ出なくなり、画面下では自動でジャンプするようになりました。

ジャンプアニメーションの追加

ジャンプ上昇中はキャラクター画像を切り替える簡単なアニメーションを実装します。
Unityでアニメーションを作る場合、Animatorを使うのが一般的ですが、少し複雑なので、ここではスクリプトで制御する方法で実装してみます。
Player.cs を開いて、以下のように修正します。

cs;Player.cs
// ■プレイヤー
public class Player : MonoBehaviour {
  // ①スプライト番号定義
  const int SPR_FALL = 0; // 落下中
  const int SPR_JUMP = 1; // ジャンプ中

  [SerializeField]
  float JUMP_VELOCITY = 1000; // ジャンプ力の定義
  public Sprite[] SPR_LIST; // ②アニメーション用スプライトの保持

  Rigidbody2D _rigidbody; // 物理挙動コンポーネント保持用

  // 開始処理
  
  
  

①のところで、スプライト番号に対応する画像定数 (SPR_FALLL / SPR_JUMP) を定義しています。
②で スプライトの配列 SPR_LIST を定義して、スプライト番号に対応するスプライトを格納します。

記述できたら、Unityエディタに戻って、Playerオブジェクトの Scriptコンポーネントに注目します。
Unity_2018_4_12f1_Personal_-_SampleScene_unity_-_TestFlappyBird_-_PC__Mac___Linux_Standalone__Personal___Metal_.png
JUMP_VELOCITY の下に、SPR_LIST という項目が増えているのが確認できます。ここの Size2 に変更して、スプライト player_0 / player_1 を割り当てます。
019.gif
これにより、SPR_LIST[0] には "player_0"、SPR_LIST[1] に "player_1" が割り当てられたことになります。
名称未設定.png
これを Playerオブジェクトの状態によって切り替えるようにします。
Player.cs を開いて、以下のように修正します。

Player.cs
// ■プレイヤー
public class Player : MonoBehaviour {
  // スプライト番号定義
  const int SPR_FALL = 0; // 落下中
  const int SPR_JUMP = 1; // ジャンプ中

  [SerializeField]
  float JUMP_VELOCITY = 1000; // ジャンプ力の定義
  public Sprite[] SPR_LIST; // アニメーション用スプライトの保持

  Rigidbody2D _rigidbody; // 物理挙動コンポーネント保持用
  SpriteRenderer _renderer; // ①スプライト描画

  // 開始処理
  void Start() {
    // 物理挙動コンポーネントを取得
    _rigidbody = GetComponent<Rigidbody2D>();
    // ②スプライト描画コンポーネントを取得
    _renderer = GetComponent<SpriteRenderer>();
  }

  // 更新
  void Update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
      // Spaceキーを押したのでジャンプ処理
      _rigidbody.velocity = Vector2.zero; // 落下速度を一度リセットする
      _rigidbody.AddForce(new Vector2(0, JUMP_VELOCITY)); // 上方向に力を加える
    }

    // ③プレイヤーの状態でスプライトを切り替える
    if (_rigidbody.velocity.y < 0) {
      // 落下中
      _renderer.sprite = SPR_LIST[SPR_FALL];
    } else {
      // 上昇中
      _renderer.sprite = SPR_LIST[SPR_JUMP];
    }
  }
  
  
  

①でスプライト描画コンポーネントである SpriteRenderer を格納する変数を定義しています。

Player.cs
  SpriteRenderer _renderer; // ①スプライト描画

スプライト描画コンポーネントを取得するために、② (Start関数) で取得処理を行っています。

Player.cs
  // 開始処理
  void Start() {
    // 物理挙動コンポーネントを取得
    _rigidbody = GetComponent<Rigidbody2D>();
    // ②スプライト描画コンポーネントを取得
    _renderer = GetComponent<SpriteRenderer>();
  }

そして③(Update関数)で落下方向によってスプライトの切り替えを行っています。下方向の移動(yがマイナス)であれば落下とし、上方向の移動であればジャンプ中、という判定としています。

Player.cs
  // 更新
  void Update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
      // Spaceキーを押したのでジャンプ処理
      _rigidbody.velocity = Vector2.zero; // 落下速度を一度リセットする
      _rigidbody.AddForce(new Vector2(0, JUMP_VELOCITY)); // 上方向に力を加える
    }

    // ③プレイヤーの状態でスプライトを切り替える
    if (_rigidbody.velocity.y < 0) {
      // 落下中
      _renderer.sprite = SPR_LIST[SPR_FALL];
    } else {
      // 上昇中
      _renderer.sprite = SPR_LIST[SPR_JUMP];
    }
  }

では実行して、ジャンプ中と落下中でスプライトが切り替わることを確認します。
020.gif

次回

次のページでは障害物となるブロック、それを生成するブロック管理を作成します。

Flappy Bird を作るチュートリアル (2/3)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】DOTS(ECS)を導入したゲームを作る際に設計してみた話

この記事は【unityプロ技】 Advent Calendar 2019の7日目の記事です。

もう1ヶ月以上前にはなりますが【Unity1週間ゲームジャム - お題「さがす」1】と言うベントが開催されたので、それを機に久しぶりにDOTS2の一環であるECSを導入して簡単なゲームを実装してみました。

実装するにあたっては表題に「設計」とある通り、ECSを使う際の設計部分...もう少し踏み込んで言うと「ECSとMonoBehaviourとの連携周りの設計」を少なからずは意識するようにして実装してみました。3

はじめに

設計を検討し直したと言えども...設計の方針については恐らく正解と呼べるものが無い上に、今回の設計自体もまだ微妙なところが点在していると思ってます。

それにECS自体もまだpreviewの段階という事もあり、将来の変更によってはその影響で今回解説する内容が適用出来なくなる/または考え直す必要が出てくると言った可能性もあり得ます。

そもそもとして「(変更の可能性がある)previewの段階で設計の話を持ち出すのか?」と言う考えもあるかもしれませんが...それでも部分的に参考になるところはあるかもしれない上に「ECSを実際に組み込んでみた」関連の情報があまり出回っていない印象のある今、折角やって出さないのは少し勿体無いとも思えたのでアウトプットすることにしました。

将来そのまま適用できる話ではないかもしれませんが、ECSを触る際の一例として参考になれば幸いです。

● Project Repository

mao-test-h/DOTS-Jungle

※ 記事中で引用しているコードのライセンスは上記リポジトリに準拠

Supported Unity versions

Unity 2019.3.0f1+

DOTS Packages
  • Entities preview.4 - 0.3.0
  • Hybrid Renderer preview.4 - 0.3.0
    • ※開発自体は過去版である0.1.1をメインに行っていたので、最新に追従できていない箇所があります。

※注意点

記事中ではDOTS及びECSの基礎的な部分や概念については触れません。

これらに関する初学者向けの資料としては、今年のUniteであった以下の講演が比較的新しい上に分かりやすくてオススメです。(どちらか一方ではなく、合わせて見た方が良いかも)

TL;DR

先に要点を纏めます。

ECSとMonoBehaviourとの連携周りの設計 → 「必要な箇所だけECSで動かすように」

  • 従来通りMonoBehaviour/GameObjectベースの実装でゲーム全体を構築しつつ、大量に計算する必要がある箇所だけECSで動かすようにする
    • → 「ECSと言うアーキテクチャを高速化のためのモジュールとして取り入れる」と言う考え

MonoBehaviourとECSはレイヤーを分けて「依存関係を制限」

  • MonoBehaviourとECSは「Assembly Definition Files (以降、ADFと表記)」でレイヤーを分けることで依存関係(依存の方向)を制限させる
    • 「MonoBehaviourレイヤー」からは「Providerレイヤー」にあるメソッドを叩いてEntityを生成
      • → MonoBehaviourからComponentSystemなどのコアロジックを見えないようにする
    • 「ECSレイヤー」からは「MonoBehaviourレイヤー」が見えない
      • → MonoBehaviourレイヤーの影響は一切受けない
  • 結果としてMonoBehaviourとECSが分断されるので、「お互いの状態に依存しなくなる」
    • → 依存性がなくなると言うことは「機能単位にテスト/動作確認を行いやすくなる」「変更に強くなる」「設計の見通しが良くなる(何がどこにあるのか?作る際にどこに作ればよいのか把握しやすくなる)」とも言い換えられるので、チーム開発や運用案件で常に動かし続けなくてはならないプロジェクトにとってはメンテナンス性周りで利点に繋がるかも。

※以降の解説でも「レイヤー」と言う単語が出てきますが、こちらは「ADFで区切ったAssembly単位」と同義になります。

0.png

※ なぜ依存関係を制限させるのか?

そもそもとしてMonoBehaviourとECS(ComponentSystem)はお互い別々のアーキテクチャとなっているために、その時点では「既に分断はされている」とも言えるかもしれません。
→ 更に使い方を「MonoBehaviourからEntityManagerを叩いてEntityを生成するだけ」と言ったシンプルな操作に絞るだけなら依存の方向もスッキリします。

しかし、ECSの方は管理によってはWorld.DefaultGameObjectInjectionWorld(従来で言うWorld.Active)を経由したグローバルなアクセスによりMonoBehaviourのどこからでもComponentSystemを引っ張ってこれてしまう上に、ComponentSystem自体も実態としてはピュアなC#のクラスでしか無いので、状態を持たせるような実装もやろうと思えば可能になるかと思います。

そうなると、使い方によっては「MonoBehaviour側から直接ComponentSystemの状態を変更して挙動を変える」と言った実装を行える(言い換えるとMonoBehaviourに依存する)ので、「レイヤー単位で分割することでMonoBehaviourレイヤーからECSレイヤーを見えなくして非依存であることを保証する」と言ったのが今回のアプローチの一つの考えであります。

作ったゲームについて

具体的な解説に入っていく前に作ったゲームについても簡単に触れておきます。
※ 以降の解説に登場人物として出てくるので。

ゲームとしては「主人公であるゴリラがジャングルでバナナを探して集める」と言うシンプルなゴリラシミュレータであり、以下のゲーム画面のようにバナナの木から絶えず射出され続ける光弾に耐えつつ、たまに光弾に混じって射出されるバナナを取得してスコアを稼いでいくゲームになります。
(以降、バナナの木から射出されるバナナをスコアバナナと表記)

あとはゴリラ自体は光弾を受け続けたらゲームオーバーとなるので、ピンチの時用にドラミングで周囲にエネルギー波を飛ばして光弾とスコアバナナ諸共消滅させる必殺技を出すことも可能となってます。

69494705-2565c800-0f02-11ea-![EKnMaH4VAAAhZzD.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/9457b0da-f800-3217-beec-8b4c7a67af6c.png)<br>
8fd1-b25de3fee158.gif

※ ちなみに公開リポジトリの方はゴリラを始めとしたAssetStoreの物は再配布できないので、代わりとなる物に差し替えてあります。

設計について

今回の設計思想を一言で表すと「従来通りMonoBehaviour/GameObjectベースの実装でゲーム全体を構築しつつ、必要な箇所だけECSで動かすようにする」になります。

この「必要な箇所だけECSで動かすようにする」と言う点については、言い換えると「ECSと言うアーキテクチャを高速化のためのモジュールとして取り入れる」とも説明できるかもしれません。

「MonoBehaviourとECSが共存する」となるとイメージがつかないうちは「2つの巨大なアーキテクチャが混在してゴチャゴチャしそう...?4」と思われるかもしれませんが...これらはレイヤーを分けて「お互いの参照方向を明確にしてやる」ことで治安良く管理できるものだと私は考えてます。

具体的な設計について順を追って解説していきます。

必要な箇所だけECSで動かすようにする?

言葉のとおりであって、今回の例で言えば大量に計算する必要がある箇所のみECSで計算出来るような作りにしてあります。

詳細に入る前に...先ずは解説を進めやすくするために、インゲームに登場するオブジェクトとそれの制御関係を以下に纏めます。

GameObjectで管理 (MonoBehaviourで制御)

  • ゴリラ(プレイヤー)
    • 入力操作制御/アニメーション
  • バナナの木
    • 光弾及びスコアバナナの射出制御など

Entityで管理 (ComponentSystemで制御)

  • 弾全般 (光弾, スコアバナナ, エネルギー波)
    • 移動処理 (弾道の計算ロジック)
    • 衝突判定全般
      • 「エネルギー波」 x 「光弾/スコアバナナ」
      • 「ゴリラ」 x 「光弾/スコアバナナ」
        • ※ GameObjectであるゴリラとEntityである光弾/スコアバナナでどうやって衝突判定を取っているのかについては後述
  • バナナの木に実っている大量の演出用バナナ
    • 動作制御
    • ※ このオブジェクト自体は演出用なのでゲームロジックには影響しない

※バナナの木の見かけについてはこちらを参照 (クリックで展開)

banana.gif

こちらがジャングルに自生する今作の「バナナの木」となります。
中央のコアを周回しているのが「バナナの木に実っている大量の演出用バナナ」であり、ComponentSystemで動作制御しているEntity郡となります。

★ ポイント

この様にECSで制御しているのはゲームとして「必然的に大量発生するであろうオブジェクトの移動制御/衝突判定/演出」だけに留まってます。

逆に言うと、それ以外のゲームループやUIの制御、プレイヤーの制御(入力制御/アニメーション)、オーディオやパーティクルなどは全て従来通りの作り方そのままで実装されてます。

レイヤー構成

大まかなレイヤー及び参照方向の図を載せます。

矢印は参照方向を示しており、言葉で簡単に説明すると「MonoBehaviourレイヤーからはProviderレイヤーにあるメソッドを叩いてEntityを生成。ComponentSystemなどを見えないようにする」 「ECSレイヤーからはMonoBehaviourレイヤーが見えないので、変更などの影響は一切受けない」ような構成にしてます。

0.png

★ ポイント

この構成のポイントとしては「MonoBehaviourレイヤーとECSレイヤーが分断されるので、お互いの状態に依存しなくなる」点にあります。

依存性がなくなると言うことは先程の要点の項目でも記載したとおり、「機能単位にテスト/動作確認を行いやすくなる」「設計の見通しが良くなる」「変更に強くなる」とも言い換えられるので、チーム開発や運用案件で常に動かし続けなくてはならないプロジェクトにとってはメンテナンス性周りで利点につながるかと思います。

Assembly Definition Filesを導入しやすくなる

後は参照の方向を明確にすると言うことは、ADFによるAssembly単位の分割も行いやすくなる利点もあります。
今回の設計ではADFを切るところまでを含めて1つのレイヤーとして定義してます。

※ 補足: ADFを切る利点について (クリックで展開)

ADFを切ると何が良いのか?について簡単に所感をまとめます。

  • レイヤー単位の役割が明確になり、不必要なパッケージの参照を切り離すことが出来る
  • レイヤー単位での「Allow unsafe Code」の設定が可能
    • こちらはProject Settingsにある同様の設定とは独立した物となっているいるので、unafeを必要とする範囲を絞ることが可能になります
  • コンパイル時間の短縮

2点目の「Allow unsafe Code」についてはまさに今回のレイヤー設定で言うと、必要となる箇所は「ECSレイヤー」だけとなっているので、こちらのみ有効にして他は使わないことを明示できるような構成となってます。


他のレイヤーも含めると全体としては大雑把に見て以下のような構成となります。5

all2.png

例えば「どのレイヤーからも参照される共通定義(定数、enum定義)」は最小限の単位で1つのレイヤーに纏めることで各レイヤーから参照できるようにしてます。

他にも「ツール類(Editor拡張など)」と言った物についても結合度を下げる目的で1つの独立したレイヤーに分けてやり、外から必要とするレイヤーを覗くような構成としました。

各レイヤーの役割

メインとなるレイヤーの役割について解説していきます。

MonoBehaviour

ベースとなるレイヤーです。

a.png

ECS自体は「大量に動かす必要のあるオブジェクトの移動/衝突判定」ぐらいでしか使っていないので、担当箇所としてはそれ以外の全てとなります。

  • ゲームループの制御
  • プレイヤーであるゴリラの制御
    • 入力に対する操作制御 (移動、ドラミング)
    • ゴリラのアニメーション
      • → 従来通りAnimatorを利用
  • バナナの木の制御
    • 光弾スコアバナナの射出ロジック
      • → 愚直にMonoBehaviour.Updateで実装
  • オーディオ全般
    • → 従来通りAudioSource/AudioListenerを利用
  • パーティクル全般
    • → 従来通りShurikenを利用
  • UI全般
    • uGUI

ECSからのイベント取得について

今回の実装では「ゴリラはGameObject」「光弾とスコアバナナはEntity」となっている上で、衝突判定に関するロジックは全て「ECSレイヤー」のComponentSystemで判定を取ってます。
これは言い換えるとUnityEngine.RigidbodyUnityEngine.Colliderの類は一切使っていない事を指します。

ただ、パーティクルやオーディオの制御は全てMonoBehaviour側が受け持つ形となっているので、実装としては「ComponentSystemからMonoBehaviourに対しイベントを通知する」専用の仕組みを実装して制御できるようにしました。

こちらの詳細については「ECS.Provider.Hybridレイヤー」の項目で解説します。

検討事項

一部のロジックはECSに委譲できるかも

今回は数と負荷的に許容できたので一部のロジックはそのままMonoBehaviourで実装してますが、例えば以下の処理などは数によってはECSの制御下に委譲することができるかもしれません。

  • バナナの木の光弾スコアバナナの射出ロジック
  • パーティクル制御
    • ただしパーティクルシステムの再実装の必要が有りそう
  • オーディオ制御
    • DOTS Audioが使えるようになったら検証の価値あり

ECS.Provider

こちらはMonoBehaviourからECSを操作するためのレイヤーとなります。
(※ 名前にProviderと付いているが、個人的にはもう少し別の名前を検討できたかもしれない感があったりも..)

b.png

例えば「Entityの生成」と言った処理はMonoBehaviourから直接EntityManagerを叩いて生成せずに、全てこちらのレイヤーにあるECSProviderと言うクラスのメソッドが受け持つ形となっております。

この様に分離することで以下の利点が成立します。

  • MonoBehaviourからはECSを知らなくてもメソッドを叩く形でEntityを生成できる
    • → 言い換えるとECSの実装を透過的にすることが出来る
  • ECSからはMonoBehaviourに対し根本にあるEntities関連のクラスを隠すことができる

今回はECSProviderは直接具象クラスとして実装してますが、Interfaceで分けることも検討できるかもしれません。

Entityの生成をメソッド化

ECSProviderでは以下の様にEntityの生成処理をメソッド単位で持たせるようにしてます。
→ 例として光弾スコアバナナの生成メソッドの一部を引用

※ しれっとPrefabProviderなるクラスが出てきてますが、こちらについてはEntityのPrefab Workflowについての章にて解説

ECSProvider.cs
/// <summary>
/// バナナの木から「光弾 or バナナ」を生成
/// </summary>
/// <param name="position">バナナの木の位置</param>
/// <param name="isBanana">trueならスコアバナナを生成</param>
public void CreateCircleBullet(in float3 position, bool isBanana)
{
    // ベースとなるPrefabEntityを取得
    var prefabEntity = _prefabProvider.PrefabEntities[(int) (isBanana ? BulletIndex.Banana : BulletIndex.Damage)];
    var barrageParam = _entityManager.GetComponentData<BarrageConfig>(prefabEntity).Config.Circle;

    .....

    var count = barrageParam.BurstCount;
    for (int i = 0; i < count; i++)
    {
        // 計算いろいろ

        // Entityの生成
        var entity = Instantiate(prefabEntity, position, rotResult);
        _entityManager.SetComponentData(entity, new Angle {Value = dir});
        _entityManager.SetComponentData(entity, new Destroyable
        {
            IsKilled = false,
            Lifespan = barrageParam.Lifespan,
        });
        _entityManager.AddComponentData(entity, new CircleTag());
    }
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
Entity Instantiate(in Entity prefabEntity, in float3 position, in quaternion rotation)
{
    var entity = _entityManager.Instantiate(prefabEntity);
    _entityManager.SetComponentData(entity, new Translation {Value = position});
    _entityManager.SetComponentData(entity, new Rotation {Value = rotation});
    return entity;
}

使う側である「バナナの木の射出ロジック」としては以下のようにUpdateで時間を計測して一定時間経過したらECSProviderの生成メソッドを呼び出して生成だけです。

BananaTreeLogic.cs
public ECSProvider Provider { get; private set; }

void Update()
{
    // HACK: Updateで直にEntityを生成しているが、パフォーマンスが気になるなら生成機構をECSに回しても良いかも
    if (_time > _barrageConfig.ShotSpan)
    {
        var trs = transform;
        var isBanana = UnityEngine.Random.value <= _barrageConfig.BananaRate;

        // 一定時間経過したら弾幕の種類に応じてEntity(光弾/バナナ)を生成
        switch (_barrageType)
        {
            case Barrage.Aiming:
                Provider.CreateAimingBullet(
                    trs.position, trs.rotation,
                    _targetTrs.position, isBanana);
                break;
            case Barrage.Circle:
                Provider.CreateCircleBullet(
                    trs.position, isBanana);
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }

        _time = 0f;
        return;
    }

    _time += Time.deltaTime;
}

ECSProviderを必要とするクラスに対し参照を注入

ECSProviderを必要とする各クラスに対する参照解決について、こちらは「IECSProviderUsableと言うinterfaceを実装したクラスに対し、初期化時のタイミングでインスタンスを流し込む」形で参照解決してます。

IECSProviderUsable.cs
namespace Main.ECS.Provider
{
    public interface IECSProviderUsable
    {
        ECSProvider Provider { get; }
        void SetProvider(in ECSProvider provider);
    }
}

処理としては非常に単純であり、ゲームループの初期化時にECSProviderをインスタンス化 → 全GameObjectを総なめして流し込んでいるだけです。
(恐らくはZenjectなど導入すればもうちょっとスマートに書けるかもしれないので要検証...)

GameLoop.cs
// ゲームループのコンストラクタ
public GameLoop(GameLoopType startType, GameSettings settings, IAudioPlayer audioPlayer)
{
    // ECSProviderのインスタンス化
    _ecsProvider = new ECSProvider();

    // HACK. 面倒なので全オブジェクト拾ってきてinterfaceを対象に初期化していく.
    var objs = Object.FindObjectsOfType<GameObject>();
    foreach (var obj in objs)
    {
        // ECS利用箇所にPresenterを流し込む
        foreach (var usable in obj.GetComponents<IECSProviderUsable>())
        {
            usable.SetProvider(_ecsProvider);
        }
    }

先に例として挙げたバナナの木以外でECSProviderを必要とするクラスとしては、プレイヤーであるゴリラもこちらを必要としており、例えばドラミングが入力されたタイミングでECSProviderの「エネルギー波の生成メソッド」を呼び出します。

PlayerLogic.cs
/// <summary>
/// ドラミングが入力されたタイミングで呼び出されるイベント
/// ※イベント自体はAnimationClipから発火される
/// </summary>
public void StartDrumming()
{
    // カメラシェイク
    _cinemachineImpulseSource.GenerateImpulse();
    // 効果音再生
    _audioPlayer.PlayDrumming();
    // ドラミングのエネルギー波(Entity)生成
    Provider.CreateDrummingBarrage(_trs.position, _trs.rotation);
}

Entityの生存管理について

Entityも種類によってはMonoBehaviour側で生存管理をしたい時があります。

生存管理を行いたい意図としては「GameObjectが破棄されたタイミングで紐付いているEntityも一緒に破棄したい」と言う要件であり、今回で言えば「バナナの木に実っている大量の演出用バナナ」などがそれにあたります。

こちらを解決する手段としては、ECSProviderで生成メソッドを呼び出したタイミングで「戻り値として生存管理用のIDisposable」を返す形で管理できるようにしました。

具体例

以下のメソッドはECSProviderにある演出用バナナの生成メソッドです。
内部で生成したEntityは一度NativeArrayに保持をして後述のEntityDisposerに渡します。

ECSProvider.cs
/// <summary>
/// バナナの木に実っている演出用バナナの生成
/// </summary>
/// <returns>Entityを破棄する際には戻り値のDisposeを呼び出すこと</returns>
public IDisposable CreateFruitfulBananas(
    in float3 position, in quaternion rotation,
    int createCount, float defaultRadius,
    Vector2 rotationSpeedRange, Vector2 thetaRange)
{
    // ベースとなるPrefabEntityを取得
    var prefabEntity = _prefabProvider.PrefabEntities[FruitfulBananaIndex];
    var arr = new NativeArray<Entity>(createCount, Allocator.Temp);
    for (int i = 0; i < createCount; i++)
    {
        // Entityの生成
        var entity = Instantiate(prefabEntity, position, rotation);
        _entityManager.SetComponentData(entity, new OriginalPosition {Value = position});

        // 必要な情報をSetCompoonentData... (省略)

        arr[i] = entity;
    }

    // POINT: 生存間利用に作ったEntityは保持して返す
    var disposer = new EntityDisposer(_entityManager, arr);
    arr.Dispose();
    return disposer;
}

EntityDisposerの実装としては以下のようになっていて、IDisposableを実装したシンプルな破棄管理用のクラスになります。

MonoBehaviourには生成時にIDisposableとして返されるので、内部実装を意図せずとも透過的にEntityを破棄することが可能になります。6

EntityDisposer.cs
public class EntityDisposer : IDisposable
{
    readonly EntityManager _entityManager;
    NativeArray<Entity> _entities;

    public EntityDisposer(EntityManager entityManager, NativeArray<Entity> entities)
    {
        _entityManager = entityManager;
        _entities = new NativeArray<Entity>(entities, Allocator.Persistent);
    }

    public void Dispose()
    {
        try
        {
            foreach (var entity in _entities)
            {
                _entityManager.DestroyEntity(entity);
            }
        }
        catch (NullReferenceException e)
        {
            // ゲーム終了タイミングでDisposeが呼び出されるとEntityManagerが先に破棄されている事があり、
            // 内部的にNullReferenceExceptionが飛んでくるのでNativeArrayが正しく破棄されるように例外を潰しておく.
            // FIXME: 逆にアプリ終了以外で呼び出されたら想定外なので適切に対処すること.
            Debug.LogWarning($"    >>> {e}, {e.Message}");
        }

        _entities.Dispose();
    }
}

生存管理しなくても良いEntityもある

ゲーム中に存在するEntityとしては「演出用バナナ」の他に「弾全般」がありますが、こちらについては「一度生成されたらComponentSystem内で確実に破棄される仕組み」となっているために、特にIDisposableを返すような生存管理などは行ってません。

具体的に言うと、弾は以下の条件を満たしたときに破棄されるので基本残り続けることがありません。

  • 衝突判定時に条件を満たしたとき
  • Y軸の位置を見て床よりも下に移動したとき
  • 生存管理用のComponentDataが持つ生存時間が経過したとき
Destroyable.cs
// 生存管理用のComponentData
public struct Destroyable : IComponentData
{
    public float Lifespan;  // 生存時間
    public bool IsKilled;   // trueで破棄確定
}

ゲーム側の仕様として「バナナの木が破壊可能であり、破棄されたタイミングで射出された弾を全て消す」と言った仕様にしたい場合であれば、仕組みを改修して生存管理できるようにするのは有りかもしれません。

ECS

「ECS.Provider.Hybridレイヤー」の解説に入る前に先にこちらの方から解説していきます。

メインとなるレイヤーの一番上に位置するこちらは他のレイヤーに依存することなく、独立して稼働するようなイメージとなります。

d.png

レイヤーが持つ役割をザックリと纏めると以下のようなものと定義できます。

  • ComponentDataComponentSystemの実装
  • オーサリングコンポーネントの定義
  • ECSレイヤーで使用するunsafeなScriptableObjectの管理

ComponentSystemについて

今回のゲームで動かすComponentSystemとしては以下のようなものを実装しました。

  • BarrageSystem
    • 弾幕の弾道計算(弾の移動)
  • UpdateColliderSystem
  • CheckIntersectSystem
    • 衝突判定
  • DestroySystem
    • Entityの破棄
  • FruitBananaSystem
    • バナナの木に実っている演出用バナナの動作

こちらに記載しているComponentSystemについては、主に「PureECSを対象としたEntityの制御」を行うものとなります。

これとは別に後述の「ECS.Provider.Hybridレイヤー」でも幾つかのComponentSystemを定義してますが、こちらは「HybridECS」を想定したものとなるため、Entity以外にもMonoBehaviour(従来のコンポーネント)に対しても処理を行うと言った違いがあります。

分けている意図としてはHybridECSの特性上「MonoBehaviourレイヤー」から「ECSレイヤー」を直接参照する必要性が出てきたので別レイヤーに分断しました。

※補足: ComponentSystemの実行順について (クリックで展開)

ComponentSystemの実行順については基本的にDefault Worldにある既定のComponentSystemGroupなどをベースに設定してます。

こちらについては以下の3つのグループが既定のものとして定義されており、全てのPlayerLoopを含めた実行位置についてはEntity Debuggerより確認可能となるので、それを見つつ独自のグループを挟むなりして調整してます。

  • InitializationSystemGroup
  • SimulationSystemGroup
  • PresentationSystemGroup

※ちなみに、おなじみのMonoBehaviour.Updateが呼ばれるタイミングは「Update.ScriptRunBehaviourUpdate」が該当します。SimulationSystemGroupよりも前に呼び出されてますね。

Art000.png

詳細については以下の記事がすごく参考になります。

オーサリングコンポーネントについて

オーサリングコンポーネントを簡単に説明すると、従来のコンポーネントをECSのComponentDataに変換する物を指します。

このレイヤーで管理しているオーサリングコンポーネントは通常のPrefab(NestedPrefab)にアタッチして使う想定が有り、主に「PureECSで処理するPrefabEntityに変換を行うためのコンポーネント」として機能します。

また、後述の「ECS.Provider.Hybridレイヤー」でもオーサリングコンポーネントを定義してありますが、こちらは「HybridECS側で処理するコンポーネント」を想定していると言った違いがあります。

このオーサリングコンポーネントを主に用いているところとしては、今回で言うPrefab Workflowが該当しますが、こちらについてはEntityのPrefab Workflowについてと言う章に纏めてあるのでそちらをご覧ください。

ECSレイヤーで使用するunsafeなScriptableObjectの管理

ECS側で共通値参照を行う際のテクニックとなりますが、こちらは本題とは少しズレるので「付録: ECSレイヤーで使用するunsafeなScriptableObjectの管理について」と言う章に纏めました。

宜しければ御覧ください。

ECS.Provider.Hybrid

最後にHybridについて解説します。

c.png

こちらはHybridECSと言う性質上、「MonoBehaviourレイヤー」から一部を参照する必要があるために、位置的にはECSレイヤーの一つ下に配置するようにしてます。

このレイヤーが存在する大きな意図としては「MonoBehaviourとECSの相互連結」であり、今回で言えばECS側で計算を行っている衝突判定時のイベント通知などがそれに該当します。

以降、衝突判定処理を中心に解説していきます。

衝突判定で用いるオーサリングコンポーネントを定義

キーとなるオーサリングコンポーネントは以下のHitReceiverComponentです。

役割としてはHybridECSの特性である「GameObjectを保ったままのEntity」に対し、衝突判定の各種設定を適用すると言ったものになります。

今回の使用箇所で言うと、プレイヤーであるゴリラのGameObjectにこちらをアタッチすることで「ゴリラと弾各種(光弾/スコアバナナ)の衝突判定」を取れるようにします。

HitReceiverComponent.cs
namespace Main.ECS.Provider.Hybrid
{
    [RequireComponent(typeof(ECSSphereColliderComponent))]
    [RequireComponent(typeof(ConvertToEntity))]
    [RequiresEntityConversion]
    public sealed class HitReceiverComponent : MonoBehaviour, IConvertGameObjectToEntity
    {
        public event Action<float3> OnDamageHitEvent = default;
        public event Action<float3> OnBananaHitEvent = default;

        Entity _entity;
        ECSSphereColliderComponent _sphereColliderComponent;

        void IConvertGameObjectToEntity.Convert(
            Entity entity, EntityManager dstManager,
            GameObjectConversionSystem conversionSystem)
        {
            // GameObject → Entityの方向で情報を同期させる.
            _entity = entity;
            dstManager.AddComponentData(entity, new SyncTRSFromGameObject());

            // 衝突判定用のComponentDataを追加
            _sphereColliderComponent = GetComponent<ECSSphereColliderComponent>();
            var sphereCollider = _sphereColliderComponent.GetComponentData;
            dstManager.AddComponentData(entity, sphereCollider);

            dstManager.AddComponentData(entity, new HitReceiverTag());
        }

        void OnDestroy()
        {
            var world = World.Active;
            if (world == null) return;

            var entityManager = World.Active.EntityManager;
            if (entityManager.Exists(_entity))
            {
                entityManager.DestroyEntity(_entity);
            }
        }

        // 衝突時にComponentSystemから呼び出される
        public void OnCollisionHit(float3 hitPosition, Bullet bullet)
        {
            //Debug.Log($"    hit >>> {hitPosition}, {bullet}");
            switch (bullet)
            {
                case Bullet.Damage:
                    OnDamageHitEvent?.Invoke(hitPosition);
                    break;
                case Bullet.Banana:
                    OnBananaHitEvent?.Invoke(hitPosition);
                    break;
            }
        }
    }
}

今回の作りとしてはプレイヤーはシーンに直に置かれているので、以下のようにアタッチして衝突判定に必要となるパラメータを設定します。

gorilla.png

※Hybrid想定なのでConvertToEntityConversionModeは「Convert And Injection GameObject」に設定。これでGameObjectは破棄されずにEntityと共存可能となる。

衝突判定の通知について

こちらは「ECS.Provider.Hybridレイヤー」が持つComponentSystemで通知を行います。

処理としてはHitEventDispatcherSystemと言うComponentSystemがそれにあたり、OnUpdateの中でPlayerHitEventStateと言うISystemStateComponentDataを見てぶつかったことを通知していきます。
(PlayerHitEventStateがどこでAddComponentDataされるのかについては後述)

※補足: ISystemStateComponentDataについて (クリックで展開)

一言で言うと「EntityがDestroyEntityされても破棄されずに残り続ける特殊なComponentData」であり、言い換えると「ISystemStateComponentDataを持つEntityはISystemStateComponentDataを取り除かない限りずっと残り続ける」ことになります。

ISystemStateComponentDataを取り除く方法としては、これ自体を明示的にRemoveComponentする事で取り除くことが可能です。

※参考 : 【Unity】ISystemStateComponentDataという機能

今回の利用箇所としては「ヒットした弾のEntity」に対してこれをAddComponentDataする事により、弾自体はDestroyEntityを呼び出して機能を停止させることが出来る上で、今回のようにイベント用のEntityとして回収することが可能となります。
※この使い方が思想的に正しいのかは少し見えておらず...

HitEventDispatcherSystem.cs
// ECSからMonoBehaviourへのヒット通知(Hybridで処理)
[UpdateInGroup(typeof(InitializationEventGroup))]
sealed class HitEventDispatcherSystem : ComponentSystem
{
    EntityQuery _playerHybridQuery;
    EntityQuery _playerHitEventQuery;

    protected override void OnCreate()
    {
        _playerHybridQuery = GetEntityQuery(new EntityQueryDesc()
        {
            All = new ComponentType[]
            {
                ComponentType.ReadWrite<HitReceiverTag>(),
                typeof(Transform),
            },
        });

        _playerHitEventQuery = GetEntityQuery(new EntityQueryDesc()
        {
            All = new ComponentType[]
            {
                ComponentType.ReadWrite<PlayerHitEventState>(),
            },
        });
    }


    protected override void OnUpdate()
    {
        var transforms = _playerHybridQuery.GetTransformAccessArray();

        Entities.With(_playerHitEventQuery).ForEach((
            Entity entity,
            ref PlayerHitEventState state) =>
        {
            for (int i = 0; i < transforms.length; i++)
            {
                // ヒットしたらHitReceiverComponent.OnCollisionHitを呼び出す
                var authoring = transforms[i].gameObject.GetComponent<HitReceiverComponent>();
                authoring.OnCollisionHit(state.HitPosition, state.BulletType);
            }

            EntityManager.RemoveComponent<PlayerHitEventState>(entity);
            EntityManager.DestroyEntity(entity);
        });
    }
}

一方のMonoBehaviour側での通知の受け取り方としては、先ほども出てきたオーサリングコンポーネントを経由して受け取る形となります。
HitReceiverComponentが「OnDamageHitEventOnBananaHitEvent」と言うイベントを外に公開しているので、必要とするクラスがこちらを購読してイベントを受け取ります。

HitReceiverComponent.cs
namespace Main.ECS.Provider.Hybrid
{
    [RequireComponent(typeof(ECSSphereColliderComponent))]
    [RequireComponent(typeof(ConvertToEntity))]
    [RequiresEntityConversion]
    public sealed class HitReceiverComponent : MonoBehaviour, IConvertGameObjectToEntity
    {
        // 必要とするクラスにてこちらを購読してイベントを受け取る
        public event Action<float3> OnDamageHitEvent = default;
        public event Action<float3> OnBananaHitEvent = default;

        .......

        // 衝突時にComponentSystemから呼び出される
        public void OnCollisionHit(float3 hitPosition, Bullet bullet)
        {
            //Debug.Log($"    hit >>> {hitPosition}, {bullet}");
            switch (bullet)
            {
                case Bullet.Damage:
                    OnDamageHitEvent?.Invoke(hitPosition);
                    break;
                case Bullet.Banana:
                    OnBananaHitEvent?.Invoke(hitPosition);
                    break;
            }
        }
    }
}

※衝突判定の計算そのものは「ECSレイヤー」で処理

上記のHitEventDispatcherSystemでは「通知を行う」とだけあり、実際の衝突判定の計算そのものは行ってません。
では衝突判定の計算はどこで行っているのかと言うと、こちらは「ECSレイヤー」にあるCheckIntersectSystemUpdateColliderSystemと言う衝突判定用のComponentSystemで処理してます。

分けてる意図としては、計算量が多い衝突判定はPureECS側で計算させることで「BurstCompilerの最適化を適用しつつ効率良く処理」できるようにしてます。
※特に今回の衝突判定は愚直に総当りで計算しているので量が多い。空間分割とかしてやれば最適化出来るかもしれないが未対応。

ソースについては引用しないので以下を参照してください。

衝突判定の流れとしては、CheckIntersectSystem「ヒットした弾のEntity」に対してPlayerHitEventStateをAddComponentData → こちらを上述のHitEventDispatcherSystemで回収して通知を行うと行った流れになります。

検討事項

こちらのHybridレイヤーですが、個人的には色々と検討の余地があると思っているので最後に纏めておきます。

レイヤー構成について

現状は「ECS.Providerレイヤー」の内部に含まれてますが...改めて考え直すとECS.Providerの外に出してECS.Hybridと言う別レイヤーで管理した方が良かったかな〜と思ってます。

更にその上で「Hybridレイヤー」自体も「MonoBehaviourレイヤーに向けての公開層」と「ComponentSystemを含んだ内部実装層」の複数階層に分けたほうが良かったかとも考えてます。
※今回の実装の反省点として、「MonoBehaciourレイヤー」と直に結びついている「Hybridレイヤー」がComponentSystemと言ったコアロジックを持ってしまった点があり..。(アクセス修飾子レベルでアクセスできないようにはしている)

図に纏めると以下のような構成になります。

hybrid_internal.png

どちらにせよHybridECSの構成については色々と検討の余地が残っていると感じてます...。

衝突判定用オーサリングコンポーネントの役割

このレイヤーにあるHitReceiverComponentがそれに当たりますが...こちらは機能的には「Entityに対するComponentDataの付与」以外にも「衝突時のイベント通知」も持っちゃってます。

ここらはinterfaceで実装を分けるのも有りかな~と思いました。。
(手を抜いて具象クラスを直接覗きに行ってしまった... :innocent: )

HitEventDispatcherSystem.cs
    protected override void OnUpdate()
    {
        var transforms = _playerHybridQuery.GetTransformAccessArray();

        Entities.With(_playerHitEventQuery).ForEach((
            Entity entity,
            ref PlayerHitEventState state) =>
        {
            for (int i = 0; i < transforms.length; i++)
            {
                // HACK: 具象クラスを直接見に行かないほうが良いかも
                var authoring = transforms[i].gameObject.GetComponent<HitReceiverComponent>();
                authoring.OnCollisionHit(state.HitPosition, state.BulletType);
            }

            EntityManager.RemoveComponent<PlayerHitEventState>(entity);
            EntityManager.DestroyEntity(entity);
        });
    }

EntityのPrefab Workflowについて

現在のECSには「GameObjectからECSに変換するための機構」が幾つか用意されてます。7
今回の運用ではそれを基にして「一部のEntityを従来のPrefab Workflowで管理する」と言った実装を行いました。
それをどの様に管理したのかについて簡単にまとめていきます。

ちなみに管理方法自体は「DOTS-Shmup3D-sample」と言うプロジェクトにある各種マネージャークラスによるPrefabEntityの管理を参考にさせていただきました。
→ e.g. BeamManager

※ 本題に入る前に...Prefabと言う単語が入り混じって分かりづらいので...名称に関しては以下の呼び分けで解説します。

  • GameObjectベースのPrefab → NestedPrefabと表記
  • EntityベースのPrefab → PrefabEntityと表記

NestedPrefabにアタッチするオーサリングコンポーネントを定義

今回のプロジェクトではこちらは「ECSレイヤー」にて定義してます。

定義するオーサリングコンポーネントの単位としては、「1つのオーサリングコンポーネントを1つのアーキタイプとして定義」するようにしました。(故にConvert内では複数のComponentDataをAddしている)

最初の頃は「1つのオーサリングコンポーネントを1つのComponentDataに対応させる形で定義」してましたが...コードベースでこちらのほうがPrefabEntityが持つアーキタイプの見通しが良かったために意図的にこうしてます。
※ この管理単位については検討事項であり、以後のアップデートを踏まえた所感を後述。

DamageBulletAuthoring.cs
/// <summary>
/// 光弾のオーサリングコンポーネント
/// </summary>
[RequireComponent(typeof(ECSSphereColliderComponent))]
sealed unsafe class DamageBulletAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    [SerializeField] ECSSettings _settings = default;
    [SerializeField] Bullet _bullet = default;
    ECSSphereColliderComponent _sphereColliderComponent;

    void IConvertGameObjectToEntity.Convert(
        Entity entity, EntityManager dstManager,
        GameObjectConversionSystem conversionSystem)
    {
        dstManager.AddComponentData(entity, new BulletType {Value = _bullet});
        dstManager.AddComponentData(entity, new BarrageConfig
        {
            ConfigPtr = _settings.NativeBarrageConfig.GetUnsafePtr,
        });

        _sphereColliderComponent = GetComponent<ECSSphereColliderComponent>();
        var sphereCollider = _sphereColliderComponent.GetComponentData;
        dstManager.AddComponentData(entity, sphereCollider);

        // non Serialize ComponentData.
        dstManager.AddComponentData(entity, new Destroyable());
        dstManager.AddComponentData(entity, new Angle());
        dstManager.AddComponentData(entity, new SphericalCoordinates());
    }
}

これをEntityに変換する想定のNestedPrefabにアタッチして各種パラメータを設定します。
ポイントとしてはこのPrefab自体にはConvertToEntityはアタッチしません。

Art000.png

※補足: 描画周りの設定について (クリックで展開)

描画周りの設定については従来通りMeshRendererコンポーネントに設定します。

こちらが適切に設定されていればECSの描画パッケージであるHybridRendererNestedPrefabをPrefabEntityに変換する際に自動的に変換をかけてくれます。
※ただしSkinnedMeshRendererは対応していないので注意。最新である0.2.0以降からは対応しているっぽいが、恐らくはDOTS Animationと呼ばれる機能が追加されるまでは無意味説がある...?

PrefabEntityへの変換

一通り設定の終えた変換対象のNestedPrefabを実際のPrefabEntityに変換するのは、「ECS.Providerレイヤー」に所属するPrefabProviderと言うクラスが担当します。

こちらは以下のような役割を持つクラスとなります。

  • Inspectorから変換対象のNestedPrefab及び参照用のIDを登録
  • DeclareReferencedPrefabsPrefabEntityとして登録
  • ConvertでEntityに変換し、変換したPrefabEntityを保持
PrefabProvider.cs
namespace Main.ECS.Provider
{
    [RequiresEntityConversion, DisallowMultipleComponent]
    sealed class PrefabProvider : MonoBehaviour, IDeclareReferencedPrefabs, IConvertGameObjectToEntity
    {
        [Serializable]
        class PrefabDetail
        {
            public int ID = default;
            public GameObject Prefab = default;
        }

        [SerializeField] PrefabDetail[] _prefabDetails = default;
        public Dictionary<int, Entity> PrefabEntities { get; } = new Dictionary<int, Entity>();

        public void DeclareReferencedPrefabs(List<GameObject> gameObjects)
        {
            foreach (var detail in _prefabDetails)
            {
                gameObjects.Add(detail.Prefab);
            }
        }

        public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
        {
            foreach (var detail in _prefabDetails)
            {
                PrefabEntities.Add(detail.ID, conversionSystem.GetPrimaryEntity(detail.Prefab));
            }
        }
    }
}

PrefabProvider自体もNestedPrefabで管理

PrefabProvider自体は以下のようにNestedPrefabにアタッチする形で管理してます。
そして先程は変換対象のPrefabには付けていなかったConvertToEntityはこちらにアタッチします。

そして設定が完了したPrefabProviderConvertと言った変換フローを呼び出させるために、今回の実装ではシーン中に配置してあります。

PrefabEntityの管理と利用

管理対象となるPrefabは_prefabDetailsと言うフィールドに「変換対象のNestedPrefab」と「変換後のPreafabEntity取得用のID」を合わせて設定していきます。

prefabprovider_.png

最後に設定が完了したPrefabProviderの取得及び呼び出しを行うのが、同じく「ECS.Providerレイヤー」に所属するECSProviderです。

ECSProvider.cs
namespace Main.ECS.Provider
{
    [RequireComponent(typeof(PrefabProvider))]
    public sealed class ECSProvider
    {
        enum BulletIndex
        {
            Drumming = 0,
            Damage = 2,
            Banana = 3,
        }

        const int FruitfulBananaIndex = 1;

        readonly EntityManager _entityManager;
        readonly PrefabProvider _prefabProvider;

        public ECSProvider()
        {
            // Get PrefabProvider Reference
            // HACK: もう少しスマートな取得方法があるかも...
            _prefabProvider = GameObject.FindObjectOfType<PrefabProvider>();
            _entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        }

    ....
}

前述のコード中にPrefabEntityを取得している箇所がチラホラと出てきましたが、こちらは今回のフローで変換したものを取得する形になってます。

ECSProvider.cs
/// <summary>
/// バナナの木から「光弾 or バナナ」を生成
/// </summary>
/// <param name="position">バナナの木の位置</param>
/// <param name="isBanana">trueならスコアバナナを生成</param>
public void CreateCircleBullet(in float3 position, bool isBanana)
{
    // ベースとなるPrefabEntityを取得
    var prefabEntity = _prefabProvider.PrefabEntities[(int) (isBanana ? BulletIndex.Banana : BulletIndex.Damage)];

検討事項

ざっと解説しましたが、個人的にはこの管理方法についても幾つか検討の余地があると感じてます。
(もう少しスマートに管理できないものか。。。)

オーサリングコンポーネントの単位

今回の例では「1つのオーサリングコンポーネントを1つのアーキタイプとして定義」する様な形で実装しましたが、他の例などを見ていると「1つのオーサリングコンポーネントを1つのComponentDataに対応させる形で定義」が多い?様に見受けられました。

裏付ける話として、Entitiesの最新である0.2.0以降からは今年のUniteなどで発表もされていた「オーサリングコンポーネントを簡単に書くための属性」に対応しており、機能的に見ても後者の「1オーサリングコンポーネント - 1ComponentData」が思想的には正しいのかもしれません。

オーサリングコンポーネントとComponentDataが1対1の関係になったら、恐らくはPrefabにアタッチされているオーサリング用コンポーネントがArchetypeになるかと思われる...?

オーサリングコンポーネントの所属レイヤーについて

今回はPureECSで使うPrefabEntity用のオーサリングコンポーネントは「ECSレイヤー」で管理し、HybridECSで使うオーサリングコンポーネントは「ECS.Provider.Hybridレイヤー」で管理すると言った棲み分けにしてますが、個人的に引っかかる点としては「ECSレイヤーでUnityEngine.MonoBehaviourを参照していた」点があります。

これは言い換えるとエンジンコードと結びついてしまっていることを指しており、完全なピュアC#なクラスとして分けていくのであればもう少しレイヤーを細かく分けても良いかな〜と思いました。
(ここらについてはEntitiesパッケージの分け方に倣うのが良いのか...?)

最後に

以上が今回実装したゲームの設計になります。

とは言えども検討事項はまだまだ残っていると考えてます。
例えば気になるポイントとしては「ECSレイヤー」がUnityEngineと完全に切り離せていない8ので、ECSレイヤーにComponentSystemを直に触れるMonoBehaviourを実装したら今回防ごうと意識していたポイントも防げなかったりします。。

ここらについては最新である0.2.0以降から幾つか切り離せそうな機能が追加されていたので、今後のアップデートに合わせて設計を変動させていくのも有りかと思いました。
→ 例えばUnityEngine.Timeに依存せずに時間を取得することが可能になったり

この設計は正しい?

冒頭にも書いたとおり、今回のこの設計が「正解」だとは思ってません。

ひょっとしたら実現する内容によっては遠回りな形式にもなり得るかもしれませんし、以前書いたやり方でも十分な可能性もありえます。
※例えば「意図的にMonoBehaviourからWorld.Active経由でComponentSystemを引っ張ってきて状態を変更させたい」場合や「ComponentSystemそのものにイベントを持たせてMonoBehaviour側で購読したい」場合など。

何が正しいのかについては明確な答えが存在しない分野かと思われるので、今回の設計自体はあくまで参考の一例程度に留めていただけると幸いです。(ECSがまだpreviewだという事も踏まえつつ)

付録: ECSレイヤーで使用するunsafeなScriptableObjectの管理について

ECS側のパラメータ調整に関する話です。

例として「光弾/スコアバナナ」と言ったECS側で制御されている処理も、既存の作りと同じくScriptableObjectを用いてバランス調整してます。

ecs_settings.png

ただ、ECSのIComponentDataに持たせる際には参照型のままだと都合が悪く、Blittable型を満たす必要が出てくるのでそのまま渡すことが出来ません。

今回はこれをどうやって渡したのかについて解説します。

アンマネージドメモリに確保してポインタでやり取り

結論から言うと「ScriptableObjectに持たせるパラメータをBlittableな構造体として定義し、確保したアンマネージドメモリにコピー → ポインタを必要とするデータに渡して間接参照」と言った流れになります。9

実装としては以下のようにScriptableObject内で「定義したパラメータのメモリ確保/解放」を出来るような形にしてます。

ECSSettings.cs
public sealed class ECSSettings : UnmanagedScriptableObjectBase
{
    public NativeObject<BarrageConfig> NativeBarrageConfig => _nativeBarrageConfig;

    // Blittableな構造体として定義
    [Serializable]
    public struct BarrageConfig
    {
        [Serializable]
        public struct DrummingParams
        {
            public int BurstCount;
            public int DefaultRadius;
            public float RotationSpeed;
            public float BulletSpeed;
            public float Lifespan;
        }

        [Serializable]
        public struct AimingParams
        {
            public int BurstCount;
            public float BulletSpeed;
            public float ConeRadius;
            public float ConeAngle;
            public float Lifespan;
        }

        [Serializable]
        public struct CircleParams
        {
            public int BurstCount;
            public float BulletSpeed;
            public float2 PitchRange;
            public float PitchSpeed;
            public float YawSpeed;
            public float Lifespan;
        }

        public DrummingParams Drumming;
        public AimingParams Aiming;
        public CircleParams Circle;
        public float KillLineY;
    }


    [SerializeField] BarrageConfig _barrageConfig = default;

    NativeObject<BarrageConfig> _nativeBarrageConfig;


    // ScriptableObject内でメモリ確保/破棄を行う
    public override void Initialize()
    {
        _nativeBarrageConfig = new NativeObject<BarrageConfig>(Allocator.Persistent, _barrageConfig);
    }

    public override void CallUpdate()
    {
        _nativeBarrageConfig.Value = _barrageConfig;
    }

    public override void Dispose()
    {
        _nativeBarrageConfig.Dispose();
    }
}

メモリの確保と解放は自動で呼び出し

上記のScriptableObjectのメモリ確保/解放の呼び出しについては、以下のUnmanagedScriptableObjectLifecycleと言うクラスを経由して自動的に呼び出されるようにしてます。

自動的に呼び出す仕組みとしては(若干力技感ありますが...)RuntimeInitializeOnLoadMethod属性にて自動生成できるようにしてます。10

UnmanagedScriptableObjectLifecycle.cs
sealed class UnmanagedScriptableObjectLifecycle : MonoBehaviour
{
    const string PrefabPath = "Prefabs/Systems/" + nameof(UnmanagedScriptableObjectLifecycle);

    [SerializeField] UnmanagedScriptableObjectBase _scriptableObject = default;

    // Resources以下より自身を拾ってきて自動生成
    [RuntimeInitializeOnLoadMethod]
    static void Bootstrap()
    {
        var prefab = Resources.Load<GameObject>(PrefabPath);
        var instance = Instantiate(prefab);
        instance.hideFlags = HideFlags.HideInHierarchy;
        var lifecycle = instance.GetComponent<UnmanagedScriptableObjectLifecycle>();
        lifecycle.Initialize();
    }

    void Initialize() => _scriptableObject.Initialize();

#if UNITY_EDITOR
    void Update() => _scriptableObject.CallUpdate();
#endif

    void OnDestroy() => _scriptableObject.Dispose();
}

Updateを呼び出す理由

UNITY_EDITOR定義時のみUpdateを呼び出すようにしている理由としては、実行中にInspectorからの操作による動的な値変更を反映できるようにするためです。

後述のIComponentDataなどに渡すポインタは「コピーした値」でしかなく、そのまま使うだけだとInspectorからの動的な変更は反映されません。

それを反映出来るように開発用の機能としてEditor時のみScriptableObject側から毎回値を上書きして反映できるようにしてます。
※前提として、このプロジェクトでは実行中にScriptableObjectの値を動的に変更する想定が無い。

ECSSettings.cs
public sealed class ECSSettings : UnmanagedScriptableObjectBase
{
    .....

    // ScriptableObject側ではUpdate時に毎回値を上書きしている
    public override void CallUpdate()
    {
        _nativeBarrageConfig.Value = _barrageConfig;
    }

    .....
}

オーサリングコンポーネント経由でIComponentDataにポインタを渡して参照

上記で設定したメモリのポインタを渡しているのはPrefabEntityのオーサリングコンポーネントとなります。

以下のようにオーサリングコンポーネントのInspectorから設定されたScriptableObjectを経由してIComponentDataに渡します。

DamageBulletAuthoring.cs
sealed unsafe class DamageBulletAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    [SerializeField] ECSSettings _settings = default;
    [SerializeField] Bullet _bullet = default;
    ECSSphereColliderComponent _sphereColliderComponent;

    void IConvertGameObjectToEntity.Convert(
        Entity entity, EntityManager dstManager,
        GameObjectConversionSystem conversionSystem)
    {
        ....

        // ここでIComponentDataにポインタを渡す
        dstManager.AddComponentData(entity, new BarrageConfig
        {
            ConfigPtr = _settings.NativeBarrageConfig.GetUnsafePtr,
        });

        ....
    }

IComponentDataとしては以下のような実装となります。

BarrageConfig.cs
public unsafe struct BarrageConfig : IComponentData
{
    public ECSSettings.BarrageConfig* ConfigPtr;

    public ECSSettings.BarrageConfig Config => *ConfigPtr;
}

後はこちらを必要とするComponentSystemで取得して参照するだけです。

※補足

今回は上述のようなポインタ経由で共通値を参照しましたが、他にもやり方は色々検討できるかと思います。
最後に幾つかをまとめます。

  • ISharedComponentData
    • 実はあまり使ったこと無いので何とも言えず...
    • ドキュメント曰く値は変更される想定のないものらしいが...ScriptableObject自体の参照を変えなければ使える..?
  • Managed IComponentData (Entities-0.2.0以降より)
  • staticなフィールド
    • Burstが使えなくなるので注意

参考/関連サイト

DOTS基礎

DOTS設計

ドキュメント

  • Entities | 0.3.0-preview.4
    • ※現時点における最新版のドキュメント。 バージョンによってURLが変わってくるので注意

  1. 結局は開発に1週間以上掛かってしまったために、イベント自体には参加していないという... :innocent:  

  2. DOTS・・・Data-Oriented Technology Stackの略。「NativeContainer」「JobSystem」「ECS」「BurstCompiler」と言ったデータ指向型の総称と言う認識 

  3. 実は以前にも同様の話題に関する記事を書いたことがあるのですが、こちらは連携周りこそ考慮すれど...MonoBehaviourとECSが同じレイヤーに混在していて少しカオスな感じとなっていたので、ここらの反省点を踏まえつつ検討し直した内容となります。 

  4. 自分も最初はこんなイメージだった... 

  5. 具体的に言えば必要に応じてこれ以外のADFも参照している。この図中ではそれらの表記は割愛 

  6. コメントにも記載しているが...実行終了時に先にECS関連から破棄されるためか挙動が怪しくなる減少を確認...実装として微妙かもしれないが...終了時に例外を握りつぶすような形にしている... 

  7. 例えばConvertToEntityとかSubSceneとか 

  8. これを切り離すため?にADFには「No Engine References」と言う項目がある 

  9. 実は以前書いた記事にある「パラメータ調整について」とほぼ同じ。ただし管理方法を少し変えている 

  10. 一応ScriptableObectにもAwakeOnDestroyと言ったイベントがありますが...呼び出されるタイミングが不明確だったのでこのように明示的に呼び出されるようにしました。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

uGUIから読み解くdelegate

uGUIから読み解くdelegate

UnityでButtonのクリック時のコールバックメソッドをスクリプトから定義する時

button.onClick.AddListener(callbackMethod);

のような感じで書くと思います。調べると今まで曖昧だったデリゲートと関連することが分かったので、調べたことを記事にしたいと思います。

記事を書こうと思ったら、すでに模範解答のような記事を見つけました。
良記事なので、そちらを参照してみても良いかもしれません。

学び後の理想の状態

  • 「Delegateって何ですか?」と聞かれた時に、しっかりと答えることができる
  • UnityActionとは何かについて理解している

現状

  • そもそもDelegateとは何かよく分かっていない
  • uGUIのコールバックメソッドのスクリプトからの登録の仕方は分かっているけど、本当のところどういう仕組みで動いているのか分かっていない。

AddListener()とは

AddListenerはUnityEventクラスのパブリックメソッドで

public void AddListener(Event.UnityAction call)

のように定義されています。ランタイムコールバックの追加をこの関数はしています。

注目すべきは引数にUnityAction型のコールバックメソッドを指定しているところです。
一体UnityActionとは何なのでしょうか。

UnityActionとは

ではUnityActionの定義を見てみましょう。

public delegate void UnityAction();

となっています。つまり戻り値、引数なしのdelegateにすぎないと分かります。
UnityActionを理解するにはデリゲートについて理解していないとだめですね。

デリゲートとは

デリゲートの定義

まずは言葉の定義から。Microsoft公式ドキュメントより

デリゲートは、特定のパラメーター リストおよび戻り値の型を使用して、メソッドへの参照を表す型です。

デリゲート自体はあくまでメソッドへの参照を表す型。
1つ簡単な例をあげておきます。

簡単な例
using System;

namespace delegateTest
{
    class Program
    {
        // 1. まずはデリゲートを定義します。(デリゲートはあくまでメソッドへの参照を表す型です)
        public delegate void SimpleCalc(int a, int b);

        // SimpleCalcデリゲートと同じ戻り値の型とパラメーターリストであること関数を用意
        static void Add(int a, int b) 
        {
            Console.WriteLine($"{a} + {b} = {a + b}");
        }
        static void Subtract(int a, int b) 
        {
            Console.WriteLine($"{a} - {b} = {a - b}");
        }

        static void Main(string[] args)
        {
            // 2. デリゲートをインスタンス化。同じ戻り値の型、パラメーターリストを持つ関数を代入
            SimpleCalc del_calc = Add; 
            // 3. デリゲートインスタンスを通じて関数(この場合Add)を呼び出す
            del_calc(20, 10); 

            // 2, 3繰り返す
            del_calc = Subtract;
            del_calc(20, 10);
        }
    }
}
結果
20 + 10 = 30
20 - 10 = 10

定義したデリゲートをインスタンス化する時、同じ戻り値の型、引数リストを持つ関数を参照することができ、参照された関数はそのデリゲートインスタンスを通して呼び出されるということですね。他にもデリゲートはクラスメソッド、インスタンスメソッドのどちらも参照することができます。
また便利な機能として、マルチキャストデリゲートと言って複数のメソッドを代入することができます。

デリゲートの使い所

どんな状況でデリゲートの力が発揮されるのか分かれば、デリゲートへの理解も深まると思います。(実際、僕自身デリゲートをわざわざ使う理由が中々分かりませんでした。)

「++C++; // 未確認飛行 C」のデリゲートの利用例を参考にすると、

  • 述語(条件式を外から挿す)
  • コールバック(非同期処理の終了通知)
  • イベント処理

などが主な利用例だそうです。今回理解したいUnityActionがイベント処理に用いられているのもここからわかりますね。後のサンプルを見ると、よりデリゲートのイメージが湧くかもしれません。

結局デリゲートとは

デリゲートを引数や戻り値として用いることで、デリゲートを通して関数をもっと自由に扱えるようになる(関数を呼ぶタイミングを調整できたり、実行する関数を状況に応じて変更できたり)ことがデリゲートを使う利点なのではないでしょうか。

サンプル

サンプルとして、ボタンを押したらwebから画像を引っ張ってきて、それをシーン上のimageUIに貼り付けるということをしてみます。

シーンにimageとbuttonを用意します。
スクリーンショット 2019-12-02 0.03.24.png

canvasに以下のUIController.csというスクリプトを貼り付けます。

UIController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
public class UIController : MonoBehaviour
{
    [SerializeField] string imagePath;
    public Button button;
    public Image image;

    private void Start() 
    {
        button.onClick.AddListener(OnButtonClicked);
    }

    private void OnButtonClicked() 
    {
        StartCoroutine(LoadImage(imagePath, DisplayImage));
    }

    private void DisplayImage(Texture2D tex2D) 
    {
         Rect rect = new Rect(0f, 0f, tex2D.width, tex2D.height);
         image.sprite = Sprite.Create(tex2D, rect, Vector2.zero);
    }

    public delegate void ImageProcessing(Texture2D texture);
    IEnumerator LoadImage(string _imagePath, ImageProcessing _callback) 
    {
        UnityWebRequest request = UnityWebRequest.Get(_imagePath);
        yield return request.SendWebRequest();

        if (request.isNetworkError) 
        {
            Debug.LogError(request.error);
        }
        else 
        {   
            Texture2D tex2D = new Texture2D(400, 200);
            tex2D.LoadImage(request.downloadHandler.data);

            _callback(tex2D);
        }
    }
}

まずは、

button.onClick.AddListener(OnButtonClicked);

UnityAction型のデリゲートにコールバック関数としてOnButtonClickedを渡す。
そうすることでボタンがクリックされた時に、OnButtonClickedが呼ばれることになります。

結果
スクリーンショット 2019-12-02 0.57.06.png

特に解説するようなことは何も書いていないのですが、LoadImage関数の引数を見ると、ここでも自分で定義したデリゲートを使用しています。

// public delegate void ImageProcessing(Texture2D texture)
IEnumerator LoadImage(string _imagePath, ImageProcessing _callback) 

第二引数のコールバック関数には、戻り値の型と引数リストさえ合っていれば良いので、

private void Save(Texture2D texture) 
{
    File.WriteAllBytes(Application.dataPath + "/savedImage.png", texture.EncodeToPNG());
}

のような画像を保存するような関数を渡したりもできますね。

少し発展

AddListenerで登録したOnButtonClicked()関数なのですが、

private void OnButtonClicked() 
{
    StartCoroutine(LoadImage(imagePath, DisplayImage));
}

わざわざコルーチンを実行するためだけに関数を定義したくないというのが本音です。

そこで匿名関数(匿名メソッドとラムダ式があるが、今回はラムダ式)を用いると、インラインで処理内容を記述できるようになります。

button.onClick.AddListener( () => StartCoroutine(LoadImage(imagePath, DisplayImage)) );

匿名関数、ラムダ式に関しては【LINQの前に】ラムダ式?デリゲート?Func?な人へのまとめ【知ってほしい】を参照してくだされば分かるかと。

また、LoadImage関数でも

// public delegate void ImageProcessing(Texture2D texture);
IEnumerator LoadImage(string _imagePath, ImageProcessing _callback) 

と引数にImageProcessingというデリゲートを指定していますが、このためだけにデリゲートを作るのもあまり格好が良いとは言えませんし、そもそもデリゲートって型なので名前をつけるのちょっと難しいんですよね。今回それを解決してくれるのが、 Action<T>型のデリゲートです。(Func, Action系のデリゲートに関しても先ほどのリンクをみていただけるとわかります。)

Action<T>型を用いて変更すると、

using System;

// デリゲートをわざわざ定義しなくて良い
IEnumerator LoadImage(string _imagePath, Action<Texture2D> _callback) 
{
}

これまたスッキリ。

まとめ

今回はbuttonを扱いましたが、sliderとかでも基本一緒だと思います。
デリゲートはまだまだ奥が深く勉強しがいがありそうです。以下のことを今後調べたいと思います。

  • Func, Action系デリゲート
  • 匿名関数
  • LINQ

Qiita書いたのほぼ初めてだったのですが、学んだことを言語化して記事にするのって中々大変なのだなと感じました。
いつも読むばかりだったので、これからは継続的に学んだことのアウトプットとして記事を書いていけたらなと思います。

最後の方は、体力切れてしまったので、少しあやふやな部分が多いです。もし誤り等あればご指摘お願いします。また、以下の観点からコメント頂けると嬉しく思います!

  1. 「デリゲートの使い方、こんなのあるよ!」
  2. 「デリゲート周りだと、〜とか調べてみたら面白いよ!」

参考資料

以下今回調べるに当たってお世話になった参考資料です。
ありがとうございました。インターネット万歳。

(MSDN)
Delegate Class
Delegate(C# Programming Guide)
MulticastDelegate Class
(++C++; // 未確認飛行 C)
デリゲート
デリゲートの利用例
デリゲートの内部
(Qiita)
【LINQの前に】ラムダ式?デリゲート?Func?な人へのまとめ【知ってほしい】
(Unity Documentation)
Unity Action
Button.onClick
UnityEvent.AddListener

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Effekseerを使ってVキャスに雨と雪を降らせてみた。

どうも、猫月遥歩(ねこづきあゆむ)です。

先日、「天候VCI」っていうものを作ったので、その話をします。
作った環境が前のPCなので、スクショとか少ないです;;

この記事は VCIアドベントカレンダー2019 9日目の記事です。

ちなみに、本作品で利用したUniVCIはv0.22です。

天候VCIについて

Effekseerで作成した、「雨」と「雪」のエフェクト(?)を表示するVCIです。
出てくるCubeをグリップ(onUse)すると、雪←→雨が切り替わります。

https://seed.online/items/a066e943223a6fa6c1e6ffec5b73321fcc39a69f963a0f88c03e2ec56f365f5d

利用イメージはこんな感じ。

image.png

image.png

スクショだとわかりにくいと思うので、動画でどうぞ
 天候VCI 利用イメージ - ニコニコ動画

Effeksserでの設定

Effekseer、ほとんど触ったことなくて、公式チュートリアルみながら試行錯誤しつつやってました。

公式チュートリアルの、「一点で生成したものを分散移動させる」というのを応用して、
「Y軸一定、XZ軸は10の範囲内からランダムで生成、Y軸マイナス方向に移動させる」的な感じでいじったらうまくいきました。
image.png

課題(うまくいかなかったこと)

初期設定では、表示時間(?)的なものが0~100ぐらいで設定されていて、降り始めたと思ったらすぐに止んでしまう

 →最大値を最大(限界)まで伸ばして長く表示するようにしたが、一定時間(10分ぐらい?)立つと限界が来て止んでしまう

 →雪の降り始めが遅い(落下スピードの関係)ので最小値を1000ぐらい(ちょうどアバター位置で舞うぐらい)で設定したのに、VCIとして出力すると反映されない

 →うまく調整できなかったので無視して実装中。後々修正したい。

image.png

Unityでの設定

雨・雪を切り替えるためのスイッチとなるCube、雨・雪のエフェクトの入ったコンポーネントを入れるためのEnpty2つを利用しました。

課題(うまくいかなかったこと)

Effekseerで作成したファイルを入れるためのコンポーネントEffekseer Emitterに入っているIs Looping(ループ再生)がVCIとして出力すると反映されない

 → Luaスクリプトで代用(後術)

Luaスクリプト

こんな感じで実装。
一応、GitHubにもあります。
https://github.com/AyumuNekozuki/VCI_Lua/blob/master/weather.lua

weather.lua
print("天候VCI 読み込みました")

local eff_snow = vci.assets.GetEffekseerEmitter("snow")
local eff_rain = vci.assets.GetEffekseerEmitter("rain")

local toggle = true

function onUse(use)
    --switch_use toggle反転
    print("グリップされました")
    if use == "switch" then
        toggle = not(toggle)

        if toggle == true then
            --stop rain
            eff_rain._ALL_Stop()
            --play snow
            eff_snow._ALL_SetLoop(true)
            eff_snow._ALL_Play()
            print("天候 : 雪")
        end

        if toggle == false then
            --stop snow
            eff_snow._ALL_Stop()
            --play rain
            eff_rain._ALL_SetLoop(true)
            eff_rain._ALL_Play()
            print("天候 : 雨")
        end
    end
end

公式wikiで紹介されていた、toggleを使ったやり方で実装しました。
最初VCIを出したとき(初期値)ではtoggletrueにしておき、グリップすると反転(false)になりそれで挙動を変えます。

toggleが変わると、再生されているエフェクトを止め、自動ループオンの状態で再生するように設定しています。
上述していた、Unity側での設定が効かない分、こちらのスクリプトで自動ループをオンにしています。

ちなみに、GitHubからみてもらえると分かるんですが、キーボードでも操作できるようにしました。
ただ、これ。VCIの現状の欠陥(?)で、ほかのVCIでキーボード使ってると勝手に変わっちゃうんですよね。(使えるキーが限られているのでどうやっても被る)

全体の課題

Effekseerでの"エフェクト"に過ぎないので、当たり判定がありません。なので、傘VCIとかを出しても、貫通しますw
なんとかして改善したいんですが、いまいち方法が思い浮かばず....

作成して数日、使ってくださった方からフィードバックを頂きまして、やはり一番多いのが「雪の降り始めが遅い」。
いろいろと試行錯誤しつつ改善したいです。

さいごに

いろいろと試行錯誤してぱぱっと修正かけたいところなんですが、自身のPCが変わりまして、Unity等インストールし直しな状況で...
少し待っていただけると幸いです。クリスマスまでにはなんとかしたい!!w

なかなかにグダグダな記事ですみませんでした。

VCIアドベントカレンダー2019 明日以降もありますのでぜひ御覧ください。
 https://qiita.com/advent-calendar/2019/vci

他のアドカレにも参加しているので良かったら御覧ください。
 N高アドカレ(本日公開):https://qiita.com/advent-calendar/2019/n-highschool
 Vキャスアドカレ(生放送 11日 22:00~):https://adventar.org/calendars/4432

SpecialThanks

  • ニコ生リスナーの皆様
  • VCIを利用いただいた皆様
  • フィードバック送ってくださった皆様
  • 以下参考文献製作者の皆様

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CleanArchitectureでひとつ『上』 のコードを目指す:テスト編

前回の記事(実装編)からの続きになります。
Unity(C#)で私なりにCleanArchitectureの実装例を説明しました。

CleanArchitectureのルールに従って、アプリケーションロジックからフレームワークを抽象化して切り離すことで、特定のフレームワークに依存しないアプリケーションロジックを実装することが出来ます。
つまり、フレームワークが無くても(≒決まってなくても)アプリケーションロジックのみを実装することが出来、フレームワークの都合を抜きにしてテストすることが出来る訳です。

この記事ではUnityのシーンに実装したアプリケーションロジックが正しい仕様で動いているかをTest RunnerのPlay Modeテストする方法を解説します。

  • Play Modeでのテスト実行がシーンに反映されている様子です。 bmi.gif

GitHub:naninunenoy/UnityViewPatterns/BMIApp

おさらい

UIの実装を View に落とし仕込み、アプリケーションロジック( UseCase )からは View を直接参照させるのではなく、Presenter という中間層を定義し、それを介してUIの操作(ボタンのイベント受信やテキストの変更など)を行っていました。

image.png

同様にデータの入出力(保存/読み来み)やログイン処理などでも UseCase からの利用を中間層を介してやることで、フレームワーク(詳細)にとらわれないアプリケーションロジックの実行が可能になります。これにより、クライアント側で一時的なデータ保存の実装を用意してやれば

「データを保存するバックエンドが用意できていないからクライアント側の実装が進められない」

という状況にも対応できますし、テスト用のログイン実装を用意すれば

「ログイン画面のテストが通信状況の良し悪しで結果が変わってしまう」

いった問題に対応できます。

DI

※DI(dependency injection): 依存性の注入

肝になる考え方は、CleanArchitectureによってアプリケーションロジックである UseCase が詳細(UIやデータ保存や認証の方法)とは無関係でいられるので、製品コードとテストコードとの実行でそれら(詳細の実装)を切り替えてもアプリケーションロジックは問題ないということです。
本来のクラスの実装では内部変数やイベントが隠蔽されているので操作できない(良いことです!!)ところを、テスト用のクラスではそれらを外側(テスト実行のコード)から自由に操作できるようにし、操作した結果が画面やデータに反映されているかをテストすればOKという訳です。

または、実装が特定のフレームワークに依存してしまっているので、依存せずテストに都合のいいクラスをテスト用に用意するなどの選択肢があります(こっちが本来の恩恵かも)。

このために、実際とテストとの実行で UseCase にinterfaceで渡される中間層のクラスをDIで切り替える必要があります。

Zenject

Zenject(または Extenject)はDIのフレームワークですがテストによる実行もカバーしており、自動テストのための解説も載ってます。 自分でGoogle翻訳したやつ

その中に SceneTestFixtureというものがあります。本来はシーンのロードがエラー(例外)なく行えるかをテストするもののようですが、こいつでPlayModeテスト用のDIを行ってシーンを実行できないかを試してみました。

(結果的に実現できましたが、前提として準備しておくことが多く、既存のプロジェクトに後の載せで行うにはかなり厳しいと思います汗)

Main と Installer

実装の前提ですが、UseCase に渡す Presenter などは Installer で準備します。そして、準備された Presenter などの中間層の要素を Main で受けとって UseCase を作成/実行します。

BMISceneMain.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Zenject;
using BMIApp.CleanArchitecture;

namespace BMIApp.BMI {
    public class BMISceneMain : MonoBehaviour, ISceneMain {
        IUseCase bmiUseCase;
        IUseCase historyUseCase;
        IUseCase logoutUseCase;

        // Injectメソッドで準備された中間要素を受けっとってUseCaseを生成
        [Inject]
        void ConstructUseCases(IHistoryListPresenter historyListPresenter,
                               IBMIHistoryRepository historyRepository,
                               IBMIPresenter bmiPresenter,
                               IUserAccountRepository userAccountRepository,
                               IAccountPresenter accountPresenter) {
            historyUseCase = new HistoryUseCase(
                historyListPresenter, 
                historyRepository, 
                this);
            bmiUseCase = new BMIUseCase<BMIDataTransferObject>(
                bmiPresenter,
                historyUseCase as IPushHistoryDelegate,
                this);
            logoutUseCase = new LogoutUseCase(
                userAccountRepository,
                accountPresenter,
                this);
        }

        void Awake() {
            // run UseCase
            bmiUseCase.Begin();
            historyUseCase.Begin();
            logoutUseCase.Begin();
        }
    }
}

Installer はZenjectの MonoInstaller を継承したものであり、実際にアプリケーション実行のためのDIを行うものになります。こいつは中間層も詳細も両方知っておいてよい存在になります。[SerializeField] などでUnityの要素を受け取るのもこいつに集約させると良いでしょう。

BMISceneInstaller.cs
using UnityEngine;
using BMIApp.CleanArchitecture;

namespace BMIApp.BMI {
    // MainInstallerBaseは後で説明します
    public class BMISceneInstaller : MainInstallerBase {
        // inspectorからアタッチする
        [SerializeField] SharedScriptableObject sharedData = default;
        [SerializeField] BMIView bmiView = default;
        [SerializeField] HistoryView historyView = default;
        [SerializeField] HistoryElmView historyElmView = default;
        [SerializeField] AccountView accountView = default;

        // シーンの最初に呼ばれる。DIを行う。
        public override void InstallBindings() {
            base.InstallBindings();
            var dataStore = new PlayerPrefsHistoryDataStore(sharedData.CurrentUserId) 
                as IHistoryDataStore;
            Container
                .Bind<IHistoryListPresenter>()
                .FromInstance(new HistoryListPresenter(historyView, historyElmView))
                .AsCached()
                .IfNotBound();
            Container
                .Bind<IBMIHistoryRepository>()
                .FromInstance(new BMIHistoryRepository(dataStore))
                .AsCached()
                .IfNotBound();
            Container
                .Bind<IBMIPresenter>()
                .FromInstance(new BMIPresenter(bmiView))
                .AsCached()
                .IfNotBound();
            Container
                .Bind<IUserAccountRepository>()
                .FromInstance(new UserAccountRepository(sharedData))
                .AsCached()
                .IfNotBound();
            Container
                .Bind<IAccountPresenter>()
                .FromInstance(new AccountPresenter(accountView))
                .AsCached()
                .IfNotBound();
        }
    }
}

Installer でDIされた実装が Main に渡り UseCase の材料になって実行されるわけです。

テストでの実装

Installer でDIされた実装が Main に渡り UseCase の材料になって実行されるわけです。

つまり、テストではテスト用のDIを事前に行った状態でPlayModeテストでシーンを読み込めばテストのためのシーン実行が出来る訳です。

BMISceneTest.cs
using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEditor;
using Zenject;
using BMIApp.CleanArchitecture;
using BMIApp.BMI;

namespace BMIApp.Tests.PlayMode {
    public class BMISceneTest : SceneTestFixture {
        const string sceneName = "BMI";

        BMITestPresenter bmiPresenter = new BMITestPresenter();
        HistoryListTestPresenter historyPresenter = new HistoryListTestPresenter();
        AccountTestPresenter accountPresenter = new AccountTestPresenter();
        UserAccountTestRepository accountRepository = new UserAccountTestRepository();
        BMIHistoryTestRepository historyRepository = new BMIHistoryTestRepository();

        SharedScriptableObject sharedData = default;
        TemporaryHistoryDataStore historyData = default;

        BMIView bmiView = default;
        HistoryView historyView = default;
        HistoryElmView historyElmView = default;
        AccountView accountView = default;

        void CommonInstallBindings() {
            StaticContext.Container
                .Bind<ITest>().To<Test>()
                .AsTransient();
            StaticContext.Container
                .Bind<IBMIPresenter>().FromInstance(bmiPresenter)
                .AsTransient();
            StaticContext.Container
                .Bind<IHistoryListPresenter>().FromInstance(historyPresenter)
                .AsTransient();
            StaticContext.Container
                .Bind<IAccountPresenter>().FromInstance(accountPresenter)
                .AsTransient();
            StaticContext.Container
                .Bind<IUserAccountRepository>().FromInstance(accountRepository)
                .AsTransient();
            StaticContext.Container
                .Bind<IBMIHistoryRepository>().FromInstance(historyRepository)
                .AsTransient();
        }

        void FindGameObjects() {
            // find
            var canvas = GameObject.Find("Canvas").transform;
            bmiView = canvas.Find("BMIView").GetComponent<BMIView>();
            historyView = canvas.Find("HistoryView").GetComponent<HistoryView>();
            accountView = canvas.Find("AccountView").GetComponent<AccountView>();
            // prefab
            var prefab = AssetDatabase.
                LoadAssetAtPath<GameObject>("Assets/BMIApp/Prefabs/HistoryElm.prefab");
            var historyElm = prefab.GetComponent<HistoryElmView>();
            // data
            sharedData = ScriptableObject.CreateInstance<SharedScriptableObject>();
            historyData = new TemporaryHistoryDataStore();
            // set
            bmiPresenter.InnerPresenter = new BMIPresenter(bmiView);
            historyPresenter.InnerPresenter = 
                new HistoryListPresenter(historyView, historyElm);
            accountPresenter.InnerPresenter = new AccountPresenter(accountView);
            accountRepository.InnerRepository = new UserAccountRepository(sharedData);
            historyRepository.InnerRepository = new BMIHistoryRepository(historyData);
        }

        void BeginMain() {
            GameObject.Find("SceneContext")
                .GetComponent<IMainInstaller>().SceneMainObject.SetActive(true);
        }

        [UnityTest]
        public IEnumerator BMI計算_保存_削除までの一連の操作() {

            CommonInstallBindings();
            yield return LoadScene(sceneName);
            FindGameObjects();
            BeginMain();

            // 最初は未入力
            Assert.IsEmpty(bmiView.NameInput.text);
            Assert.IsEmpty(bmiView.HeightInput.text);
            Assert.IsEmpty(bmiView.WeightInput.text);
            Assert.IsEmpty(bmiView.AgeInput.text);
            Assert.IsFalse(bmiView.GenderMaleToggle.isOn);
            Assert.IsFalse(bmiView.GenderFemaleToggle.isOn);
            Assert.That(bmiView.BMIText.text, Is.EqualTo("99(やせすぎ)"));
            Assert.IsFalse(bmiView.SaveButton.interactable);
            Assert.That(historyView.Content.childCount, Is.Zero);

            // 名前/身長/体重を入力すると[保存]が押せるようになる
            bmiView.NameInput.onEndEdit.Invoke("test_name");
            Assert.IsFalse(bmiView.SaveButton.interactable);
            bmiView.HeightInput.onEndEdit.Invoke("123");
            Assert.IsFalse(bmiView.SaveButton.interactable);
            bmiView.WeightInput.onEndEdit.Invoke("56");
            Assert.IsTrue(bmiView.SaveButton.interactable);

            // 計算されたBMIと評価が表示される
            Assert.That(bmiView.BMIText.text, Is.EqualTo("37.0(肥満)"));

            // [保存]を押すとリストに追加される
            bmiView.SaveButton.onClick.Invoke();
            yield return null;
            Assert.That(historyView.Content.childCount, Is.EqualTo(1));

            // 内容が 日時-名前-BMI
            var elm = historyView.Content.GetChild(0)?.GetComponent<HistoryElmView>();
            Assert.IsFalse(elm == null);
            Assert.That(elm.DateText.text, 
                Is.EqualTo(System.DateTime.Now.ToString("M/d")));
            Assert.That(elm.NameText.text, Is.EqualTo("test_name"));
            Assert.That(elm.BMIText.text, Is.EqualTo("37.0"));

            // 後から追加された方が上にくる
            bmiView.HeightInput.onEndEdit.Invoke("100");
            bmiView.WeightInput.onEndEdit.Invoke("1");
            bmiView.SaveButton.onClick.Invoke();
            yield return null;
            elm = historyView.Content.GetChild(0)?.GetComponent<HistoryElmView>();
            Assert.IsFalse(elm == null);
            Assert.That(elm.BMIText.text, Is.EqualTo("1.0"));

            // リポジトリにも追加されている
            Assert.That(historyData.Datas.Count, Is.EqualTo(2));

            // [クリア]でデータが消える
            historyView.ClearButton.onClick.Invoke();
            yield return null;
            Assert.That(historyView.Content.childCount, Is.Zero);
            Assert.That(historyData.Datas.Count, Is.Zero);

            yield return null;
        }
    }
}

さらっと(?)書いていますが、テスト実行のために解決すべきポイントがいくつかあったので解説します。

問題1

シーンがロードとされる前にDIするので SceneContext がまだ存在しないためBindできなかった

対応

StaticContext に設定できます。
しかし、StaticContext のBindよりも InstallerSceneContext に改めてBindされるものの方が優先されてしまうので、Installer でのBind全てに .IfNotBound() を設定します。

問題2

シーンがロードとされる前にDIしたい訳ですが、Presenter のコンストラクタには IView が必要であり View は シーン上のGameObject なのでシーンがロードするまで取得できない(テスト用に事前にDIするPresenter が生成できなかった)

対応

IPresenter を実装した TestPresenter を定義しました。

BMITestPresenter.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using BMIApp.BMI;

namespace BMIApp.Tests.PlayMode {
    public class BMITestPresenter : IBMIPresenter {
        public BMIPresenter InnerPresenter { set; get; }
        public IReadOnlyReactiveProperty<string> NameInput => InnerPresenter.NameInput;
        public IReadOnlyReactiveProperty<string> HeightInput => InnerPresenter.HeightInput;
        public IReadOnlyReactiveProperty<string> WeightInput => InnerPresenter.WeightInput;
        public IReadOnlyReactiveProperty<string> AgeInput => InnerPresenter.AgeInput;
        public IReadOnlyReactiveProperty<bool> GenderMaleSelect => InnerPresenter.GenderMaleSelect;
        public IReadOnlyReactiveProperty<bool> GenderFemaleSelect => InnerPresenter.GenderFemaleSelect;
        public IObservable<Unit> SaveButtonClickObservable => InnerPresenter.SaveButtonClickObservable;
        public void Begin() => InnerPresenter.Begin();
        public void SetBMIResult(string result) => InnerPresenter.SetBMIResult(result);
        public void SetSaveButtonEnable(bool enable) => InnerPresenter.SetSaveButtonEnable(enable);
    }
}

こいつはコンストラクタに IView を持たず、シーンがロードされてから BMIViewGameObject.Find() などで見つけてきて改めて BMIPresenter を生成し、.InnerPresetner に後で設定することが出来ます。動作は本来の BMIPresenter と同じ振る舞いをします。

問題3

IUseCase.Begin()Main.Awake() に書かれているので、シーンロード直後に問答無用で実行されるため上のように .InnerPresenter を設定する暇がなかった(設定しても実行された後なので無意味だった)

対応

Main.Awake() のを任意のタイミングで実行するためにトリックを仕込みます。
まず、Installer全てを MainInstallerBase が親になるように継承させ、その中でテスト実行かを判別し MainGameObject を非活性にするようにします。

MainInstallerBase.cs
using UnityEngine;
using Zenject;

namespace BMIApp.CleanArchitecture {
    public abstract class MainInstallerBase : MonoInstaller, IMainInstaller {
        [SerializeField] GameObject main = default;
        public GameObject SceneMainObject => main;

        public override void InstallBindings() {
            // Bindの内容でテストによる実行かを判断し、
            // テストの場合はmainをここで非活性化し、
            // テストから任意のタイミングでAwakeを呼べるようにする
            if (Container.HasBinding<ITest>()) {
                main.SetActive(false);
            }
        }
    }
}

非活性( gameObject.activeSelf==false )な GameObjectAwake() が実行されず、活性化したタイミングで Awake() が実行されるという Unity の仕様があります。Zenjectも内部的にこの仕組みを利用しているらしいです。1

そして、テストコードで Main を活性化すれば任意のタイミングで IUseCase.Begin() を呼ぶことが出来ます。BeginMain() がそれです。

あとは View であるuGUIをコードから任意に操作したり、シーンをロードする前に DataStore に任意の設定しておくなどして想定通りの挙動になっているかを Assert でチェックしていって下さい。

まとめ

テストコードでの流れは
1. テスト用のDIを StaticContext.Container に行う
2. 目的のシーンを読み込む
3. シーンからテストに必要な要素( View など)を取り出して準備をする
4. 前述のトリックを解除し、UseCase を実行する
5. 挙動が想定通りかどうかをテスト

になります。

テストの実現方法の説明にスペースを割きましたが、ここまで準備すれば外部に依存しているクラスを別のテスト用のクラスに入れ替えて、にUnity内の実装だけをテストすることが出来ます。

「折角 UseCase が inerface に依存するようになったんやし、シーンテストするときに詳細の実装を入れ替えられたらええんちゃうんちゃうん?」
みたいなノリでやり始めたのですが、結構つまづきポイントが多く、結局はテスト都合の実装を製品コードに埋め込む必要があるという結果になってしまいました。

それでもテストがあればデグレへの不安が大幅に減りますし、バグが見つかってもUnity側(クライアント側)で完結したテストが通っていれば原因切り分けの助けになるでしょう。

補足1

TestPresenter は通常の Presenter はコンストラクタで IView を受け取る仕様になっていて、しかも readonly で書き換え不可能なのために仕方なく生まれたもので、

外部に依存しているクラスを別のテスト用のクラスに入れ替えて、にUnity内の実装だけをテスト

という文脈とは本来的には無関係です。

補足2

Play Modeで[Run All]するとZenjectのテストでコケる場合があります。

TestScene (0.056s)
---
Zenject.ZenjectException : Assert hit! Cannot load scene 'TestSceneContextEvents' for test 'TestSceneContextEvents'.  The scenes used by SceneTestFixture derived classes must be added to the build settings for the test to work
---

エラーメッセージに書いてあるように、Scenes In Buildに TestSceneContextEvents シーンを追加してやれば通ります。
(Zenject/OptionalExtras/IntegrationTests/SceneTests/TestSceneContextEvents/ にあります)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Tilemapを使う人は2d-extrasを使ってみよう!

Unityでは、Unity 2017.2からTilemapが導入されました。このTilemapを使うことで、次の画像のようなマップチップを敷き詰めたマップがUnity標準機能だけで簡単に作れるようになりました。(画像はUnity公式の2d-techdemosのサンプルシーンより)

map例2.png

またIsometric Tileでは次のようなシームも作成することができます。(画像はUnity公式の2d-techdemosのサンプルシーンより)

map例1.png

Tilemap Editorの良いところは拡張性です。TileやBrushは拡張が可能で、自分のゲームに合わせたTilemap拡張ライブラリを作ることで、より円滑に2Dマップを作成することができます。

2d-extrasは、Unity公式のTilemap拡張ライブラリです。非常に広範囲のプロジェクトで活躍しそうなTileやBrushが提供されています。

この投稿では、2d-extrasで提供されているTile・Brushをいくつか紹介します!

2d-extrasの導入方法

Unity 2019.2以降であれば、Packages/package.jsonに、denpendencies中に次のように追加してください。

{
  "dependencies": {
    "com.unity.2d.tilemap.extras": "https://github.com/Unity-Technologies/2d-extras.git#v1.3.1",
    "com.unity.2d.sprite": "1.0.0",
    "com.unity.2d.tilemap": "1.0.0",
  },
  "lock": {
  }
}

末尾のv1.3.1は使いたい2d-extrasのバージョンを書いてください。

Unity 2019.1以前で使いたいのであれば、2d-extrasからリポジトリをクローンし、使いたいバージョンのタグに切り替え、ソースコードを使いたいプロジェクトにコピーしてください。

2d-extrasを試すなら2d-techdemo

もし気軽に2d-extrasを試したいのであれば、Unity公式の2d-techdemosがプロジェクトがおすすめです。GitHubからクローンしてきて、Unity 2019.2以降で開いてください。

2d-techdemosは、Tile用の画像アセットや2d-extrasを使ったTileアセット・Tile Paletteのサンプルを豊富に含んでいます。

ここから先は、2d-techdemosのプロジェクトを使い、2d-extrasを紹介します。

Tile編

ランダムに画像を表示するRandomTileとWeightedRandomTile

RandomTileは登録したSpriteをランダムに表示するTileです。

スクリーンショット 2019-12-01 20.49.15.png

上のように4種のSpriteを登録したRandomTileをTilemapに配置すると、次のgifのようにランダムに画像が切り替わります。

random0.gif
random1.gif

RandomTileは登録したSpriteが、同じ確率で表示されます。WeightedRandomTileは重み付けをして、ランダムに表示される確率を指定できるTileです。

スクリーンショット 2019-12-01 20.52.20.png

WeightedRandomTileは上のようにSpriteにくわえてその重みを指定します。重みの数値が大きいものほど、高い確率で表示されます。

アニメーションするAnimatedTile

AnimatedTileは登録した複数枚のSpriteにより、Spriteアニメーションを行うTileです。

スクリーンショット 2019-12-01 20.56.30.png

上のようにTileにはSpriteアニメーションをしたいSprite群を設定します。

animated.gif

AnimatedTileを使えば、Tilemapで上のようなアニメーションを作成することができます。

いい感じにダンジョンをつくれるTerrainTile

TerrainTileは簡単にいい感じのダンジョンを作れるTileです。

terrain0.gif

上のgifでは、新たにTileを設置すると、すでに設置済みのTileが表示するSpriteが切り替わります。これにより、次のようなダンジョンが非常に簡単に作成できます。

terrain1.gif

TerrainTileでは、15種のSpriteを登録します。隣接8マスのTileの有無により、Spriteの切り替え、および回転を行います。

スクリーンショット 2019-12-01 21.12.36.png

これと同じようなTileにPipelineTileがあります。PipelineTileは、上下左右4マスのTileの有無から、5種のSpriteを切り替えと回転をするTileです。

pipeline.gif

関連記事 : ダンジョンを作れる!TerrainTileを使ってみよう!

インスペクタからルールを設定するRuleTile

RuleTileはインスペクタからルールを指定し、表示するSpriteを切り替え・回転させるTileです。

RuleTileを使えば、AnimatedTileも、TerrainTile、RandomTile、そしてそれらを複合したTileも作ることができます。

スクリーンショット 2019-12-01 21.57.34.png

上の画像はシンプルなRuleTileのサンプルです。

  • そのTileの上下にTileが存在するときは一番上の画像が表示されます
  • そのTileの上にTileが存在し、下にTileが存在しないときは、真ん中の画像が表示されます
  • そのTileの下にTileが存在し、上にTileが存在しないときは、一番下の画像が表示されます

このようなルールをRuleTileアセットのインスペクターからGUIで設定することができます。ルールを変更するのに、コーディングは必要ありません。

RuleTileには、HexagonalTile用のHexagonalRuleTile、IsometricTile用のIsometricRuleTileが存在します。

スクリーンショット 2019-12-01 21.46.56.png

isometric_rule.gif

Brush編

Grid上の座標を表示するCoordinateBrush

CoordinateBrushは、SceneウィンドウにおいてブラシのあるGrid上座標をラベルで表示するBrushです。

次の画像のように、座標が表示されます。

スクリーンショット 2019-12-01 22.53.12.png

線を引けるLineBrush

次のgifのように簡単に線を引けるBrushです。

line.gif

登録したTileアセットをランダムに配置するRandomBrush

RandomBrushは登録したTileアセットをランダムに配置するBrushです。RandomTileと似ていますが、こちらはBrushです。

RandomBrushはPrefabBrushと同じ様に、アセットをつくるBrushです。RandomBrushのアセットに、登録したいTileAssetを登録します。

スクリーンショット 2019-12-01 23.25.42.png

このようにRandamに配置されるTileが変わります。

random_brush.gif

色を変えられるTintBrush

TintBrushは配置したTileの色を変えられるBrushです。

ただし色を変えられるは、TileFlags.LockColorが設定されていないTileのみです。

tint_brush.gif

登録したPrefabを配置できるPrefabBrush

次のgifのようにあらかじめ登録したPrefabをTileを置く様に生成・配置できるBrushです。

prefab.gif

Tileをおいているのではなく、Tilemap GameObjectの下にGameObjectが生成されているのに注目してください。

CoordinateBrushやLineBrushと違い、PrefabBrushはアセットをつくるBrushです。作成したPrefabBrushのアセットにPrefabを登録します。

スクリーンショット 2019-12-01 23.14.57.png

また、いくらでもPrefab Brushを作成することができます。

GameObjectもTileのように操作できるGameObjectBrush

次のgifのように、シーン上のGameObjectもTileの様にTilePaletteの操作であつかえるBrushです。

ただし、Hierarchyウィンドウで選択中のGameObjectの子GameObjectである必要があります。

gameobject_brush.gif

TileグループをまとめてスポイトできるGroupBrush

GroupBrushは、次のgifのように複数のまとまったTileグループをスポイトできるBrushです。

group_brush.gif

最大数やギャップを指定することもできます。

まとめ

2d-extrasの中のいくつかのTile・Brushを紹介しました。

こんなTile・Brushをつくりたい!というとき、2d-extrasのコードは参考になると思うので、是非みてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む