メインコンテンツへスキップ

【リズムゲームの作り方】 #9 判定したノーツを消す

目次
リズムゲームの作り方 - シリーズ
Part 9: この記事

前回は、ノーツを叩いたときに判定結果を求めてコンソール上に表示する機能を作りました。

判定が終わったノーツを消す
#

前回シーンを再生したときに、ノーツを叩いても叩いたノーツが消えなかったことに気づいたでしょうか。これは、本来必要な「ノーツが判定されたあとにそのノーツを消す処理」がないために起こっています。

実はこれまで、見た目だけでなく内部的(データ上)のノーツを消す処理も入れていませんでした。そのため、連続でキーを押すと同じノーツに対して何度も判定されてしまう状態になっていました。

これらを解消するために、

  1. 判定されたノーツと対応するゲームオブジェクトを消す
  2. 判定されたノーツを内部のNotesListから消して判定対象から外す

という2つのことを行う必要があります。

NoteInfoに対応するゲームオブジェクトの情報を追加する
#

「1. 判定されたノーツと対応するゲームオブジェクトを消す」という機能を実装するには、NoteInfoをもとにして画面のオブジェクトを消す必要があります。そのため、まずはNoteInfoとそのNoteInfoに対応したノーツのゲームオブジェクトを紐づける必要があります。

まず、NoteInfoの内容を書き換えて、GameObjectを保存できるようにしておきます。

NoteInfo.cs
1using UnityEngine;
2
3public class NoteInfo
4{
5    public float time;
6    public int lane;
7    public GameObject gameObject;
8}

変数gameObjectの定義に加え、GameObjectを使うのに必要なusing UnityEngine;も追加しました。

次に、NoteInfoの作成時にgameObjectを追加する処理をNotesGeneratorクラスのGenerateNotesメソッドに追加します。

NotesGenerator.cs
 1using System;
 2using System.Collections.Generic;
 3using UnityEngine;
 4
 5public class NotesGenerator : MonoBehaviour
 6{
 7    // ...(省略)
 8
 9    void GenerateNotes(string[] chart)
10    {
11        for (int line = 0; line < chart.Length; line++)
12        {
13            string currentLine = chart[line];
14            for (int laneIndex = 0; laneIndex < 4; laneIndex++)
15            {
16                if (currentLine[laneIndex] == '1')
17                {
18                    // 座標を計算
19                    float time = 60.0f / bpm * line;
20                    float posZ = time * noteSpeed;
21                    float posX = laneIndex - 1.5f;
22                    Vector3 notePosition = new(posX, 0.0f, posZ);
23
24                    // ノーツを生成
25                    GameObject noteObj = Instantiate(note, notePosition, Quaternion.identity, transform);
26
27                    // NoteInfoを作成
28                    NoteInfo noteInfo = new() { time = time, lane = laneIndex + 1, gameObject = noteObj };
29                    noteInfoList.Add(noteInfo);
30                }
31            }
32        }
33    }
34}

これでNoteInfoとゲームオブジェクトを紐づけることができました。

対応するゲームオブジェクトの削除と、リストからの削除
#

次は、JudgementManagerクラスのJudgeメソッドを修正し、ノーツの判定が行われたときに以下の2つの処理を行うようにします。

  1. 判定されたノーツと対応するゲームオブジェクトを消す ノーツのゲームオブジェクトを非表示(SetActive(false))にする
  2. 判定されたノーツを内部のNotesListから消して判定対象から外す 判定されたノーツをnoteInfoListから削除(Remove)する

処理を追加してみましょう。

JudgementManager.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class JudgementManager : MonoBehaviour
{
    // ...(省略)

    void Judge(int lane)
    {
        // 最も近いノーツを探す
        NoteInfo nearestNote = FindNearestNote(lane);

        // もしノーツが見つからなかったら何もしない
        if (nearestNote == null)
        {
            return;
        }

        // ノーツのベストタイミングを渡して、判定評価を取得する
        string evaluation = GetNoteEvaluation(nearestNote.time);

        // 判定対象外(NonTarget)でなければ、判定結果をコンソールに表示し、対応するノーツを削除する
        if (evaluation != "NonTarget")
        {
            Debug.Log($"レーン{lane}: {evaluation}!");
            nearestNote.gameObject.SetActive(false);
            noteInfoList.Remove(nearestNote);
        }
    }

    // ...(省略)
}

以下の2行を追加しました。

  • nearestNote.gameObject.SetActive(false);

    Unityの機能で、オブジェクトを「非アクティブ(無効)」にして画面から見えなくしています。

  • noteInfoList.Remove(nearestNote);

    リストから判定が終わったノーツのデータを取り除く処理です。もしこれを忘れると、「画面からは見えないが、データ上はそこにノーツが存在し続ける」という状態になってしまいます。連続で何度もキーを押すと、見えないノーツが何度も判定されてしまうバグ(2重判定など)を防ぐために必要な処理です。

保存してUnityエディタからシーンを再生してみましょう。ノーツを叩くと同時に画面から消え、連打しても1回しか判定されなくなっていれば成功です!

ロストノートの削除
#

今の状態では、キーを押したときにだけ判定をするようになっているので、キーを何も押さずにノーツが判定線を通過した場合は、ノーツが判定されないまま残ってしまっています。これは本来であればMissとして処理する必要があります。

そこで、判定線を過ぎ、Miss判定よりも離れた場合はMissとして判定してノーツを削除できるようにしていきます。

実装
#

ノーツがMiss判定を超えて通り過ぎてしまった場合にMissにする処理を書いていきます。

これは、Updateメソッドで毎フレーム

  1. Missになるノーツがないか確認し、
  2. Miss判定の0.15秒を超えているノーツがあればMissとして判定する

とすれば良さそうです。

JudgementManagerクラスのGetNoteEvaluationメソッドの下にJudgeLostNoteメソッドを作ります。

JudgementManager.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class JudgementManager : MonoBehaviour
{
    List<NoteInfo> noteInfoList = new();
    NotesMover notesMover;

    void Start()
    {
        noteInfoList = GetComponent<NotesGenerator>().noteInfoList;
        notesMover = GetComponent<NotesMover>();
    }

    void Update()
    {
        JudgeLostNote();

        // 現在のキーボード情報
        var current = Keyboard.current;

        // キーボードが接続されていなければ無視
        if (current == null)
        {
            return;
        }

        // Dキーが押された瞬間なら
        if (current.dKey.wasPressedThisFrame)
        {
            Judge(1);
        }

        // Fキーが押された瞬間なら
        if (current.fKey.wasPressedThisFrame)
        {
            Judge(2);
        }

        // Jキーが押された瞬間なら
        if (current.jKey.wasPressedThisFrame)
        {
            Judge(3);
        }

        // Kキーが押された瞬間なら
        if (current.kKey.wasPressedThisFrame)
        {
            Judge(4);
        }
    }

    // ...(省略)
    // (JudgeLostNoteはGetNoteEvaluationメソッドの下に追加)

    void JudgeLostNote()
    {
        // 1. スルーされた(Missになった)ノーツを探してリストに一時保存する
        List<NoteInfo> lostNotes = new();
        float elapsedTime = Time.time - notesMover.startTime;

        foreach (NoteInfo noteInfo in noteInfoList)
        {
            float diffTime = elapsedTime - noteInfo.time;
            // 判定線を過ぎていて、かつ判定範囲外まで離れてしまったノーツ
            if (diffTime > 0.0f && GetNoteEvaluation(noteInfo.time) == "NonTarget")
            {
                lostNotes.Add(noteInfo);
            }
        }

        // 2. 見つかったノーツをMiss扱いにして画面とリストから消す
        foreach (NoteInfo lostNote in lostNotes)
        {
            Debug.Log($"レーン{lostNote.lane}: Miss!");
            lostNote.gameObject.SetActive(false);
            noteInfoList.Remove(lostNote);
        }
    }
}

まず各ノーツに対してベストタイミングとのズレを計算して、判定線を過ぎていてかつ判定範囲外まで離れたノーツを調べています。 diffTime > 0.0f とすることで、「まだ届いていないノーツ」ではなく「すでに通り過ぎてしまったノーツ」だけを調べるようにしています。

対象となるノーツ(ロストノーツ)を見つけたら、とりあえず lostNotes というリストに一時保存して追加しています。

そして後半では、見つかったロストノーツに対して、これらをMissと判定してノーツを消す処理を行います。コンソールに表示するメッセージに Miss! と直接書き込んでいる以外は、記事前半で書いた処理とほとんど同じですね。

Q. なぜわざわざ別のリスト(lostNotes)に入れ直しているのか?

「1つ目のforeachの中で見つけたら、その場ですぐにRemoveすればいいのでは?」と思うかもしれません。

しかし、C#のルールとして「foreachでリストを順番に調べている最中に、そのリスト自体から要素を削除(Remove)してはいけない」ことになっています。(実行するとエラーになります)

そのため、「消す予定のノーツ」を一度lostNotesという別のリストに書き出しておき、あとでまとめて消すという手順を踏んでいます。

保存して実行してみましょう。キーを叩かずにノーツをスルーすると、コンソールにMiss!と出るはずです。

まとめと次回予告
#

これで、リズムゲームの基礎となる仕組みが完成しました!

ステージの作成から始まり、譜面の生成、曲の再生、キー入力、そして今回の判定処理と、複雑な機能も多かったですが、ついにノーツを叩いて遊べる状態になりましたね。ここまで本当にお疲れ様でした!

しかし、今はまだコンソール画面に文字が出ているだけで、ゲーム画面上では自分が上手く叩けたのかどうかが分かりません。

次回は、画面に「Perfect」や「Miss」といった判定結果を表示する機能や、スコア・コンボの計算機能を実装し、より本格的なリズムゲームへと仕上げていきます!

リズムゲームの作り方 - シリーズ
Part 9: この記事