miso_soup3 Blog

主に ASP.NET 関連について書いています。

ゲーム「楽譜で音ゲー」を Unity で作った話

ゲーム「楽譜で音ゲー」のプロトタイプを、Unity で作成しました。→ 追記:その後リリースしました

曲は、簡単な「かえるの歌」と、難しめな「戦闘曲35(魔王魂より)」の2曲です。UnityRoom にアップしたので実際にプレイできます。

「かえるの歌」

www.youtube.com

「戦闘曲35(魔王魂より)」

www.youtube.com

コード:GitHub - hhyyg/Miso.ScoreOtoge: ♬ Prototype for music rythm game using Unity

これから詰めた実装する段階なのですが、道のりが険しく挫けそうなので、ここで一区切りとしてブログを書くことにしました。

しくみ

まず楽譜を作成し、そこからノーツ(音ゲーで降ってくる玉のこと)を自動生成しています。

f:id:miso_soup3:20180727160232p:plain

楽譜は MuseScore(ミューズスコア)というソフトで作成し、MusicXML 形式のファイルにエクスポートします。そこからノーツの座標を計算し、Unity 上で描画します。MusicXML とは楽譜表記のための XML 形式のフォーマットです。中には、テンポ情報や、どういう音符がどの小節に配置されているか、どの楽器のどのパートが存在するか、といった情報が格納されています。また同時に MuseScore で作成した楽譜から、MP3 形式の音声ファイルもエクスポートし、Unity 上で音楽を再生します。つまりこのゲームは、楽譜である MusicXML ファイル・音声の MP3 ファイルの 2 つから成り立っています。

音ゲーでいつタップするか、というのはこのように打楽器のパートで楽譜に記載します。

f:id:miso_soup3:20180727160306p:plain

↓これが音ゲー上だとこのように描画されます。

f:id:miso_soup3:20180727160426p:plain

コンセプト:「音ゲー+楽譜」

音ゲーに楽譜を組み合わせたらどうなるのか?」これが気になり作成しました。楽譜を読める自分にとっては、音ゲーをプレイしていて常に感じるのが「これが楽譜だったらクリアも簡単だし、リズムもわかって楽しいのに…」ということです。

「楽譜を組み合わせる」とはどういうことかというと、ノーツ(音ゲーで降ってくる玉)をタップするタイミングを、距離だけではなく、記号(音符)としても表現する、ということです。

f:id:miso_soup3:20180727160710p:plain

例えば、多くの音ゲーのノーツは、以下のように全て同じ丸の図です。

f:id:miso_soup3:20180727161107p:plain

これはデレステの画面のスクショです。ノーツは上から下に流れます。

f:id:miso_soup3:20180727161233p:plain

画像:太鼓の達人 ブルーVer.(ゲームセンター向け) | バンダイナムコエンターテインメント公式サイト これは太鼓の達人の画面です。ノーツは右から左に流れます。

タップのタイミングは、「ノーツとノーツの距離」で音楽を聴きながら予測します。

(ノーツには、他にもフリックする・長押しする という違いがあります。これは「どうアクションするか」という表現で「いつタップするか」という表現ではありませんので、ここでは省きます。音ゲーでは重要な概念ではありますが。)

一方、ノーツを音符で表示した場合は、このようになります。

f:id:miso_soup3:20180727161404p:plain

音ゲーと同じように「ノーツとノーツの距離」でタイミングを表しつつも、ノーツのタイミング(=ノーツ自身がもつ時間の長さ)を「記号(音符)」で表しています。

ビットマップかベクトルの違い

この「距離」と「記号」で表すことは、例えると、画像形式の分類である「ビットマップ」か「ベクトル」の違いに似ています。例えば、「記号」である♩(四分音符)が持つ時間(音価)は、何秒、といったように絶対的な値ではなく、1/4という相対的な値であり、1拍の時間が変わればその四分音符の音価も変わります。対して、音ゲーの「距離」は、見た目では「時間」が測れません。他の距離と比較することにより、時間を推測することができますが、難しいです(音ゲー上では楽しく遊べますが)。

楽譜についての話

なぜ楽譜なのか?というと、先述のように「自分が楽譜を読めるから」にあると思います。例えば、もし私が琴奏者だったら、「琴の楽譜で音ゲーをプレイ」したい、と思っているかもしれません。

今ある音ゲーの譜面も、立派な楽譜であり、現代的な楽譜と言えます。楽譜の再設計についての記事:How I’d Redesign Piano Sheet Music – Alex Couch's portfolio – Mediumに登場する楽譜も、音ゲーに近いものがあります。楽譜にはいろいろな記譜法 - Wikipediaがあり、例えば、琴の楽譜は漢字で縦書き(参照:箏(琴)の楽譜 | 箏-三味線.info)ですし、いわゆる一般的な楽譜は、五線記譜法と言い、西洋音楽に由来します。

今ある音ゲーデレステの譜面は、攻略サイトなどに掲載されており、次のように表されています。

f:id:miso_soup3:20180727161445p:plain

画像:Trancing Pulse -アイマス デレステ攻略まとめwiki【アイドルマスター シンデレラガールズ スターライトステージ】 - Gamerch

縦の線が時間軸になっており、この画像では横線も記されています。ゲーム上ではこの横線は表示されていません。ノーツとノーツの距離を横線で区切ることにより、タイミングを分かりやすくしているのです。

「楽譜で音ゲー」は「デレステのゲーム上でもこの横線を表示したい」と同じ感じです。記号を足すために、西洋音楽の楽譜の書式を使用します。

楽譜が最初から存在するということ

音ゲーの譜面は、第三者が譜面に起こしたり、またはシミュレーションできるソフトが開発されていたり、ゆっくり練習するための動画が公開されていたりします。

そうではなく、初めから公式が楽譜を提供するべき、という思いがあります。MuseScore は、楽譜作成ソフトも出していますが、楽譜共有のプラットフォームでもあります。そういった場所を活用したいと考えています。なので、「楽譜で音ゲー」はしくみ上、初めから MuseScore に楽譜があるし、ゆっくり再生も可能、第三者が楽譜を通してノーツを作成することもできます。

レーンが横である理由

今回作成したものは、ノーツは右から左に流れる太鼓の達人方式で、タップする場所は下の方にあります。五線譜が横書き、ピアノの鍵盤が横向きなので、それを連想しています。右側をタップすると、一番上のレーンが反応します。これなんですが、プレイしにくいという意見がありますorz(3つのレーンで音の高さに違いが無いからとか)

最近 MuseDash という音ゲーを遊びました。これも太鼓の達人方式なのですが、右側をタップすると上ではなく下のレーンが反応します。このレーンとタッチ場所についてですが、自分としてはピアノの鍵盤と五線譜を表現したいのですが、ゲーム性を優先するべきかもしれません。

f:id:miso_soup3:20180727161946p:plain

(MuseDashのスクショ)

休符の矛盾

この「楽譜で音ゲー」には矛盾が存在します。それは、「ゲームに表示する休符と、ゲームに表示しない休符が存在する」ということです。これにより、音ゲーではなく静止画で見た場合に、リズムの再現が難しくなっています。楽譜なのに…。なぜ表示しない休符があるかというと、音ゲーとしてのゲーム性のために、直感的に表示/非表示にしています。まだルールが存在しないのでバラツキがあります。しかも、表示/非表示を楽譜上ではフェルマータを用いて区別しており、本来の使い方とかけ離れた記譜法になっています。(本当はフェルマータではなく休符にアクセントを付与したかったのですが、MuseScoreが対応していませんでした。仕様的には極稀な使い方なので無理ない。)

f:id:miso_soup3:20180727163208p:plain

楽譜が読めなくても遊べるか?

楽譜が読めなくても、単純に音ゲーとして遊べるゲームを目指しています。ゲーム性を保ちつつ、どこまで楽譜を組み込むことができるか実験しています。

また、この仕組みを利用して「このゲームにより楽譜が読めるようになる」ことは期待しつつも、副産物としての扱う予定です。

Callable HTTPS Function(Cloud Function for Firebase)のデバッグとテスト

2018/4頃にCloud Functions for Firebase SDK v1.0 がリリースされました。追加された機能の1つに「Callable HTTPS Function」というものがあります。このFunctionのデバッグとテストの方法を記述します。

目次:

まとめ

Callable HTTPS Function の中身は HTTP 関数。関数内でユーザーを特定したい場合は、Callable HTTPS Functions を使った方が良さそう。

  • とりあえず実行したい場合は、ID Token を HTTP Header に格納して HTTP リクエストを送信する
  • テストは、メソッドを切り出した方が楽。あるいはこの Issue の経過を見る。

Callable HTTPS Function とは

Callable HTTPS Function とは、ドキュメントにも記載されていますが、Cloud Function に従来ある HTTP(S) 関数を、クライアントから容易に呼び出せるようにしたものです。容易に呼び出せるというのは、今までは HTTP 関数を呼び出すには、クライアント側で HTTP リクエストを組み立てて送信しますが、それを Firebase SDK がやってくれるということになります。例えば、iOS では Swift で以下のように記述すると Callable HTTPS Functions を呼び出せます。

functions.httpsCallable("addMessage").call(["text": inputField.text]) { (result, error) in
 //...
}

HTTP 関数との違いの1つは、リクエスト送信時に Firebase Auth・FCMトークンの認証情報が自動的に追加され、サーバー側で検証してくれることです。これは次のようなケースの場合に便利です——「iOS からの呼び出しを想定し、 Cloud Functions 内で送信元の Firebase Auth で認証されたユーザーを特定して処理を行う」といったものです。このケースではHTTP 関数ではなく Callable HTTPS Function を選択した方が良さそうです。(HTTP 関数で行う場合は、このサンプルコードのように記述します。)

中身は HTTP 関数なので、SDK を経由せずとも 純粋にHTTP 送信で呼び出すことができます。ただし、一定のルールに合わせる必要があります。そのルールは以下の通りです。

  • HTTP Method は POST、Content-Type は application/json
  • IDトークンは HTTP Header に、キー:Authorization、値: Bearer {idtoken} で埋め込む
    • この Authorization は無くても Cloud Function はエラーを発生せずに処理します(その代わりサーバー側ではユーザーを特定できません)。
  • HTTP Request の Body には、"data" キーに送信したいオブジェクトを格納する

詳細は以下にあります。

Callable HTTPS Function の準備

ここでは、TypeScript で Function を書きます。以下のドキュメントを参考にし、Callable HTTPS Function を定義します。

次のように、ユーザーのIDとメッセージを返す Function を定義しました。2つのファイルに分けた理由は、後述のテストのためです。

/src/index.ts:

import * as functions from 'firebase-functions';
import * as httpFunc from './httpfunc';

export const myFunc = functions.https.onCall(httpFunc.myFunc);

/src/httpfunc.ts:

import * as functions from 'firebase-functions';

export function myFunc(data: any, context: functions.https.CallableContext) {
  
  console.log(data);
  let uid = "";
  if (context.auth != null) {
    uid = context.auth.uid;
  }
  return {
    uid: uid,
    message: "yah!"
  };
};

デバッグ・実行

firebase serve を実行し、ローカルで実行した後、Postman などの HTTP ツールにて、次のように HTTP リクエストを送信します。

送信する HTTP リクエスト:

POST /***firebase の project id***/us-central1/myFunc HTTP/1.1
Host: localhost:5000
Content-Type: application/json
Authorization: Bearer ***IDトークン***

{ "data": { "a": "b" } }

Body { "a": "b" } は適当な値です。*** で囲んだ部分は自分の環境に合わせて置換します。IDトークンの取得方法ですが、例として次のように iOS 側で実行し、手動で取得するという方法があります。(あらかじめ Firebase Authentication にユーザーが登録されていることが前提です)

import Firebase
//...
if let user = Auth.auth().currentUser {

            user.getIDToken { (token, _) in
                if let token = token {
                    logger.debug("idToken: \(token)")
                } else {
                    logger.debug("no idToken")
                }
            }
}

送信すると、次のようにレスポンスが返ってきます。

返ってきた HTTP レスポンス:

{
    "result": {
        "uid": "1hVPnUmHVoQdFWKT64amzGTqN013",
        "message": "yah!"
    }
}

テスト

HTTP 越しのテストは実用的ではなさそう

先の実行では、手動でIDトークンを取得しているのでやや面倒です。テストも行えるようにコードから実行するには、Cloud Functions の単体テスト  |  Firebaseを参照して HTTP 関数のテストと同じように記述したいところです。 ただし、Callable HTTPS Function を HTTP 関数と同じようにテストするには、以下の難点があったので見送りました。

  • IDトークンを HTTP Header に設定する必要がある。(Firebase Admin SDK で特定ユーザーのIDトークンを取得する方法がほぼ無い。Client SDK と同じように認証してからID トークンを取得する必要がある。)

これは Function 内でユーザーを特定する場合の話です。ユーザーを特定しない場合は、ID トークンが不要なので HTTP 関数と同じようにテストを記述できます。しかし、HTTP Request オブジェクトを先述のルールに合わせたり、いろいろモックのメソッドを用意する必要があるため、実用的ではありません。また、テストコード上でユーザーを認証させてIDトークンを取得する方法もありますが(参照:node.js - Testing callable cloud functions with the Firebase CLI shell - Stack Overflow、後述の方法と比べるとこれも実用的ではありません。

また、firebase-functions-testライブラリでは、Callable HTTPS Function のテストをサポートする予定なので、それを待つのも手です。(参照:Unit testing HTTP Callable functions · Issue #9 · firebase/firebase-functions-test · GitHub

ということで、メソッドを切り出してテストする

ということで、メソッドを切り出してテストします。先のコード(/src/httpfunc.ts)のように、Callable HTTPS Function の記述を、HTTP レイヤーから切り出して (data: any, context: CallableContext) => any | Promise<any> の形で定義しました。テストコードは以下の通りです。

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as fftest from 'firebase-functions-test';
import * as chai from 'chai';
const assert = chai.assert;

const test = fftest({
    databaseURL: 'https://***.firebaseio.com',
    storageBucket: '***appspot.com',
    projectId: '***'
  }, `${__dirname}/../../../***.json`);

import * as myFunctions from '../index'
import * as myHttpFunctions from '../httpfunc'

describe(`myFunc`, () => {
    it("返ってくるobjectの検証", () => {

        const data: any = {
        };

        const context: any = {
            auth: {
                uid: "0123"
            }
        };

        const response = myHttpFunctions.myFunc(data, context);
        
        assert.equal(response.uid, "0123");
        assert.equal(response.message, "yah!");
    });
});

これで、対象のユーザーのIDを指定することでテストが可能になります。切り出しのレベルは、テストの目的に合わせて変わるでしょう。