2019年3月2日土曜日

[Unity, Firebase] Firebaseを使ってオンラインランキングボードを実装する

ゲームにオンラインランキング機能を付けようって思っても、ランキングサーバーを用意するっていうのはちょっと大変なので
BaaSであるところのFirebaseさんにやってもらおうという記事です。Firebaseは初めて使ったのですが、思ったより簡単にできてしまったのでFirebaseすげーなーって感想です。
記事は長いので実際に実装しながらゆっくり読むと良いですよ。対象はUnityはチョットデキルけどFirebaseはわからんってひと。JavaScriptがなんとなくわかると吉。あと自分用のメモ。

Table of Contents

環境

ぼくの環境です。Windowsじゃない人は適当に読み替えてください。

  • Windows 10 Home / PowerShell 5.1.17134.590
  • .NET Framework 4.7.03056
  • chocolatey 0.10.11
  • Unity 2018.3.0f2

つくるもの

オンラインランキング機能です。まずは単純にユーザー名とスコアを登録/表示するものをつくります。FirebaseのSDKを使って正式にサポートされるのはモバイル向けのみですがデスクトップでもなんやかんやで動きます(公式にはワークフロー用つまりテストとか検証目的ってことになってるんでちょっと微妙ですが)
capture.PNG (34.8 kB)

ソースコードはここに置いています。(Firebaseの設定ファイルとかは入ってません)
https://github.com/HassakuTb/RankingBoardSample

最終的にはこのような構成になります。
cmpRankingBoard.png (22.7 kB)

詳しくは下記で解説しますが、FireStoreがデータベースで、Functionsに実装したAPI経由で
データベースの更新と取得を行います。Authが存在するのはノリですが、
Authを使うとTwitterと連携したりも簡単にできるようになるので、とりあえず入れておけば楽しい!

Firebase

BaaSということで、バックエンドの機能を提供しているサービスです。
サーバーに必要な大体の機能の実装をサーバーを自前で立てずにできてしまうすげーやつです。
データベースとか認証とかストレージとかホスティングとかできます(つまり大体何でもできます)。しかも今回ぐらいの小規模構成なら余裕をもって無料でできてしまいます。最強かよ。母体はGoogle。

コンソールのUIがシンプル+公式ドキュメントが読みやすい ので、これから使うぞって方はとりあえず公式のガイドを読んでみて実際に遊んでみると良いと思います。

Firebase Cloud FireStore

FireStoreはデータベースです。2019年2月現在ベータ版です。
FireStoreの他にRealtime DataBaseというデータベースサービスがありますが、FireStoreの方がいろいろと高機能らしいので今回はFireStoreを使います。Googleさんもお勧めしているし。FireStoreはUnity用のSDKが存在しないため、下記のCloud Functions経由で操作することにしました。(ちなみにRealtime DatabaseにはUnity向けのSDKが存在します)

Firebase Cloud Functions

何かがあったことをトリガーに何かするための機能です。認証に成功したときとかストレージにファイルがアップロードされたりしたタイミングで特定の処理を行います。今回はFunctionsに実装された機能をアプリケーションから直接呼び出すように実装さいます。処理内容はNode.jsで動いているので、Node.jsで実装します。

Firebase Authentication

アカウント認証機能。アカウントの管理とかできます。Twitterでログインとか一瞬でできてしまうやべーやつ。
今回はランキングの登録/取得にいちいち認証するのは(ユーザー的に)めんどくさいので匿名認証を利用しています。

つくる

基本的にここに書いてあることは公式のガイドにも書いてあることがほとんどなので、
一度公式ガイドを読んだ方がいいです。読みやすいです。

Firebaseプロジェクトを作成する

何はともあれFirebaseにプロジェクトを作成します。
コンソールの「プロジェクトを追加」を押すとプロジェクト名を決めてプロジェクトを作成できます。
image.png (72.6 kB)
ちなみに、リージョンは「asia-northeast1」が東京です。
ちょっと待つとプロジェクトができます! 終わり!

ランキング保存用のFireStoreを作成する

ランキングを保存するのにデータベースが必要なのでプロジェクトにFireStoreを追加します。
左側の「開発」の「Database」をクリックするとCloud Firestoreが出てくるので「データベースの作成」をクリックします。
image.png (443.2 kB)
セキュリティルール聞いてきますが、全部非公開にしておきます。(Functions経由ならAdminSDKを通して完全非公開でもアクセスできるため)
image.png (61.2 kB)

一旦これでFireStoreを作成する作業は終わりです。

が、今後作ろうとするデータ構造についてここで話しておきます。
image.png (25.8 kB)

FireStoreは以下の要素でデータが構成されます。

  • コレクション
    • コレクションはドキュメントの集合です
  • ドキュメント
    • ドキュメントはひとつのデータを表すっぽいです
    • 複数のフィールドとコレクションを持てます
  • フィールド
    • データの内容
    • KeyValue
    • いくつかの型があります(stringとかtimestampとか)

今回のランキングボードではentriesという名前のコレクションにnameとscoreという名前のフィールドを持つ単純な構造にします。

entries
├ドキュメント1(IDは自動採番)
│  ├name : "ほげ"
│  └score : 12345
├ドキュメント2(IDは自動採番)
│  ├name : "ふが"
│  └score : 6789
│...

Functionsを作成する

FireStoreは一旦置いておいて次はFunctionsにAPIを作成します。

Node.jsのインストール

作成するにはNode環境(v6かv8)が必要なので、Nodeをインストールします。
Nodeのインストールはnvm(Node Version Manager)を使うと良いです。公式ガイドもそう言ってる。nvmは複数のNode.jsのバージョンを切り替えて使うためのコマンドラインツールです。
nvmのWindows実装はchocolatey 1からインストールするのが簡単でおすすめです。

PowerShell(管理者)
> choco install nvm

入ったら

PowerShell
> nvm list

でインストール確認。
https://nodejs.org/download/release/でパッケージリストを確認してインストール。
Functionsの実装はv6かv8じゃないとダメです。

PowerShell
> nvm install 6.16.0

そしてインストールしたバージョンに切替

PowerShell
> nvm use 6.16.0

これでNode.jsが使えます。

Firebase CLIのインストールと初期化

npm経由でFirebase CLIをインストールします。

PowerShell
> npm install -g firebase-tools

インストール確認

PowerShell
> firebase --version

インストールが確認出来たら認証を通します。

PowerShell
> firebase login

ブラウザが展開するので認証します。

次にFirebaseFunctionプロジェクトを初期化します。
適当な場所にプロジェクトフォルダを作成して、カレントディレクトリを移動します。 2
firebaseプロジェクトを初期化します

PowerShell
> firebase init functions

これを実行するとFIREBASE!
image.png (3.7 kB)
いろいろ聞かれるので答えていきます。

  • Firebaseプロジェクトは新しく作ったプロジェクトを選択します。
  • 言語はJavaScriptとTypeScriptが選べるようです。僕はとりあえずJavaScriptを選択しました。
  • ESLintも入りますが僕はOffにしました。
  • npmの依存パッケージはこの際にインストールしときましょう。

image.png (12.5 kB)
できたぞい。functionsの実装はfunctions/index.jsに実装していきます。

Functionsのデプロイ

index.jsに雛形が記述されているのでコメントアウトを外してデプロイしてみます

index.js
const functions = require('firebase-functions');

// Create and Deploy Your First Cloud Functions
// https://firebase.google.com/docs/functions/write-firebase-functions

exports.helloWorld = functions.https.onRequest((request, response) => {
  response.send("Hello from Firebase!");
});

functions.https.onRequestはHTTPリクエストがあったときに起動するFunctionsで
この実装だとresponseで文字列が返ってきます。

PowerShell
> firebase deploy --only functions

Deploy complete! したらコンソールを見てみるとデプロイされているのがわかります。
image.png (14.3 kB)

HTTPリクエストトリガーなのでブラウザで表示されているURLを叩くと実行できます。
image.png (10.9 kB)
やったぜ。

FunctionsからFireStoreを操作する

Functionsのデプロイができたことだし、これを使ってデータベース操作します。
index.jsを書き換えてレコードを追加するAPIとトップn個のレコードを取得するAPIを作成します。

index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

//  /addEntry?name=ほげ&score=1234
exports.addEntry = functions
  .region('asia-northeast1')
  .https.onRequest((req, res) =>
{
  const entry = {
    name : req.query.name,
    score : parseInt(req.query.score)
  };
  return admin.firestore().collection('entries')
    .add(entry)
    .then((snapshot) =>
  {
    return res.sendStatus(200);
  });
});

//  /getTopEntries?count=100
exports.getTopEntries = functions
  .region('asia-northeast1')
  .https.onRequest((req, res) =>
{
  const count = parseInt(req.query.count);

  return admin.firestore().collection('entries')
    .orderBy('score', 'desc')
    .limit(count)
    .get()
    .then((qSnapshot) =>
  {
    return res.status(200).json(qSnapshot.docs.map(x => x.data()));
  });
});

firebase-adminモジュールはプロジェクト内の要素にアクセスするモジュールでだいたい何でもできるやつです。
これを使ってFirebaseの読み書きをしています。他にデプロイ先のリージョンを変更したりしています。
これをfirebase deploy --only functionsしたらブラウザから叩いてみてFirestoreが更新されるか確認してみます。
image.png (24.2 kB)
image.png (24.8 kB)
addEntryを叩いてみるとドキュメントが増えているはず...!

Unityのセットアップ

APIができたところで、次はSDKを使ってUnityから直接Functionsを呼び出せるようにしていきます。3

SDKのインポート

Unity プロジェクトに Firebase を追加する  |  Firebase
からSDKが入手できます。
unitypackage形式なのでUnityプロジェクトにインポートして完了です。
今回はAuthとFunctionsのみ利用するので、それ以外のパッケージは必要ありません。

Firebaseにアプリケーションを登録する

Firebase側にアプリケーションを登録します。
image.png (115.2 kB)
Project Overviewにそれっぽいメニューがあると思うので、Unityアプリを選択して追加します。
iOSかAndroid4のどっちかもしくは両方を選択して手なりに進めると、google-services.jsonというファイルがダウンロードできるので、これをUnityプロジェクトのAsset内の適当な場所に配置します。
これでUnity側の設定は完了です。

FunctionsをHTTPS callableにする

Functionsのトリガーは現在はHTTPリクエストになっていますが、これをアプリから直接叩ける形に変更します。
index.jsを変更して再度デプロイします。

index.js

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

//  Entryを追加
exports.addEntry = functions
  .region('asia-northeast1')
  .https.onCall((data, context) =>
{
  const entry = {
    name : data.name,
    score : data.score
  };
  return admin.firestore().collection('entries')
    .add(entry)
    .then((snapshot) =>
  {
    return 'OK';
  });
});

//  トップcount件のEntryを取得
exports.getTopEntries = functions
  .region('asia-northeast1')
  .https.onCall((data, context) =>
{
  const count = data.count;

  return admin.firestore().collection('entries')
    .orderBy('score', 'desc')
    .limit(count)
    .get()
    .then((qSnapshot) =>
  {
    return {
      entries : qSnapshot.docs.map(x => x.data())
    };
  });
});

SDKから呼び出すには、functions.https.onCallを使って記述します。

UnityからFunctionを呼び出す

ではこれをUnity側のスクリプトから呼び出してみます。

FirebaseService.cs
FirebaseFunctions functions = FirebaseFunctions.GetInstance("asia-northeast1");
public async Task<object> AddEntryAsync(RankingEntry entry)
{
    object data = new Dictionary<object, object>
    {
        { "name", entry.Name },
        { "score", entry.Score },
    };

    return await functions.GetHttpsCallable("addEntry").CallAsync(data)
        .ContinueWith(task =>
        {
            return task.Result.Data;
        });
}

public void AddEntry(RankingEntry entry)
{
    service.AddEntryAsync(entry).ContinueWith(task =>
    {
        if (task.IsFaulted)
        {
                // onError
        }
        else
        {
                // onComplete
        }
    }, TaskScheduler.FromCurrentSynchronizationContext());
}

呼び出しにはFirebaseFunctions.GetHttpsCallable()で取得したCallbaleインスタンスのCallAsyncを使うわけですが、こいつはasync関数なのでContinueWithなどで待ち受けます5。Taskの結果のハンドリングはメインスレッドに戻してやらないとUnityObjectにアクセスしたときとかにログも何もなく処理が止まって頭を抱えることになるので注意です6 7(1敗)

js側でobject型で受け渡ししていると、Unity側で取得できるのはDictionary<object, object>型で取り回しが悪いのでJson文字列でやり取りした方がいいかもしれません。

これを実行してやるとFireSroreの更新を確認できるはずです。
あとはちょいちょいとUIを構成してやればオンラインランキングボードの出来上がりです。

認証を有効にする

せっかくなので認証もやりましょう。リクエストに認証ユーザーの情報が乗ってないなら処理を止めるようにします。

コンソールからAuthenticationを開いて今回は匿名認証を有効にします。設定終わり!
image.png (46.1 kB)

Functionsを認証しないと使えないようにする

Functionは今はだれでもウェルカムな状態なので、登録されているユーザー情報が載っていないと処理しないように変更します。

index.js
...

exports.addEntry = functions
  .region('asia-northeast1')
  .https.onCall((data, context) =>
{
  if(context.auth === undefined || !context.auth.uid || context.auth.uid === 0){
    throw new functions.https.HttpsError('unauthenticated');
  }
  return admin.auth().getUser(context.auth.uid)
    .then(() =>
    {
      const entry = {
        name : data.name,
        score : data.score
      };
      return admin.firestore().collection('entries').add(entry)
    })
    .then((snapshot) =>
    {
      return 'OK';
    });
});

onCallで登録する関数の第二引数のcontextに認証情報が入ってくるので、
これを見て、Authenticationに該当ユーザーがいる場合だけ処理を通すようにします。

デプロイしたら処理が通らなくなったことを確認できるはず。

Unityで匿名認証する

ではUnity側で認証処理を書きます。

FirebaseService.cs
FirebaseAuth auth = FirebaseAuth.DefaultInstance;

auth.SignInAnonymouslyAsync().ContinueWith(task =>
{
    if (!task.IsFaulted)
    {
        //  onComplete
    }
    else
    {
        //  onError
    }
}, TaskScheduler.FromCurrentSynchronizationContext());

おわり。
認証が終わった後にCallble.CallAsyncを読んでやると処理が通るようになります。すごいかんたん。

匿名認証は認証通る度にユーザーがどんどん増えるのが気持ち悪いので処理が終わったらユーザーを消す処理を追加しておきました。

FirebaseService.cs
auth.CurrentUser.DeleteAsync().ContinueWith(task =>
{
    if (!task.IsFaulted)
    {
        //  onComplete
    }
    else
    {
        //  onError
    }
}, TaskScheduler.FromCurrentSynchronizationContext());

発展させる

今回作ったランキングボードは機能的にはかなり適当なのでいろいろ発展させると楽しいかと思います。例を挙げると

  • 名前とスコア以外を記録してみる
  • 登録したレコードの周辺n件を取得して今回記録を強調表示してみる
  • 直近1週間のみの記録を乗せる
  • 匿名でない認証必須にしてひとり1レコードしか記録できないようにする
  • Twitter認証と連携してスコアボードにアイコンを表示する

などなど簡単なものから結構めんどくさそうなものまでいろいろ考えられるので、好みのランキングボードを作ると楽しそうです。

気を付けよう

Functionsは頻繁にアクセスされないとき、最初の一回は遅いです。10秒ぐらいかかるときもありましたので、この時間を考慮してゲーム内のフローを組まないとダメです。あとオンライン機能なので普通に失敗します。なので異常系処理もちゃんと組みましょうね。

UnityプロジェクトにはgoogleのAPIキーみたいなセキュアな情報が含まれることになるので、何も考えずにgithubとかに置くとかなりよろしくないです。公開するときは慎重にしましょうね。

おわり

この記事はかいふ氏が主催するVOICEゲームジャムで何か役に立ちそうな記事書けないかなーと思って書いたものなので、何か役に立ったらいいなああああああ!

  1. Windowsのパッケージマネージャー apt-getとかyumの類 便利なので超おすすめ

  2. フォルダをShift+右クリックで「PowerShellウィンドウをここに開く」が便利

  3. どこからでもアクセスされちゃうことに気を付ければHTTPリクエストを呼び出す形式でもいいと思う。HTTPリクエストのままで処理するならSDKのサポートが無いとか何も考えなくていいしね。

  4. デスクトップサポートは無いです。デスクトップで使いたい場合もどっちか選択して進めるととりあえず動きます。

  5. Unityは早くTaskをサポートして その気は無いっぽいけど

  6. ログが出ることもある

  7. ちなみにUnityのCurrentSyncronizationContextはWaitForFixedUpdateの後に待ち合わせをスケジュールするっぽい

2018年12月7日金曜日

[C#, Unity]Coroutineを逐次処理するCoroutine

やりたいこと

逐次実行する複数のCoroutineの中で、一連の処理を全部中断したい。

CoroutineRunner.cs
public IEnumerator HogeCoroutine()
{
    yield return CoroutineA();
    yield return CoroutineB();
    yield return CoroutineC();
}

private IEnumerable CoroutineA()
{
    ...
}
private IEnumerable CoroutineB()
{
    ...
    // ここで一連の処理を全て中断したい
}
private IEnumerable CoroutineC()
{
    ...
}

愚直にやる

キャンセルフラグを持っておいてキャンセルされたら外側のCoroutineでyield breakします。
単純だけどCoroutineが更にネストしたりするとめんどくさい。

CoroutineRunner.cs
private isCancelled = false;

public IEnumerator HogeCoroutine()
{
    IEnumerator[] coroutines = new IEnumerator[]
    {
        CoroutineA(), CoroutineB(), CoroutineC(),
    }
    foreach(IEnumerator e in coroutines)
    {
        yield return e;
        if(isCancelled) yield break;
    }
}

private IEnumerable CoroutineA()
{
    ...
}
private IEnumerable CoroutineB()
{
    ...
    // ここで一連の処理を全て中断したい
    isCancelled = true;
    yield break;
}
private IEnumerable CoroutineC()
{
    ...
}

Coroutineを分解する

各CoroutineのMoveNextの呼び出しをひとつのループで呼び出す。
ここまでやると後からCoroutineを追加したり、タイムアウトや割り込みを仕込んだりできるので応用が利きます。

CoroutineRunner.cs
public class CoroutineRunner{
    private Queue<IEnumerator> queue = new Queue<IEnumerator>();
    private bool isRequestedCancel = false;
    
    public Append(params IEnumerator[] coroutines)
    {
        foreach(var e in coroutines) queue.Enqueue(e);
    }
    
    public void Cancel()
    {
        isRequestedCancel = true;
    }
    
    public IEnumerator Coroutine()
    {
        while (true)
        {
            if (isRequestedCancel) break;
            if (queue.Count == 0) break;
    
            if (peek.MoveNext())
            {
                yield return peek.Current;
            }
            else
            {
                queue.Dequeue();
            }
        }
    }
}

private CoroutineRunner runner = new CoroutineRunner();

public IEnumerator HogeCoroutine()
{
    runner.Append(CoroutineA(), CoroutineB(), CoroutineC());
    yield return runner.Coroutine();
}

private IEnumerable CoroutineA()
{
    ...
}
private IEnumerable CoroutineB()
{
    ...
    // ここで一連の処理を全て中断したい
    runner.Cancel();
}
private IEnumerable CoroutineC()
{
    ...
}

ちなみに僕が普段使いしているものはこちらです。

[C#, Unity]Coroutineを自前で実行する

C#のCoroutineへの理解を深めましょう。
UnityでMonoBehaviourが無くてもCoroutineを実行できる1ようになったり、Coroutineの拡張をできるようになったりできます。

Coroutine is 何

C#のCoroutineは「処理を一旦止めて途中から処理を再開できる」ブロック2です。
C#ではyieldというキーワードを用いて、メソッドやプロパティをCoroutineにできます。
Coroutineブロックの戻り値はIEnumeratorまたはIEnumerableでなければなりません。
このCoroutineを1回呼び出すには、IEnumerator.MoveNext()を使います。

EnumeratorCoroutineSample.cs
//  IEnumeratorを返すコルーチンメソッド
public IEnumerator<int> SomeCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}

public void Hoge()
{
    IEnumerator<int> coroutine = SomeCoroutine();

    //  1回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(1, coroutine.Current);  //  pass

    //  2回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(2, coroutine.Current);  //  pass
}
EnumerableCoroutineSample.cs
//  IEnumerableを返すコルーチンメソッド
public IEnumerable<int> SomeCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}

public void Hoge()
{
    IEnumerator<int> coroutine = SomeCoroutine().GetEnumerator();

    //  1回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(1, coroutine.Current);  //  pass

    //  2回目の呼び出し
    coroutine.MoveNext();
    Assert.AreEqual(2, coroutine.Current);  //  pass
}

yieldキーワードを用いたブロックはIEnumeratorを実装したクラスにコンパイルされます。
yieldはIEnumeratorを簡単に実装するための構文というわけですね。

Coroutineを最後まで実行する

OnNextがfalseになるまで実行すれば良いです。

CoroutineSample.cs
//  IEnumeratorを返すコルーチンメソッド
public IEnumerator<int> SomeEnumeratorCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}
//  IEnumerableを返すコルーチンメソッド
public IEnumerable<int> SomeEnumerableCoroutine()
{
    yield return 1;
    //  最初の呼び出しではここまで実行される

    yield return 2;
    //  次の呼び出しではここまで

    yield break;
    //  これ以降は処理しない

    yield return 3;
}

public void Hoge()
{
    // Enumeratorを最後まで実行
    {
        int count = 0;
        IEnumerator<int> e = SomeEnumeratorCoroutine();
        while (e.MoveNext())
        {
            count++;
        }
        Assert.AreEqual(2, count);  //  pass
    }

    // Enumerableを最後まで実行
    {
        int count = 0;
        IEnumerator<int> e = SomeEnumerableCoroutine().GetEnumerator();
        while (e.MoveNext())
        {
            count++;
        }
        Assert.AreEqual(2, count);  //  pass
    }

    // Enumerableはこれでもいい(コンパイラが↑の形に展開する)
    {
        int count = 0;
        foreach(int _ in SomeEnumerableCoroutine())
        {
            count ++;
        }
        Assert.AreEqual(2, count);  //  pass
    }
}

UnityのCoroutine

UnityではCoroutineをフレームを跨いだ継続処理のために利用します。
UnityのComponentモデルはシングルスレッドなのでUI処理を止めずにフレームを跨いだ処理を実行するには
処理を途中で止める必要があり、これを実装するのはCoroutineを使うのが簡単なわけです。

UnityのMonoBehaviour.StartCoroutine()は毎フレームMoveNext()を呼び出す実装です。
また、コルーチンの中でIEnumeratorをreturnすると、コルーチンをスタックして中のIEnumeratorのMoveNext()を呼び出すような仕組みになっていることでしょう。
StartCoroutineを自前で簡単に実装するならこのような形になるのではないでしょうか。実際にはStopCoroutineやら例外やらAsyncOperationやら何やらがあるのでもっと複雑でしょうけどね。

MyStartCoroutine.cs
public void StartCoroutineSync(IEnumerator coroutine)
{
    Coroutine c = new Coroutine(coroutine);
    while (c.MoveNext()) { }
}


public class CoroutineRunner : MonoBehaviour {
    public static List<Coroutine> Coroutines { get; private set; } = new List<Coroutine>();

    public void Update()
    {
        Coroutines.ForEach(c => c.MoveNext());
        Coroutines.RemoveAll(c => !c.HasNext);
    }

    public void StartCoroutine(IEnumerator coroutine)
    {
        Coroutine c = new Coroutine(coroutine);
        Coroutines.Add(c);
    }
}

public class Coroutine : IEnumerator {

    private Stack<IEnumerator> coroutineStack = new Stack<IEnumerator>();

    public Coroutine(IEnumerator coroutine)
    {
        coroutineStack.Push(coroutine);
    }

    public object Current { get; private set; }

    public bool HasNext { get; private set; } = true;

    public bool MoveNext()
    {
        while (coroutineStack.Count > 0)
        {
            IEnumerator peek = coroutineStack.Peek();

            bool hasNext = peek.MoveNext();

            if (!hasNext)
            {
                coroutineStack.Pop();
                continue;
            }

            if (peek.Current is IEnumerator)
            {
                coroutineStack.Push((IEnumerator)peek.Current);
                continue;
            }
            else
            {
                Current = peek.Current;
                HasNext = true;
                return true;
            }
        }

        HasNext = false;
        return false;
    }

    public void Reset()
    {
        throw new NotImplementedException();
    }
}

これでMonoBehaviour.StartCoroutine()が無くてもコルーチンの実行ができますね。

  1. CoroutineのテストがPlayModeでなくてもできる!

  2. iterator blockと呼びます。

2018年10月14日日曜日

作ったゲームを解説する「Unity-chanバッティング VS.こはくちゃんズ」

utc-batting.png (985.4 kB)
「Unity-chanバッティング VS.こはくちゃんズ」というゲームを作りました。
こはくちゃんズが投げる球を打って飛距離を稼いでノルマクリアを目指すゲームです。
unityroomさんで公開しています。暇なときにでも遊んでみてください。

目次

開発のいきさつ

お仕事先で突発的にやっていた連休ゲームジャム(非公開)に参加しました。
お題は「打つ」で、ちょうどスポーツゲームって作るの難しいよねっていう話題があったのでじゃあ作ってみようとなって作りました。

作業のうちわけ

企画とか準備とか ... 1h
アセットの選定、インポート、使用感の確認 ... 18h
コーディング ... 8h
値の調整とか ... 12h
ビルド作業とか ... 1h
最終的にかかった時間は約40時間、AssetStoreで購入したアセットの料金が4000円ぐらいです。(以前購入したものを含めるともっとかかっています)
短期間のゲームジャムだと如何に既存のアセットをうまく使うかがとても重要だと思いますので、まあこんなもんでしょう。
作業時間を買うという感覚だと大抵のアセットは安く感じますね。調子に乗っていると請求がやばい。

ゲームデザイン

テーマは「打つ」なので、打撃にフォーカスした野球ゲームを作りました。
コンセプトは「プレイヤーの力量によって打てたり打てなかったりするけど打てれば気持ちいいゲーム」です。
力量によって打てたり打てなかったりするで思い浮かんだのがパワプロシリーズ1 の昇級試験2なので、それっぽいものを実装しようと思いました。ただし、ヒット性の当たりかどうかを判定するよりも、単に飛距離をスコアにした方が簡単なので飛距離がそのままスコアになります。
また、プレイヤーのモチベーションを確保するため、単に飛距離を競うスコアアタックにするのではなく、投手との対決という形態にしています。つまり、くまのプーさんのホームランダービー3
また、段階的にプレイヤースキルを上げられるようにデザインしています。前半は仕様を覚えてもらうためにノルマを低めに設定しておき、後半になるにつれノルマを上げます。最後はノルマを高めに設定していますが、そこまで到達したプレイヤーなら絶対無理ではなく、頑張ればできそうと思えるような調整にしたつもりです。

ゲームの仕組み

内部の仕組みについてざっくり解説します。
まずは野球のモーション集を探してきてUnityに突っ込み、SDこはくちゃんズのリグに適用します。この時点でゲームは8割完成です。
BASEBALL Motion Basic - Asset Store
上記のモーション数ですが投球などにバリエーションは無いので、本格的な野球ゲームを作るならやはりモーションは自前で用意する必要があるんでしょうね。

ボールの生成

アニメーションイベントを使って投球モーションの特定のタイミングでボールを生成しています。生成されるボールの位置はもちろん手の位置です。
image.png (262.7 kB)
後は単にAnimatorを使ってピッチングモーションを再生してやれば良い感じのタイミングでボールが生成されます。

投球モーションのスピード変更

現実の野球ではリリース時の手の振りなどで球種の判断をするそうなのですが、モーション自体にバリエーションを準備するのは大変だったので、代わりにモーションのスピードを球種ごとに変更しています。投球モーションに変化を付けているのはプレイヤースキルを重視する作りの一環でもあります。プレイヤーが球種によってモーションスピードが違うことに気付いてそれに対応できれば、いくらか攻略が楽になります。
モーションスピードの変更はAnimatorのパラメータを使って変更しています。
Picher.cs
public void StartPitchingMotion()
{
    ...
    GetComponent<Animator>().SetFloat("Speed",BallType.GetMotionSpeed());
    GetComponent<Animator>().SetTrigger("Pitch");
    ...
}

public void ReleaseBall()
{
    ...
    GetComponent<Animator>().SetFloat("Speed",2.0f);

    Ball ball = Instantiate(ballPrefab);
    ...
}
image.png (50.4 kB)

投球の軌道計算

ピッチャーの手元で生成されたボールはストライクゾーン上のターゲットに向かって飛んでいきます。
投球の軌道は物理演算を使用せず、スクリプトで位置を直接設定しています。
投球の軌道を説明する前に軌道の説明で使用する座標系を設定しておきます。
XYはストライクゾーンの真ん中を中心としています。
水平軸をX軸とし、アウト側を+方向、ストライクゾーンの大きさを1とします。
垂直軸をY軸とし、上を+方向、ストライクゾーンの大きさを1とします。
XY.png (208.4 kB)
Zはピッチャーがボールをリリースした点を0、ストライクゾーンの位置を1とします。
z.png (398.3 kB)
今回はボールのz位置に対するボールの位置を計算することで、投球の軌道としています。
つまりインターフェースはVector3 GetPosition(float z);
z位置は等速で移動させています。実際には空気抵抗による減速とかあると思いますが考えるのがめんどくさいので。

直線運動

現実世界では投げたボールが直線運動をするなんてことはありませんが、簡単ですからまずはこれを考えます。
ゲーム上では150km/h以上のエフェクトが無いストレートが実際に等速直線運動しています。
計算方法はリリース座標にzに対する変分を足せばいいですね。
Ball.cs
private Vector3 start;   //  リリース座標
private Vector3 target;  //  ストライクゾーン通過座標

private Vector3 GetPosLiner(float z)
{
    return start + (target - start) * z;
}
変な位置にピッチャーを配置してもちゃんと狙った位置にボールが届きます。
image.png (722.4 kB)
ちなみにリリース位置のX座標はだいたい0になるように投手の位置をずらしています。
リリース位置がずれているとカメラから見たときの情報が増え、無駄に少し難しくなるためです。

やまなり軌道

現実世界ではボールは重力の影響をうけてやまなりの軌道で移動します。
ボールがz方向について等速の場合、やまなり軌道は単純な2次関数なので、
まずはzが0と1のときに0になり、0.5のときに1になる関数を考えればいいです。
arch.png (291.9 kB)
Ball.cs
private float Arch(float z)
{
    float nz = 2 * z - 1;  //  (0, 1) -> (-1, 1)
    return - nz * nz + 1;
}
ストレートもやまなり軌道となっているのですが、このままではどんな球速でも鋭いやまなりとなってしまうので、
球速に応じた適当な係数を掛けてやまなりを緩やかにします。
arch2.png (336.8 kB)
Ball.cs
//  球種に応じたやまなり係数
private float xFactorArch;
private float yFactorArch;

// 球速
private float speed;

private Vector3 GetPosArch(float z){
    return new Vector3{
        x = Arch(z) * xFactorArch * (1 - (Mathf.Clamp(speed, 100f, 150f) - 100f) / 50f),
        y = Arch(z) * yFactorArch * (1 - (Mathf.Clamp(speed, 100f, 150f) - 100f) / 50f),
        z = z,
    };
}
X軸のやまなり軌道はカーブなどの変化球で使用しています。
変化球を考慮しないのであればY軸のやまなり係数は重力の表現なので、これが球種ごとに異なるというのはかなりおかしなことです。しかしそこはゲームなので、遅い球は大げさにやまなりになってもらった方が表現として都合が良いのです。

変化球

リアルな変化球を再現するのは無理ですし、仮に再現できたとしてもゲームとしては面白くないでしょう。
ので、単純化してこれも2次関数で表現することにします。ただし変化球の鋭さを表現するために途中までは変化しないようなものを考えます。
curve.png (281.8 kB)
Ball.cs
//  球種に応じた変化量
private float dx;
private float dy;

// 変化球の鋭さ
private float sharpness;

private Vector3 GetPosCurve(float z){
    if(z < sharpness)
    {
        return new Vector3(0f, 0f, z);
    }
    else
    {
        float s = sharpness;
        return new Vector3{
            x = dx * (z - s) * (z - s) / (1 - s) / (1 - s),
            y = dy * (z - s) * (z - s) / (1 - s) / (1 - s),
            z = z,
        };
    }
}
上記の「直線軌道」、「やまなり軌道」、「変化球」を全て足したものが最終的なzに対するボールの位置になります。
Ball.cs
public Vector3 GetPosition(float z)
{
    return GetPosLinear(z) + GetPosArch(z) + GetPosCurve(z);
}

ターゲット表示

パース付きカメラを使っているので、どこを狙えばいいかをガイドしないと非常に難しいゲームになってしまうため、ターゲットを表示しています。
これも投球と同じくzに対する位置座標をボールとは別に計算しています。変化球によるターゲット表示の移動は2次関数だと予測が難しくなるので1次関数で表現しています。
guide.png (343.8 kB)
円の大きさはストライクゾーンからボールまでのZ距離です。
円の描画はこういうコンポーネントを作っておくと便利ですね。
LineCircleRenderer.cs
using System.Linq;
using UnityEngine;

[RequireComponent(typeof(LineRenderer))]
public class LineCircleRenderer : MonoBehaviour{

    public int reslution = 32;
    public float radius = 1;

    private LineRenderer lineRenderer;

    private void Start()
    {
        lineRenderer = GetComponent<LineRenderer>();
    }

    public void Render()
    {
        lineRenderer.positionCount = reslution + 1;
        lineRenderer.SetPositions(
            Enumerable.Range(0, reslution + 1)
            .Select(x => 2 * Mathf.PI / reslution * x)
            .Select(x => new Vector3(
                Mathf.Sin(x) * radius + transform.position.x,
                Mathf.Cos(x) * radius + transform.position.y,
                transform.position.z))
            .ToArray());
    }

    private void LateUpdate()
    {
        Render();
    }
}

配球

配球はレベルデザインと強い関係があります。
配球を固定とすると覚えゲーになり、完全ランダムにすると難易度が上がるうえ、当たりはずれが大きくなってしまうので、間をとってシャッフルを利用しています。
実はどのキャラも最初の5球は球種が固定になっています。コースはランダムですが、配球のパラメータでばらつき加減を設定しています。
例えばもっとも簡単なこはくLv1では、最初の5球は必ず真ん中あたりにストレートを投げてきます。後半は2球だけフォークを投げる設定になっています。
balls.png (781.2 kB)
このような作りになっているため、ボール球は投げないようにしました。必ずストライクゾーンにボールが着弾します。
ちなみに球速は実際の球速とはだいぶ違います。(私が)反応して打てるぎりぎりを160km/hに設定しています。

バッティング

今回実装した簡易的な打撃の実装

気づく人は気づくと思いますが、バットとボールには当たり判定がありません。
スイングモーションにアニメーションイベントを付け、特定のタイミングで「ボールの着弾予定地点」「カーソル位置」「ボールのZ位置」を見てヒットしたかどうか、打球の方向、強さを決めています。
なので、バットはゲームには直接影響を及ぼさない飾りです。ただ、カーソル位置のあたりをバットが通らないと不自然になるため、バットの角度を決め打ちで無理やり変えることで近くを通しています。前から見るとバットが不自然にぐねってますし、本体がアニメーションしているため、毎回バットを通る位置は異なります。加えて、ボールのZ位置がバッターの正面のときにジャストとしているので、振り遅れてバットに当たる場合はバットよりも後ろでボールに当たることになります。これは明らかに違和感があります。
image.png (223.2 kB)

理想的な打撃の実装(うまくいくかは未検証)

まずはカーソル位置に正確にバットを通すようにIKを使ってモーションを制御します。
カーソル位置をバットが正確に通るようになったら板状の当たり判定をバットに着けてバットを振ってもらいます。
image.png (219.5 kB)
当たり判定がボールと衝突したときの衝突位置や板の角度を基に打球の飛距離や方向を決定します。正確にバットの位置を制御できたとしても
バットにボーン入れる必要がありますし、そのあたりのノウハウを持っていないため時間がかかると判断し、今回は実装しませんでした。ボーン入れるってどうやるの...

打球の軌道計算

ボールがバットにヒットしたときに角度と強さを計算で求めます。物理エンジンではいい感じの打球にすることはできないでしょう。
まずはヒットするかどうかとジャストミートの判定
タイミングの判定はボールのz位置による判定です。ただし、固定値だと球速が速くなるにつれ当たりにくくなるので、球速による補正をかけ、速い球ほど当たりやすいように設定しています。また、ジャストミートの判定はz位置が前に存在する分を長めにとっています。これは、引っ張り方向(タイミングが速め)の場合でもジャストミートが出るようにするためです。
タイミング以外の部分は単純に着弾予定地点とカーソル位置の距離を見ています。バットの形は考慮されていません。(ついでに言うとカーソルの大きさは適当で、当たり判定とは合っていません)
続いて角度の計算
カーソル位置と投球の着弾予定位置が完全に一致し、ボールのz位置がバッターの真正面のとき、打球は真正面に斜め45度で飛ぶように設定します。
y軸角度はスイングのタイミングにおおきく依存します。当たり判定をとったときに前方にボールがあるなら引っ張り方向、後方にボールがあるなら流し方向に回転します。最大の回転角度はだいたい+-110度ぐらいです。
x軸角度は着弾予定地点とカーソル位置のy差分に強く影響を受けます。ボールの下を叩いたときに上方向、上を叩いたときに下方向に飛ぶように設定します。最大の回転角度はだいたい45+-60度ぐらいです。ただし、ジャストミートのときは35度から55度の範囲に収まるように調整しています。
最後に打球の強さの計算
計算式は基本パワー + タイミング補正 + カーソル距離補正 + ジャストミートボーナス + 球種補正で計算しています。
基本パワーは固定値です。これが無いとバットに当ててもその場に落ちるだけという不思議な状況が発生してしまいます。
タイミング補正はタイミングが悪い時にあまり飛ばないようにします。ただし引っ張り方向は減衰しないようにしています。
カーソル距離補正はカーソル位置が着弾予測位置に近いほど大きいパワーを加えます。
ジャストミートボーナスはジャストミートの時に一律して加算するパワーです。
球種補正は変化球であればパワーを加えるようにします。
打球の強さの計算式は係数をあまり練っておらず、うまくいけば200m以上飛びます。蜂蜜キマってる。飛びすぎる分には別にいいかな?
初速と角度が決まったらボールのrigidbodyを有効にして初速を設定して飛ばします。
あとは地面と当たり判定をとって飛距離を計算し、スコアに加算します。

演出

ヒットストップ

特筆すべきはジャストミートのときのヒットストップだと思います。
打撃の気持ちよさを演出するために、少し派手目に演出しています。集中線のようなエフェクトを出してもよかったかもしれません。
Updateで動くものが無い時はヒットストップは簡単に実装できます。
HitStopHandler.cs
using System;
using UnityEngine;
using System.Collections;

public class HitStopHandler : MonoBehaviour {

    public void HitStop(float stopTime, Action onResume)
    {
        StartCoroutine(HitStopCoroutine(stopTime, onResume));
    }

    private IEnumerator HitStopCoroutine(float stopTime, Action onResume)
    {
        float timeScale = Time.timeScale;
        Time.timeScale = 0f;

        yield return new WaitForSecondsRealtime(stopTime);

        Time.timeScale = timeScale;
        if (onResume != null) onResume();
    }
}
前述の通り、バットはボールと接触しないことがあるため、ヒットストップの瞬間にはボールを消して一見バットにボールが当たっているように見せることが重要でした。

サウンド

打撃音とキャッチャーミットの音は、強さに応じてリバーブをかけて強さを演出しています。
Batter.cs
private float power; //  打球の強さ(0~1)
private AudioSource hitAudio;

private PlayHitAudio()
{
    float reverbLevel = -2000 + power * 2200;
    hitAudio.GetComponent<AudioReverbFilter>().reverbLevel = reverbLevel;
    hitAudio.Play();
}
WebGLではAudioReverbFilterなどのオーディオフィルターが動かないため、exe版のみの機能です。

よかった点

機能を絞れるところは絞り、こだわるところはこだわれた、バランス感覚が良かったです。最終的にはレベルデザインもいい感じに仕上がったように思えます(主観)
私にとっては新しいことにも取り組むことができましたし、満足できる結果になりました。

悪かった点

ほとんどビルドして確認していなかった

もともとWebGLで公開する気はなかったのもありますが、エディタ上だけでデバッグを済ませるのは良くなかったです。
オーディオ関係がうまく動かないのに気付いたのはほぼ完成した後だったので、そのあとからAudioFilterを使わなくてもよいサウンドのシステムを作るモチベーションは無かったです。もっと早く気づいていれば何かしらの対応をしていたかもしれません。

SDキャラにモーションを適用する際の不備

時短のために目をつぶった部分が多いので、一概に悪かったとは言えないのですが、やはり気になりますね。
image.png (140.8 kB)
他にもピッチングモーションの終わり際など、いろいろとめり込みが発生している箇所があります。
こういう不具合をさくっと解決できるグラフィッカースキルが欲しい!! 実際のところグラフィックの簡単な不具合はプログラマー側で直せた方が何かと便利ですね。

おわりに

リグを使った3Dゲームを作るのは実は初めてなので、めっちゃ楽しかったです。と同時に、3Dスキル不足を感じたので今後も精進していきたいです。
次はVOICEROID Game Jamにて何か作る予定です。
この記事および作成したゲームはユニティちゃんライセンス条項の元に提供されています。
© Unity Technologies Japan/UCL

  1. 選手を作成するストーリーモード「サクセス」におけるイベントの一種。NPCが投げる固定の数の球をヒット性のあたりにすれば得点が加算され、得点に応じて強い選手を作るための環境を手に入れたり失ったりする。