🎵

Unity で 5 レーン音ゲーを自作した — 判定設計からオブジェクトプールまで

に公開

はじめに

本記事は2018年に開発した PC 向けの 5 レーン音楽ゲーム「BakuSou」の振り返り記事です。
Unity 2018 + C# で約 1 年(166 コミット)かけて個人開発しました。

BakuSou

ノーツが画面奥から手前に流れてきて、判定ラインに重なった瞬間にキーを押す——音ゲーの基本的な仕組みです。

しかし、実際に作ってみると「BPM からノーツの出現時刻をどう計算するか」「判定の精度をどう担保するか」「大量のノーツを GC なしでどう管理するか」など、音ゲー特有の技術課題がいくつも出てきます。

この記事では、BakuSou の実装を通じて得た音ゲー開発の知見を、実際のコードと共にまとめます。


全体設計

シーン構成

Top(タイトル)→ Menu(曲選択・設定)→ Game(プレイ)→ Result(リザルト)
                    ↑                                        │
                    └────────────────────────────────────────┘
シーン 役割
Top タイトル画面。Start ボタンで Menu へ遷移
Menu 曲選択(スクロールビュー)、プレビュー再生、設定(速度・キーバインド・音量)
Game ゲーム本体。ノーツ生成・落下・判定・スコア計算
Result ランク表示、スコア内訳、ハイスコア保存

クラス構成

GameParameter(Singleton — グローバル状態)
├── musicDatas[]         全楽曲データ
├── selectMusicDataId    選択中の曲
├── gameMode             "play" / "replay" / "demo"
├── keyLog[]             リプレイ用入力記録
└── result               現在のプレイ結果

Game シーン
├── GameManager          シーンライフサイクル管理
├── NotesManager         ノーツのプール・生成・判定呼び出し
├── InputController      入力処理(3 モード分岐)
├── ScoreBoard           スコア・コンボ表示
├── Note                 個々のノーツ(位置計算・判定)
├── AudioManager         BGM 再生
├── SoundManager         効果音(タップ音)
├── VoiceManager         ボイス再生
├── NotesLineEffect[5]   レーンフラッシュ
└── PushNeonEffect[5]    ネオンバースト

Singleton でグローバル状態を管理し、各 Manager がそれぞれの責務を持つ構成です。Game シーン内は Presenter パターン(MVP-lite)で、入力 → 判定 → 表示更新の流れが分離されています。


1. 判定・タイミング設計

音ゲーの核心は「プレイヤーの入力がどれだけ正確か」を判定する仕組みです。

判定窓

まず、判定の時間幅を定数として定義します。

// Constants.cs
static class Constants
{
    public static double JUDGE_GREAT_TIME = 0.1;   // ±100ms
    public static double JUDGE_GOOD_TIME = 0.15;   // ±150ms
    public static double JUDGE_BAD_TIME = 0.2;     // ±200ms

    public static float JUDGE_OFFSET_TIME = 0.00f;
}

判定窓はノーツの理想タイミングを中心とした前後の時間幅です。プレイヤーがキーを押した時刻と、ノーツの理想タイミングの差で判定が決まります。

判定窓の図

判定の実装

各ノーツが自分の判定結果を返します。

// Note.cs
public Judgement Judge()
{
    float currentTime = AudioManager.Instance.GetTime() + Constants.JUDGE_OFFSET_TIME;
    double diff = System.Math.Abs(currentTime - timing);

    // 内側(GREAT)から外側(BAD)へ順に判定
    if (diff < Constants.JUDGE_GREAT_TIME)
        return Judgement.GREAT;

    if (diff < Constants.JUDGE_GOOD_TIME)
        return Judgement.GOOD;

    if (diff < Constants.JUDGE_BAD_TIME)
        return Judgement.BAD;

    // 判定窓の外側: まだ来ていないか、通過済みか
    if (currentTime < timing)
        return Judgement.EARLY;

    return Judgement.LATE;
}

ポイントは GREAT → GOOD → BAD の順に内側から判定している ことです。狭い範囲から先にチェックすることで、正確な打鍵が確実に GREAT になります。

また、基準時刻は AudioManager.Instance.GetTime()、つまりオーディオの再生時刻です。Time.time(フレームベースの時刻)ではなく、オーディオクロックを使うことで、フレームレートの揺らぎに左右されない安定した判定を実現しています。

スコア計算

// ResultData.cs
public int Score
{
    get
    {
        double weighted = Great + Good * 0.5 + Bad * 0.1;
        return (int)(10000.0 * weighted / MaxCount);
    }
}

スコアは 10,000 点満点。GREAT は 1.0 倍、GOOD は 0.5 倍、BAD は 0.1 倍で換算し、総ノーツ数で割ります。全 GREAT なら 10,000 点(SS ランク)です。

ランク 条件 意味
SS 10,000 全 GREAT(パーフェクト)
S 9,000 以上 ほぼ完璧
A 7,000 以上 クリア
B 5,000 以上 ギリギリ
C 5,000 未満 不合格

コンボ

// ResultData.cs
private void ComboCount(Judgement judge)
{
    if (judge == Judgement.GREAT || judge == Judgement.GOOD)
    {
        Combo++;
        if (Combo > MaxCombo)
        {
            MaxCombo = Combo;
        }
    }
    else
    {
        Combo = 0;
    }
}

GREAT か GOOD ならコンボ継続、BAD・LATE でリセット。最大コンボ数を記録しておき、リザルト画面で表示します。


2. 譜面データ設計

音ゲーにおいて、「どの時刻にどのレーンのノーツを出すか」を定義する譜面データは非常に重要です。

LPB ベースの譜面フォーマット

BakuSou の譜面は JSON 形式で、LPB(Lines Per Beat) という単位を使います。これは楽曲制作ソフト(DAW)の概念で、「1 拍を何分割するか」を表します。

{
  "name": "ezosere",
  "maxBlock": 5,
  "BPM": 80,
  "offset": 0,
  "notes": [
    { "LPB": 4, "num": 16, "block": 1, "type": 1, "notes": [] },
    { "LPB": 4, "num": 20, "block": 1, "type": 1, "notes": [] },
    { "LPB": 4, "num": 24, "block": 4, "type": 1, "notes": [] }
  ]
}
フィールド 意味
BPM テンポ(Beats Per Minute)
LPB 1 拍の分割数(4 なら 16 分音符単位)
num LPB 分割での位置番号
block レーン番号(0〜4)

BPM → 秒への変換

譜面データの LPBnum から、ノーツの絶対時刻(秒)を計算します。

// MusicDTOFormatter.cs — 譜面データ → タイミング配列の変換
foreach (var item in dto_data.notes)
{
    double lpb = item.LPB;
    double num = item.num;
    int block = item.block;

    // 1拍あたりの時間(秒)
    double section = 60 / (double)dto_data.BPM;
    // LPB で分割した 1 単位の時間
    double beat = section / lpb;
    // 絶対時刻
    timing[j] = beat * num;
    key[j] = block;
    j++;
}

具体例で計算してみます。

BPM = 80, LPB = 4, num = 16 の場合:

1拍の時間 = 60 / 80 = 0.75 秒
1分割の時間 = 0.75 / 4 = 0.1875 秒
ノーツの時刻 = 0.1875 × 16 = 3.0 秒

→ 楽曲開始から 3.0 秒後にノーツが判定ラインに到達する

この変換を全ノーツに適用し、timingArray(時刻)と keyArray(レーン番号)の 2 つの配列として保持します。ゲーム中はこの配列を先頭から順に消費していきます。


3. オブジェクトプール

音ゲーでは大量のノーツが次々と生成・破棄されます。Unity で毎フレーム Instantiate / Destroy を繰り返すと GC(ガベージコレクション)が発生し、フレーム落ちの原因になります。

ノーツプール(50 個)

// NotesManager.cs
private List<Note> notePool;
private List<Note> activeNoteList;
private int poolSize = Constants.NOTE_POOL_SIZE;  // 50
private int poolIndex = 0;

ゲーム開始時に 50 個のノーツオブジェクトを事前生成し、画面外に配置しておきます。

// NotesManager.cs — 初期化
for (var i = 0; i < poolSize; i++)
{
    GameObject noteObject = Instantiate(
        notePrefab,
        new Vector3(0, -100, -100),  // 画面外
        new Quaternion(0.0f, 180.0f, 0.0f, 1.0f)
    );
    Note note = noteObject.GetComponent<Note>();
    note.InitNote();
    note.fallDist = fallDist;
    note.fallDistOffset = fallDistOffset;
    note.fallSpeed = fallSpeed;
    note.fallTime = fallTime;
    notePool.Add(note);
}

循環探索による再利用

非アクティブなノーツをプールから取り出すとき、先頭から毎回探索するのではなく、前回の使用位置から循環的に探索します。

// NotesManager.cs
Note GetPassiveNote()
{
    for (int i = 0; i < poolSize; i++)
    {
        Note note = notePool[(poolIndex + i) % poolSize];
        if (!note.GetActive())
        {
            poolIndex = (i + 1) % poolSize;
            return note;
        }
    }
    return null;  // プール枯渇
}

オブジェクトプールの循環探索

poolIndex がカーソルの役割を果たし、直前に返したノーツの次の位置から探索を開始します。プール内のノーツは「使用 → 非アクティブ化 → 再利用」のサイクルが時間順に進むため、この循環探索はほぼ O(1) で非アクティブなノーツを見つけられます。

ノーツの出現と消滅

// NotesManager.cs — Update()
void Update()
{
    // 判定ラインを通過したノーツを除去 → LATE 判定
    for (int i = 0; i < activeNoteList.Count; i++)
    {
        if (!activeNoteList[i].GetActive())
        {
            scoreBoard.AddScore(Judgement.LATE);
            activeNoteList.Remove(activeNoteList[i]);
        }
    }

    // 次のノーツの出現タイミングに達したら、プールから取り出して配置
    if (noteIndex < timingArray.Length)
    {
        if (AudioManager.Instance.GetTime() > (timingArray[noteIndex] - fallTime))
        {
            NoteAdd(noteIndex++);
        }
    }
}

ノーツの出現タイミングは 理想タイミング - 落下にかかる時間 です。落下時間は速度設定と BPM から算出されます。

// NotesManager.cs — 落下時間の計算
float fallSpeed = (3 / Constants.DEFAULT_FALL_SPEED / (float)settingSpeed)
                * (120.0f / musicData.Bpm);
fallTime = fallSpeed / fallDist * 100;

カラーオブジェクトプール(1,000 個)

ノーツだけでなく、エフェクトの Color 構造体もプールしています。

// NotesLineEffect.cs
private List<Color> colorPool;
private readonly int poolSize = 1000;
private int poolIndex = 0;

void InitColors()
{
    colorPool = new List<Color>();
    for (var i = 0; i < poolSize; i++)
    {
        Color color = new Color(
            defaultColor.r, defaultColor.g,
            defaultColor.b, defaultColor.a
        );
        colorPool.Add(color);
    }
}

Color GetColor()
{
    if (poolIndex < poolSize)
    {
        return colorPool[poolIndex++];
    }
    // プール枯渇時は新規作成にフォールバック
    return new Color(defaultColor.r, defaultColor.g,
                     defaultColor.b, defaultColor.a);
}

レーンフラッシュエフェクトは毎フレーム色を更新するため、renderer.material.color = ... の呼び出し頻度が非常に高い箇所です。Color 自体は値型(struct)なので GC の直接的な対象にはなりませんが、Unity は renderer.material にアクセスするたびに Material インスタンスを暗黙的に複製します。これがヒープオブジェクトとして積み上がり、メモリリークの原因になっていました。プールから事前確保済みの Color を使い回しつつ、Material の不要な複製を避けることで問題を解消しています。


4. リプレイ / デモシステム

BakuSou には 3 つのゲームモードがあります。

モード 用途
play プレイヤーがキーボードで操作
replay 過去のプレイを入力ログから再生
demo 全ノーツを理想タイミングで自動再生(メニュー画面のプレビュー用)

入力ログの記録

play モードでは、プレイヤーの入力を inputLog に記録します。

// 入力記録の構造体(値型なのでアロケーション不要)
public struct InputRecord
{
    public float time;
    public int lane;
}
// GameParameter.cs — 入力ログの記録
private List<InputRecord> inputLog = new List<InputRecord>();

public void InitInputLog()
{
    inputLog = new List<InputRecord>();
}

public void RecordInput(float time, int lane)
{
    inputLog.Add(new InputRecord { time = time, lane = lane });
}

InputRecord は struct(値型)なので、new してもヒープアロケーションは発生しません。ノーツプールのように参照型オブジェクトの生成・破棄を繰り返すケースとは異なり、値型ならシンプルに new して Add するだけで十分です。

3 モードの入力処理

InputController が 3 つのモードを分岐して処理します。

// InputController.cs — 入力処理の分岐
void ProcessInput()
{
    bool hasInput = false;
    var judges = new Judgement[LaneCount]; // LaneCount = 5
    float currentTime = audioManager.GetTime();

    switch (gameParameter.gameMode)
    {
        case "play":
            // プレイモード: リアルタイム入力
            for (int i = 0; i < keyBindings.Length; i++)
            {
                if (Input.GetKeyDown(keyBindings[i]))
                {
                    gameParameter.RecordInput(currentTime, i);
                    hasInput = true;
                    judges[i] = notesManager.NoteSeek(i);
                }
            }
            break;

        case "replay":
            // リプレイモード: 入力ログを時刻順に再生
            var log = gameParameter.inputLog;
            while (replayIndex < log.Count
                && log[replayIndex].time <= currentTime)
            {
                int lane = log[replayIndex].lane;
                hasInput = true;
                judges[lane] = notesManager.NoteSeek(lane);
                replayIndex++;
            }
            break;

        default:
            // デモモード: 全ノーツを理想タイミングで自動再生
            while (demoIndex < timingArray.Length
                && timingArray[demoIndex] <= currentTime)
            {
                int lane = laneArray[demoIndex];
                hasInput = true;
                judges[lane] = notesManager.NoteSeek(lane);
                demoIndex++;
            }
            break;
    }

    if (hasInput)
        tapSound.Play();
}

3 つのモードは入力ソースが異なるだけで、その後の判定 → スコア計算 → エフェクト再生の流れは全て共通です。

モード 入力ソース 判定
play Input.GetKeyDown() プレイヤーの腕次第
replay inputLog[i].timeinputLog[i].lane 記録通り
demo timingArray[i]laneArray[i] 常にパーフェクト

デモモードは譜面データの理想タイミングをそのまま使うため、常に全 GREAT になります。メニュー画面で曲のプレビューとして自動演奏するのに使っています。

リプレイモードはプレイヤーの入力を録画・再生する仕組みです。inputLog には (時刻, レーン番号) のペアが時系列で格納されているので、オーディオの再生位置と照合しながら順番に消費していくだけです。


開発の振り返り

開発タイムライン

2018年 8月  プロジェクト開始、ロゴ・基本設計
       9月  ノーツの生成・落下・判定の基本実装、セーブ/スコアシステム
      10月  メニュー画面(FancyScrollView)、キャラクターアニメーション
      12月  サウンド・ボイスシステム、設定の永続化

2019年 1月  UI の仕上げ
       5月  リプレイ機能、デモモード
       6月  ポーズ機能
       7月  難易度ソート、フィルタ機能
       8月  メモリリーク対策 → 開発終了

約 1 年、166 コミット。最初の 2 ヶ月で基本的なゲームプレイが動くようになり、残りの 10 ヶ月は「遊べるようにする」ための作業でした。

学んだこと

学んだこと

音ゲー開発を通じて得た教訓は、大きく 3 つに集約されます。

1. 判定の設計 — 「気持ちよさ」はタイミング精度で決まる

判定窓の広さ、オーディオクロック基準の時刻取得、offset による微調整——これらが 10ms 単位で体感に影響します。フレームベースの Time.time で判定を組んでいた初期バージョンは、フレームレートの低下時に判定が不安定になりました。AudioSettings.dspTime を基準にしたことで安定し、リプレイも「入力ソースの差し替え」だけで判定ロジックを共通化できました。

2. 譜面の設計 — データフォーマットは BPM 非依存にすべき

LPB ベースで「何拍目の何分割目」として記述しておけば、BPM 変更時の再計算はローダー側で完結します。譜面データの設計はゲーム全体の拡張性に直結するので、最初に時間をかける価値があります。

3. 性能面 — 改善が目に見えるから楽しい

オブジェクトプールを導入したらカクつきがなくなる、renderer.material の暗黙コピー問題を潰したらメモリ使用量が下がる——ゲーム開発の性能改善は結果がすぐに体感できます。ノーツは「一定時間だけ表示されて消える」ライフサイクルが明確なのでプールとの相性が良く、BakuSou では 50 個で十分でした。実際に遊んで「滑らかになった」と分かるのが、パフォーマンスチューニングのモチベーションになりました。


余談:曲も譜面も自分で作った

実は BakuSou に収録されている曲のほとんどが、自分で編曲したものです。もともと音楽が趣味で、DTM(デスクトップミュージック)で曲を作っていました。「好きな曲で遊べる音ゲーを作りたい」というのが、このプロジェクトの出発点でもあります。

譜面も全て手作業です。前述の NoteEditor で曲を聴きながらノーツを一つずつ配置していきました。曲を作る → 譜面を作る → ゲームで遊ぶ → 「ここのリズムが気持ちよくないな」→ 譜面を調整する。このサイクルを繰り返すのが、プログラミングとはまた違った面白さでした。

技術的にはこの記事で解説した内容が全てですが、個人開発の原動力は「自分が遊びたいものを作る」というシンプルな動機だったと思います。


おわりに

音ゲーの自作は、ゲーム開発の中でも特にリアルタイム処理の精度が求められるジャンルです。判定のタイミング設計、パフォーマンスのためのオブジェクトプール、データ駆動の譜面設計。どれもゲーム開発全般に通じる基礎的な技術ですが、音ゲーではその重要性が際立ちます。

BakuSou は完成度としてはまだまだですが、「自分の好きな曲で遊べる音ゲーを自分で作る」という体験は純粋に楽しいものでした。当時は基本的なゲームプレイが動くまでに 1〜2 ヶ月かかりましたが、今なら Claude Code や Copilot といった AI コーディングツールを使えば、もっと短期間で形にできるはずです。判定ロジックやオブジェクトプールのような定型的な実装は AI が得意とする領域ですし、設計の壁打ち相手としても使えます。興味のある方はぜひ挑戦してみてください。

Discussion