ドリコムのゲームアプリに関わるエンジニアの一部は、社内での取り組みとして、技術的な挑戦の機会としての活動時間が与えられておりました。その場で本稿を執筆した私たち2名でチームを組み、サウンドの表現技術についての研究のため「音楽ゲーム」(通称 : 音ゲー)の開発を行いました。本稿ではこの活動でどのようなことをしたのかについて、簡単に述べたいと思います。

背景

音楽ゲーム開発メンバーとして集まったメンバーは二宮と渡邉の2名でした。私たちは共に業務でUnityを使用しているクライアントエンジニアです。そのため今回はサウンド表現技術の他に、日頃の業務に活かせそうな知識の獲得、またそれらを現場に還元することができればと思い、開発ツールには使い慣れているUnityを選定しました。また最終的に実際に動いて遊べるものができた方がワクワクすると思い、一つの音楽ゲームを制作する方針となりました。

開発の分担として、二宮は音楽と入力すべきタイミング(通称 : ノーツ)の同期処理と画面表示、1楽曲分の情報が含まれるデータ(通称 : 譜面)の作成を担当しました。渡邉は楽曲データに基づき音楽ゲームとして遊ぶための入力判定処理、ゲーム画面としてのUI表示(と作成)を担当しました。以下、二宮と渡邉の担当個所についてそれぞれ述べていきます。

①サウンド同期処理と譜面データ

ノーツを降らせる処理

今回はインゲームをあまり練る時間が取れず、簡素に済ませました。 サウンドの再生自体はUnity標準のAudioコンポーネントを利用しています。楽曲の再生時間はAudioSource.timeから直接取得し、使用します。この時間を使用し、ノーツごとに指定された時間の一定秒前になったら振り落ちるという設計にしました。振り落ちるアニメーションにはiTweenを採用しています。

ここでは特筆すべき点はないので詳細は割愛しますが、これで問題なく動作しました。

譜面データの設計と制作用ツール

データの保存形式

今回は勉強も兼ねてノーツエディタを自作しました。 保存形式をJsonとし、保存方法には手軽に使用できるJsonUtilityを採用しました。

基本設計

譜面制作ツール(以降譜面エディタ)はAudioClip付きの、AudioSourceコンポーネントを所持したPrefabから小節数を算出し、小節線、拍線、クオンタイズ線上でノーツを編集するツールです。
楽曲情報の保持するクラス
 - テンポ(今回は4分音符固定)
 - 拍子情報
 - クオンタイズ
 - 音源情報
譜面エディタの仕組み

クオンタイズについて

【参考】 クオンタイズとは?という方はこちらをご覧ください。
https://en.wikipedia.org/wiki/Quantization_(music)

クオンタイズは、最低限使用しそうな音符を用意しました。譜面エディタでは 1拍の時間 / 音価 からクオンタイズの1分割あたりの時間を算出し、timeを割り当てます。

/// <summary>
/// クオンタイズ(ここでは4分音符を幾つで割るかをintで持つ)
/// </summary>
public enum Quantize
{
    QuarterNote      = 1, // 4分音符
    EighthNote       = 2, // 8分音符
    TripletNote      = 3, // 3連符
    SixteenthNote    = 4, // 16分音符
    SextoletNote     = 6, // 6連符
    ThirtySecondNote = 8, // 32分音符
}

譜面エディタのクオンタイズ機能

譜面データ情報

楽曲情報から、この楽曲(audioClip)が何小節構成か分析します。
(作編曲者から楽曲情報を聞き出せる場合は、この機能は必要ありません。)

// 曲全体の時間(分)
var totalMinutes = audioSource.clip.length / 60;

// bpm(1分間に演奏する拍数) / 1小節の拍数 = 1分間に演奏する小節数
float barsLengthPerMinutes = bpm / beatsInScore.unitPerBar;

// 曲全体の小節数(拍あまりの場合は切り上げでラスト1小節扱い)
barsLength = Mathf.CeilToInt(totalMinutes * barsLengthPerMinutes);

譜面情報の保持について、こちらが降り落ちるノーツの基本情報となります。

public float time;      // 時間
public int   notesType; // enum NoteType 対応
public int   laneIndex; // レーン
public int longNotesType; // enum LongNotesType 対応
public int longNotesGroupIndex; // ロングノーツのグループ

譜面エディタの操作
上図の通り、譜面エディタ上でポチポチとノーツを打った後に保存される、最終的な譜面データのJsonはこのようになります。

{
    "musicPath": "Music/sound1",
    "scoreDataModels": [
        {
            "time": 0,
            "notesType": 0,
            "laneIndex": 1,
            "longNotesType": 0,
            "longNotesGroupIndex": 0,
            "noteIndex": 0
        },
        {
            "time": 0.4225352108478546,
            "notesType": 0,
            "laneIndex": 3,
            "longNotesType": 0,
            "longNotesGroupIndex": 0,
            "noteIndex": 0
        },
        {
            "time": 0.8450704216957092,
            "notesType": 0,
            "laneIndex": 1,
            "longNotesType": 0,
            "longNotesGroupIndex": 0,
            "noteIndex": 0
        },
        {
            "time": 1.2676056623458862,
            "notesType": 0,
            "laneIndex": 3,
            "longNotesType": 0,
            "longNotesGroupIndex": 0,
            "noteIndex": 0
        },
        
        ...etc
    ]
}

同期処理&譜面エディタのまとめ

譜面エディタの動作
上述のフォーマットでデータを書き出すための譜面エディタがこちらになります。Unityエディタ上で、マウスを使用しポチポチとノーツを配置することができます。 直感的に操作ができ、便利に譜面を作成することができます。

以上で、音楽ゲームに必要な譜面データを作成し、それを音楽に合わせて画面上に流すことができるようになりました。

②音楽ゲームの入力処理

基本の思想

音楽ゲームの入力処理として、TouchManagerを実装しました。考えとして、今回開発している音楽ゲーム以外にも流用できるよう、必要なコンポーネントを配置してコードでコールバックを一つ登録すれば使用できる簡単な入力ライブラリとして開発しました。

TouchManagerの構成

入力の検知にはUnity標準のInputクラスを使用することとしました。TouchManagerはテストコードも実装しています。同時押しやフリック入力などはUnityエディタ+マウスでは入力が難しく、確認のためには実際にビルドする必要がありました。ただテストコードを用意することでこれらの動作確認を容易にすることができました。

スクリプト内で完結する仮想入力の実装

今回主に工夫した点として、画面タッチなどの入力を全てスクリプト上で表現できるVirtualTouchDetectorを実装している点です。基本的な処理はUnityEngine.Input.touchesで取れるTouch構造体情報として処理するTouchDetectorを継承しているため同一ですが、Touch構造体の入力情報をスクリプト上で生成する点が異なっています。下記のように、最低限のTouch情報をスクリプト側から生成しています。

public VirtualTouch(Vector2 position, bool isTrigger)
{
    touch = new UnityEngine.Touch();
    touch.fingerId = ++latestFingerId;
    touch.phase = TouchPhase.Began;
    touch.position = position;
                
    this.processedFrameCount = FrameCount;
    this.isTrigger = isTrigger;
}

こうしてスクリプトから生成された入力VirtualTouchは、以下のようにUpdateメソッドを呼ぶたびに状態が更新されます。フリック入力等を再現するため、SetMoveメソッドで毎フレーム更新時の入力移動も設定できます。

/// <summary>
/// 毎フレーム実行する処理
/// 入力が終了した場合はtrueを返す
/// </summary>
public bool Update()
{
    // 処理フレームを比較し、同一フレームの場合に何もしない
    if(processedFrameCount == FrameCount)
    {
        return false;
    }
    processedFrameCount = FrameCount;
            
    // トリガー入力の場合、即座に離す
    if(isTrigger)
    {
        if(touch.phase == TouchPhase.Began)
        {
            touch.phase = TouchPhase.Ended;
            return false;
        }
    }
                
    // 入力終了から1フレーム経過した場合に入力を無効化
    if(touch.phase == TouchPhase.Ended)
    {
        touch.phase = TouchPhase.Canceled;
        return true;
    }
                
    // 入力の移動処理
    touch.phase = (currentMoveCount == 0) ? TouchPhase.Stationary : TouchPhase.Moved;
    if(touch.phase == TouchPhase.Moved)
    {
        touch.position += moveInfoList[0].speed;
        if(--currentMoveCount == 0)
        {
            // 移動終了
            moveInfoList.RemoveAt(0);
            if(moveInfoList.Count > 0)
            {
                // 次の移動を開始
                currentMoveCount = moveInfoList[0].count;
            }
        }
    }
                
    return false;
}

/// <summary>
/// 入力を移動させる
/// </summary>
public void SetMove(int moveCount, Vector2 moveSpeed, bool isOverwrite)
{
    if(moveCount <= 0)
    {
        throw new ArgumentException();
    }
            
    // 移動情報を上書きする場合
    if(isOverwrite)
    {
        moveInfoList.Clear();
        moveInfoList.Add(new MoveInfo(moveCount, moveSpeed));
        currentMoveCount = moveCount;
        return;
    }
            
    // 現在の移動終了後に追加で移動させる場合
    moveInfoList.Add(new MoveInfo(moveCount, moveSpeed));
    if(currentMoveCount == 0)
    {
        // 移動していない場合はすぐに移動開始
        currentMoveCount = moveCount;
    }
}

テストについて

基本的な実装は上記の通りで、入力情報をスクリプトから生成することができます。通常のタッチ入力検知クラスを継承しているため、その他の部分はタッチ入力と変わりません。後はこれを利用してテストコードを書いていきます。例えばスライドノーツのテストコードは以下のようになります。Updateメソッドは、上述の各入力に対しUpdateメソッドを呼び出し入力を更新する処理です。UnityのMonoBehaviourのUpdateに依存せず、手動で更新をかけられるようにしていることで、全てをスクリプト側から制御できる設計となっております。

/// <summary>
/// 長押し中に入力場所を移動させ、異なる領域で離した場合のテスト
/// </summary>
[Test] public void TestMovingHold()
{
    // ---入力---

    int fingerId = VirtualTouchDetector.EnterTouch(GetInputAreaCenterPosition(0));
    Update();

    VirtualTouchDetector.MoveTouch(fingerId, 10, Vector2.right * 10, true); // 10フレームかけて一つ右の領域へ
    Update(10);

    VirtualTouchDetector.ExitTouch(fingerId);
    Update();
            
    // ---判定---
            
    AssertEventMatchByFrameCount( 0, 0, TouchState.Enter, FlickType.Neutral);
    AssertEventMatchByFrameCount( 1, 0, TouchState.Stay, FlickType.Right);      // 移動し始めた
    AssertEventMatchByFrameCount( 5, 1, TouchState.Stay, FlickType.Right);      // 隣の領域へ入った
    AssertEventMatchByFrameCount(11, 1, TouchState.Exit, FlickType.Neutral);    // 隣の領域で入力が離された
}

単押しや同時押し、長押し、フリック入力それぞれについてのテストコードは、いずれもUnityに標準でついているTestRunnerを使用して実行します。動作としても問題無く、手軽に利用できます。
TestRunnerを使用したテスト
ちなみに、実際にタップ入力を行う確認用シーンも実装しているため、マウス入力でも同様に動作確認できるようになっています。
動作確認用シーン

入力処理のまとめ

こんな感じで、実際に何度も実機ビルドをすることなく動作確認を行うことができました。また実装自体は比較的複雑になってしまうため、このようなテストコードがあれば何か部分的な変更を加えても、他に影響が出ないか確認でき安心できます。テストは基本的に「入力と期待する出力」がわかっていれば書けるので、UI入力が絡むものでも工夫次第で実装できます。普段テストを書かない方はぜひやってみてください。テストを書けるよう意識しながら実装するだけでも、自然とメソッドがまとまったりと綺麗な設計になるのではないかと思っています。

できあがり

Unityエディタ上でプレイ中の様子
こちらがプレイ中の様子です。Unity上でマウスを使用し、頑張って操作しています。画面下部の黒い線上にノーツが重なった際、緑色のノーツはシングルタップ、三角形のノーツは三角形の向きにフリックすることで判定されます。またスマートフォンやタブレット端末にインストールすることで、実機でのプレイも可能です。

おわりに

今回の開発は3ヶ月程度の短い期間かつ普段の業務の傍らで行うものだったので、音楽ゲームの基本的な部分の実装のみで終わってしまいました。音楽ゲームによくある「長押し」「スライド」といった入力取得や判定が意外と複雑で、想定よりも時間を取られてしまいました。ただ前述の通り、一通り遊べる状態にすることができ、一定の達成感やサウンドの扱いに対する知見、普段一緒に活動しないメンバー同士との開発といった経験を得ることができました。エンジニアとして日々の業務以外に自主的にインプットできる貴重な時間を会社から与えて頂いたと思います。

About the Author

二宮 &渡邉

二宮 & 渡邉

ninomiya_kazuki
2017年ドリコムへ中途入社。
ゲームプログラマとしてプロダクトの開発、運用に携わっています。
---
わたなべけんと
2018年新卒のクライアントエンジニア
中学生時代よりC/C++でゲーム開発を行うようになり、現在はドリコムの運用プロジェクトのメンバーとしてゲーム開発に従事。