Godotと簡易BMS形式で作る4レーン音楽ゲーム・リズムゲーム(音ゲー)

  1. 第1章 はじめに
    1. 完成イメージ
    2. 目次
  2. 第2章 開発環境を準備する
    1. 手順1: Godotをインストールする
    2. 手順2: プロジェクトを作成する
    3. 手順3: ウィンドウサイズを設定する
    4. 手順4: 入力アクションを登録する
  3. 第3章 譜面フォーマットを設計する
    1. BMS形式とは
    2. 手順1: サポート範囲を決める
    3. 手順2: サンプル譜面を作成する
  4. 第4章 プロジェクト構成とシーンを設計する
    1. 手順1: ディレクトリ構成を決める
    2. 手順2: 全体のデータフローを把握する
    3. 手順3: Main.tscnのノードを配置する
  5. 第5章 BMSパーサーを実装する
    1. 手順1: クラスと基本データを定義する
    2. 手順2: ファイル全体を読む流れを作る
    3. 手順3: 1行を解析する
    4. 手順4: ノーツの時刻を計算する
  6. 第6章 ノーツの生成と落下処理を実装する
    1. 手順0: Main.gd の骨組みを用意する
    2. 手順1: ノーツの状態を持つスクリプトを作成する
    3. 手順2: パース結果からノーツを生成する
    4. 手順3: ゲーム内時刻を計測する
    5. 手順4: ノーツを移動させ、時間切れのノーツをMISSにする
  7. 第7章 キー入力と判定ロジックを実装する
    1. 手順1: 判定幅を決める
    2. 手順2: キー入力を受け付ける
    3. 手順3: 最も近いノーツを探して判定する
  8. 第8章 スコア・コンボ・リザルト表示を実装する
    1. 手順1: 判定に応じてスコアとコンボを更新する
    2. 手順2: 全ノーツ判定後にリザルトを表示する
    3. 手順3: 和音でヒット音が途切れないようにする

第1章 はじめに

このチュートリアルでは、Godot 4を使って4レーン式のリズムゲームを一から作ります。対象読者は、変数・関数・条件分岐といった基礎的なプログラミングの概念は理解しているが、Godotやゲーム開発自体は初めてという方です。

作るゲームの仕様は次のとおりです。

  • 画面中央に4本のレーンがあり、上から下へノーツが流れてくる
  • 画面下部の判定ラインに到達したタイミングで、対応するキー(F G H J)を押す
  • タイミングのずれに応じて GREAT / GOOD / MISS の3段階で判定し、スコアを加算する
  • 譜面は音楽ゲームで広く使われているBMS形式(Be-Music Source)のサブセットで記述する

このチュートリアルを通して学ぶのは、次の2点です。

  • BPMと判定ラインを軸にした、音とタイミングを合わせるアルゴリズム
  • 譜面データという外部フォーマットを読み込み、ゲームロジックに変換する設計

使用エンジンは

  • Godot 4.7.2
  • 言語 GDScript

各章のコードをそのまま入力していけば、最後にはproject.godotから再生できる状態になります。

完成イメージ

4本のレーンをノーツが落下し、画面下部の白い線が判定ラインです。この線にノーツが重なった瞬間に対応するキーを押します。

譜面を最後まで叩き終えると、スコアと判定の内訳が表示されます。

目次

内容
第2章開発環境を準備する
第3章譜面フォーマットを設計する
第4章プロジェクト構成とシーンを設計する
第5章BMSパーサーを実装する
第6章ノーツの生成と落下処理を実装する
第7章キー入力と判定ロジックを実装する
第8章スコア・コンボ・リザルト表示を実装する

第2章 開発環境を準備する

手順1: Godotをインストールする

Godot公式サイトからGodot 4系(本チュートリアルではv4.7.2)をダウンロードします。インストーラは無く、実行ファイル(macOSではGodot.app)を展開すればすぐに使用できます。

手順2: プロジェクトを作成する

Godotを起動し、プロジェクトマネージャから「新規プロジェクト」を選び、任意のフォルダを指定します。作成すると、そのフォルダ直下にproject.godotが生成されます。

手順3: ウィンドウサイズを設定する

4レーンを縦に流れるレイアウトにするため、ウィンドウは縦長にします。「プロジェクト設定」→「表示」カテゴリ→「ウィンドウ」を開き、「サイズ」欄にある以下の項目を設定します。

項目
ビューポートの幅480
ビューポートの高さ720

設定するとproject.godotに次の内容が書き込まれます。

[display]

window/size/viewport_width=480
window/size/viewport_height=720

手順4: 入力アクションを登録する

「プロジェクト設定」→「インプットマップ」で、4レーンに対応する4つのアクションを登録します。キーは中央列のF G H Jを左のレーンから順に割り当てます。

アクション名キー
lane_0F
lane_1G
lane_2H
lane_3J

「lane_数字」を入れて「+追加」をクリックします

追加した行の「+」ボタンを押して

割り当てるキーを①に記載して②を選択して「OK」ボタンをクリックします

キー割当て一覧です

第3章 譜面フォーマットを設計する

BMS形式とは

BMS(Be-Music Source)は、1998年に登場した音楽ゲーム「BM98」で採用された譜面データ形式です。テキストベースで、小節ごとにチャンネル(レーンや音源の種類)とノーツの配置を記述します。行の基本形式は次のとおりです。

#mmmCC:data

mmmは3桁の小節番号、CCは2桁のチャンネル番号、dataは2文字区切りの並びで、1小節をいくつに分割するかと、どこで音源を鳴らすかを表します。

本家のBMS形式はBGM専用チャンネルや譜面分岐、音量・ロングノーツなど多数の拡張仕様を持ちますが、このチュートリアルではその全体を実装せず、必要な部分だけを取り出します。

ファイル全体の構造と、1行の読み方を図にすると次のようになります。

ファイルは大きく、曲全体の設定を書く「ヘッダー部」と、ノーツの位置を書く「譜面データ行」に分かれます。

譜面データ行の1114がそれぞれ4本のレーンに対応し、F G H Jキーにつながります。

手順1: サポート範囲を決める

学習のしやすさを優先し、以下のみをサポートするサブセットとします。

  • ヘッダー行
    • #BPM <値> : 曲のテンポ
    • #WAVxx <ファイル名> : 音源ID(xxは任意の2文字)とファイル名の対応
    • #BGM <ID> : BGMとして再生する音源ID
  • データ行
    • #mmmCC:data のうち、チャンネル1114(4レーン分)のみ対応
  • 拍子は4/4固定、曲中のBPM変化は非対応

この範囲に絞ることで、パーサーが読み取るのはチャンネル4種類とヘッダー3種類だけになり、実装がシンプルになります。

手順2: サンプル譜面を作成する

charts/sample.bmsを作成し、次の内容を入力します。動作確認用の2小節・8ノーツの譜面です。

#BPM 120
#WAV01 hit.wav
#WAVBG bgm_sample.wav
#BGM BG

#00011:01000000
#00012:00010000
#00013:00000100
#00014:00000001

#00111:00000001
#00112:00000001
#00113:00000001
#00114:00000001

1小節目(000)はレーン0→1→2→3の順に4分音符で1つずつノーツが並び、2小節目(001)は4レーン同時に鳴る和音になっています。

ここで押さえておきたいのが、データ文字列の「長さ」が音符の細かさを決めるという点です。01000000は8文字なので2文字ずつ4つに分かれ、1小節が4等分されます。その何番目にノーツを置くかで時刻が決まります。

BPM 120では1小節が2秒(4拍 × 0.5秒)なので、サンプル譜面のノーツの発生時刻は次のようになります。

ノーツ小節分割位置時刻
レーン00000/40.0秒
レーン10001/40.5秒
レーン20002/41.0秒
レーン30003/41.5秒
レーン0〜30013/43.5秒

この時刻計算式は、第5章のパーサー実装でそのままコードに落とし込みます。

第4章 プロジェクト構成とシーンを設計する

手順1: ディレクトリ構成を決める

最終的に次の構成にします。

music-game/
├── project.godot
├── scenes/
│   ├── Main.tscn
│   └── Note.tscn
├── scripts/
│   ├── Main.gd
│   ├── Note.gd
│   └── BMSParser.gd
├── charts/
│   └── sample.bms
├── assets/
│   └── audio/
│       ├── bgm_sample.wav
│       └── hit.wav

譜面ファイルはcharts/、音源はassets/audio/に分け、ゲーム本体のシーンとスクリプトはそれぞれscenes/scripts/にまとめます。

手順2: 全体のデータフローを把握する

実装に入る前に、譜面ファイルからスコア表示までのデータの流れを図で確認します。

sample.bmsはパーサーによってChartNoteの配列に変換され、Main.gdがその配列をもとにノーツを画面に配置します。ノーツは_process内で判定ラインへ向けて移動し、キー入力があった時点で最も近いノーツとの時間差を判定してスコア・コンボ・リザルト表示に反映します。

手順3: Main.tscnのノードを配置する

Main.tscnを作成し、次のノードツリーを組み立てます。

Main (Node2D)
├── Lane0〜Lane3 (ColorRect)      # レーンの背景
├── JudgmentLine (ColorRect)     # 判定ライン
├── NotesContainer (Node2D)      # 生成したノーツの親
├── BGMPlayer (AudioStreamPlayer)
├── HitSoundPlayer (AudioStreamPlayer)
└── UI (CanvasLayer)
    ├── ScoreLabel (Label)
    ├── JudgmentLabel (Label)
    ├── ComboLabel (Label)
    └── ResultLabel (Label)

レーンと判定ラインはColorRectで表現します。ColorRectはControlノードですが、Node2Dの子として配置してもpositionプロパティで座標を指定できるため、今回のような単純な矩形表示であれば問題なく利用できます。

座標の基準となる定数は、次章以降で使うMain.gdにまとめて定義します。

const LANE_WIDTH := 100.0
const LANE_START_X := 40.0
const JUDGMENT_Y := 600.0
const SPAWN_Y := 0.0

レーンnのX座標はLANE_START_X + n * LANE_WIDTHで求まり、ノーツはSPAWN_Y(画面上端)からJUDGMENT_Y(判定ライン)へ向かって移動します。

どのノードが画面のどこに対応するのかを図にすると次のようになります。

第5章 BMSパーサーを実装する

パーサーはBMSParser.gdに実装します。役割は、譜面ファイルを1行ずつ読み、ヘッダー情報と譜面データをChartNoteの配列に変換することです。

手順1: クラスと基本データを定義する

scripts/BMSParser.gdを作成し、次のように書きます。

extends RefCounted
class_name BMSParser

## 1つのノーツを表すデータ。time は曲頭からの秒数。
class ChartNote:
    var time: float
    var lane: int
    var wav_id: String

    func _init(p_time: float, p_lane: int, p_wav_id: String) -> void:
        time = p_time
        lane = p_lane
        wav_id = p_wav_id


## BMSのチャンネル番号 → レーン番号(0が左端)。
const LANE_CHANNELS := {
    "11": 0,
    "12": 1,
    "13": 2,
    "14": 3,
}

const DEFAULT_BPM := 120.0
const BEATS_PER_MEASURE := 4.0

## 譜面データ行のうち "#mmmCC" の部分の長さ(小節番号3桁 + チャンネル2桁)。
const HEAD_LENGTH := 5

var bpm: float = DEFAULT_BPM
var bgm_wav_id: String = ""
var wav_table: Dictionary = {}
var notes: Array[ChartNote] = []

手順2: ファイル全体を読む流れを作る

parse_fileは行ごとに_parse_lineを呼び出し、ヘッダー行はその場でbpmbgm_wav_idwav_tableに反映します。譜面データ行は一旦measure_linesという配列に貯めておき、全行を読み終えたあとに_build_notesでノーツの時刻を計算します。

func parse_file(path: String) -> bool:
    _reset()

    var file := FileAccess.open(path, FileAccess.READ)
    if file == null:
        push_error("BMSファイルを開けませんでした: %s" % path)
        return false

    var measure_lines: Array[Dictionary] = []
    while not file.eof_reached():
        var line := file.get_line().strip_edges()
        if line.begins_with("#"):
            _parse_line(line, measure_lines)
    file.close()

    if bpm <= 0.0:
        push_error("BPMが不正です(0より大きい値が必要): %s" % bpm)
        return false

    _build_notes(measure_lines)

    # 同時刻のノーツはレーン順に並べ、結果を一意に定める
    # (sort_custom は安定ソートではないため、比較関数側で全順序を定義する)。
    notes.sort_custom(_compare_notes)
    return true


static func _compare_notes(a: ChartNote, b: ChartNote) -> bool:
    if a.time != b.time:
        return a.time < b.time
    return a.lane < b.lane

戻り値をboolにしているのは、呼び出し側が読み込み失敗に気づけるようにするためです。ファイルが無い場合やBPMが0以下の場合はfalseを返します。BPMが0だと次の手順の60.0 / bpmが無限大になり、全ノーツの時刻が壊れるため、ここで止めます。

またsort_customは安定ソートではないため、比較関数で時刻だけを見ると、同時刻のノーツ(和音)の並び順が実行のたびに変わる可能性があります。時刻が同じ場合はレーン番号で比較し、並びが常に一意に定まるようにしています。

手順3: 1行を解析する

func _parse_line(line: String, measure_lines: Array[Dictionary]) -> void:
    # 末尾の空白で区切ることで #BPM と拡張ヘッダー #BPMxx を区別する。
    # 区別しないと "#BPM01 180" が BPM=1.0 として読み込まれてしまう。
    if line.begins_with("#BPM "):
        bpm = line.trim_prefix("#BPM ").strip_edges().to_float()
        return

    if line.begins_with("#BGM "):
        bgm_wav_id = _normalize_wav_id(line.trim_prefix("#BGM ").strip_edges())
        return

    if line.begins_with("#WAV"):
        _parse_wav_header(line)
        return

    _parse_measure_line(line, measure_lines)

ヘッダー名の直後の半角スペースまで含めて判定している点が重要です。単に#BPMで始まるかだけを見ると、BMSの拡張ヘッダー#BPM01 180(曲中のBPM変化を定義する記法)も#BPMとして処理してしまい、"01 180"を数値化した1.0がBPMとして採用されてしまいます。BPM 1では1小節が240秒になり、譜面が事実上再生できなくなります。

音源定義と譜面データの解析は、それぞれ関数に分けます。

func _parse_wav_header(line: String) -> void:
    # "#WAVxx filename" の xx と filename を取り出す。
    var body := line.trim_prefix("#WAV")
    var space_index := body.find(" ")
    if space_index == -1:
        return

    var wav_id := _normalize_wav_id(body.substr(0, space_index))
    var filename := body.substr(space_index + 1).strip_edges()
    if wav_id.is_empty() or filename.is_empty():
        return

    wav_table[wav_id] = filename


func _parse_measure_line(line: String, measure_lines: Array[Dictionary]) -> void:
    # 譜面データ行: #mmmCC:data (mmm=小節番号3桁, CC=チャンネル2桁)
    var colon_index := line.find(":")
    if colon_index == -1:
        return

    var head := line.substr(1, colon_index - 1)
    if head.length() != HEAD_LENGTH:
        return

    var channel := head.substr(3, 2)
    if not LANE_CHANNELS.has(channel):
        return

    # data は2文字で1つの音源IDを表すため、長さは必ず偶数になる。
    var data := line.substr(colon_index + 1).strip_edges()
    if data.is_empty() or data.length() % 2 != 0:
        return

    measure_lines.append({
        "measure": head.substr(0, 3).to_int(),
        "lane": LANE_CHANNELS[channel],
        "data": _normalize_wav_id(data),
    })


## 音源IDは大文字に揃えて、#WAVbg と "BG" のような表記ゆれを吸収する。
func _normalize_wav_id(raw: String) -> String:
    return raw.strip_edges().to_upper()

条件に一致しない行(未対応のヘッダーやチャンネル)はそのまま無視されます。対応範囲を絞ったサブセットである以上、想定外の行は読み飛ばして問題ありません。

音源IDを_normalize_wav_idで大文字に統一しているのは、BMSの音源IDが英数字2文字で、慣習的に大文字小文字を区別しないためです。#WAVaaと定義して譜面データ側にAAと書いても、正しく対応付けられます。

手順4: ノーツの時刻を計算する

1小節の長さを4/4拍子固定で求め、データ文字列の分割数に応じてノーツの時刻を按分します。

func _build_notes(measure_lines: Array[Dictionary]) -> void:
    var measure_duration := (60.0 / bpm) * BEATS_PER_MEASURE

    for entry in measure_lines:
        var measure_start: float = entry["measure"] * measure_duration
        var data: String = entry["data"]
        # 1小節を data の文字数の半分で等分し、各位置の音源IDを読む。
        var division := data.length() / 2

        for i in range(division):
            var wav_id := data.substr(i * 2, 2)
            if wav_id == "00":  # "00" はノーツなしを表す
                continue
            var note_time := measure_start + (float(i) / float(division)) * measure_duration
            notes.append(ChartNote.new(note_time, entry["lane"], wav_id))

"00"の位置はノーツなしを意味するため、それ以外の音源IDだけをnotesに追加します。音源IDはそのままChartNote.wav_idとして保持し、あとでヒット音の再生に使います。

第6章 ノーツの生成と落下処理を実装する

手順0: Main.gd の骨組みを用意する

この章からはscripts/Main.gdに実装していきます。以降の手順で追加する関数が参照するノードと変数を、先に宣言しておきます。

extends Node2D
## リズムゲーム本体。譜面の読み込み、ノーツの落下、キー入力の判定、スコア表示を行う。

## レーン番号に対応する入力アクション名(プロジェクト設定のInput Mapで定義)。
const LANE_ACTIONS := ["lane_0", "lane_1", "lane_2", "lane_3"]
const LANE_COUNT := 4

const AUDIO_DIR := "res://assets/audio"
const NOTE_SCENE := preload("res://scenes/Note.tscn")

@export var chart_path: String = "res://charts/sample.bms"

@onready var notes_container: Node2D = $NotesContainer
@onready var bgm_player: AudioStreamPlayer = $BGMPlayer
@onready var hit_sound_player: AudioStreamPlayer = $HitSoundPlayer
@onready var score_label: Label = $UI/ScoreLabel
@onready var judgment_label: Label = $UI/JudgmentLabel
@onready var combo_label: Label = $UI/ComboLabel
@onready var result_label: Label = $UI/ResultLabel

var chart: BMSParser
var note_views: Array[Note] = []

var score: int = 0
var combo: int = 0
var max_combo: int = 0
var great_count: int = 0
var good_count: int = 0
var miss_count: int = 0
var finished: bool = false

var _fall_speed: float = 0.0
var _start_time_usec: int = 0
var _judged_count: int = 0
var _judgment_hide_time: float = -1.0

## 音源IDごとに読み込み済みの AudioStream を保持し、判定のたびに load() しないようにする。
var _wav_streams: Dictionary = {}
var _hit_playback: AudioStreamPlaybackPolyphonic = null

変数名の先頭に_が付いているものは、外から触る必要のない内部用という意味合いで分けています。

@onreadyはノードの準備ができた時点で代入されるという指定です。$NotesContainerはシーンツリー上の子ノードを名前で取得する書き方で、第4章で組んだノード構成と対応しています。

手順1: ノーツの状態を持つスクリプトを作成する

ノーツ1つ分の状態はscripts/Note.gdにまとめます。表示はColorRectをそのまま使い、見た目より状態管理を優先したシンプルな構成にします。

extends ColorRect
class_name Note
## 画面上を落下する1つのノーツ。見た目は ColorRect のままで、判定に必要な状態だけを持つ。

## このノーツを叩くべき時刻(曲頭からの秒数)。
var note_time: float = 0.0

## 対応するレーン番号(0が左端)。
var lane: int = 0

## 叩いたときに鳴らす音源のID。BMSの #WAVxx に対応する。
var wav_id: String = ""

## 判定済みかどうか。true になったノーツは移動も判定もされない。
var judged: bool = false

scenes/Note.tscnでは、このスクリプトを付けたColorRectをルートノードにします。

class_name Noteを付けておくと、Main.gd側でArray[Note]func _judge_note(view: Note, ...)のように型を書けるようになり、プロパティ名のタイプミスをエディタが指摘してくれます。

class_nameを追加した直後は、グローバルクラスの一覧がまだ更新されていないため、 headlessでスクリプトを実行するとCould not find type "Note"で失敗することがあります。一度エディタでプロジェクトを開くか、godot --headless --path . --importを実行すると解消します。

手順2: パース結果からノーツを生成する

scripts/Main.gd_spawn_notesで、パース済みのchart.notesをシーンに反映します。

func _spawn_notes() -> void:
    for note_data in chart.notes:
        var view: Note = NOTE_SCENE.instantiate()
        view.note_time = note_data.time
        view.lane = note_data.lane
        view.wav_id = note_data.wav_id
        view.position = Vector2(LANE_START_X + note_data.lane * LANE_WIDTH, SPAWN_Y)
        notes_container.add_child(view)
        note_views.append(view)

すべてのノーツをゲーム開始時に一括生成し、以後は_processで座標を更新するだけにします。曲中に随時生成する方式に比べて実装が単純になる一方、譜面が長大になるとノード数がそのまま増える点はトレードオフです。今回のような学習用の短い譜面ではこの方式で十分です。

手順3: ゲーム内時刻を計測する

ゲーム内時刻(song_time)は、BGM自体の再生位置ではなく、_ready実行時刻からの経過時間で計測します。

func _ready() -> void:
    _fall_speed = (JUDGMENT_Y - SPAWN_Y) / LEAD_TIME

    chart = BMSParser.new()
    if not chart.parse_file(chart_path):
        _show_load_error("譜面を読み込めませんでした:\n%s" % chart_path)
        return

    if chart.notes.is_empty():
        _show_load_error("譜面にノーツがありません:\n%s" % chart_path)
        return

    _load_wav_streams()
    _setup_hit_sound_player()
    _load_bgm()
    _spawn_notes()
    _update_score_label()

    _start_time_usec = Time.get_ticks_usec()
    await get_tree().create_timer(LEAD_TIME).timeout

    # 待機中にシーンが破棄されている場合があるため、再生前に生存を確認する。
    if is_inside_tree() and bgm_player.stream != null:
        bgm_player.play()


func _get_song_time() -> float:
    var elapsed := (Time.get_ticks_usec() - _start_time_usec) / 1_000_000.0
    return elapsed - LEAD_TIME

LEAD_TIME(1.5秒)は、ノーツが画面上端に出現してから判定ラインに到達するまでの時間です。最初のノーツはtime = 0.0ですが、ゲーム開始直後にいきなり判定ラインへ現れては見た目上フェアではないため、song_time0になるタイミングをLEAD_TIME秒後にずらします。BGMの再生開始もこのタイミングに合わせることで、譜面の時刻とBGMの再生位置がずれないようにします。

song_time_ready()の時点で-1.5から始まり、1.5秒かけて0に近づきます。負の値の間が助走期間です。

冒頭でparse_fileの戻り値を確認している点にも注意してください。ここを無視すると、譜面ファイルの名前を間違えたときにノーツが1つも出ない画面が表示されるだけで、原因が分かりません。読み込みに失敗した場合は理由を画面に出し、ゲームを進行させないようにします。

## 譜面を読み込めなかったときは、ゲームを進行させずに理由を画面へ出す。
func _show_load_error(message: String) -> void:
    push_error(message)
    set_process(false)
    set_process_unhandled_input(false)
    result_label.visible = true
    result_label.text = message

AudioStreamPlayerの再生位置(get_playback_position)を基準にする方法もありますが、その場合はオーディオバッファの遅延を考慮した補正が必要になり、実装が複雑になります。このチュートリアルでは判定幅を50〜200msの単位で設計するため、Time.get_ticks_usecによるシンプルな計測で十分な精度が得られます。

手順4: ノーツを移動させ、時間切れのノーツをMISSにする

ノーツの座標更新と、時間切れによる自動MISS判定は_processにまとめます。

func _process(_delta: float) -> void:
    var song_time := _get_song_time()

    for view in note_views:
        if view.judged:
            continue
        # song_time が note_time と一致する瞬間に、ちょうど判定ラインへ到達する。
        view.position.y = JUDGMENT_Y - (view.note_time - song_time) * _fall_speed
        if song_time - view.note_time > MISS_WINDOW:
            _judge_note(view, Judgment.MISS)

    _update_judgment_display(song_time)

view.position.yの式は、「ノーツの発生時刻までの残り時間 × 落下速度」を判定ラインの座標から引くことで、song_time == note_timeの瞬間にちょうど判定ラインへ到達するようにしています。

落下速度fall_speedは「画面上端から判定ラインまでの距離をLEAD_TIME秒で移動する速さ」として_readyで1度だけ計算しています。この値と残り時間を掛ければ、判定ラインからどれだけ手前にいるかが求まります。

ここまで実装すると、実際にノーツが落ちてくる画面になります。

このループの処理の流れをまとめると、以下のようになります。

第7章 キー入力と判定ロジックを実装する

手順1: 判定幅を決める

判定ライン通過タイミングとの誤差(diff)に応じて、3段階の判定を設けます。

判定許容誤差
GREAT±50ms以内
GOOD±150ms以内
MISS±200ms以内(それ以上は判定なし)

図にすると次のようになります。

Main.gdに、判定の種類を表す列挙型と、許容幅の定数を定義します。

## 判定の種類。スコアと表示文字列は下の定数テーブルで対応付ける。
enum Judgment { GREAT, GOOD, MISS }

const JUDGMENT_SCORE := {
    Judgment.GREAT: 2,
    Judgment.GOOD: 1,
    Judgment.MISS: 0,
}

const JUDGMENT_TEXT := {
    Judgment.GREAT: "GREAT",
    Judgment.GOOD: "GOOD",
    Judgment.MISS: "MISS",
}

## 判定ライン到達時刻とのずれの許容幅(秒)。
const GREAT_WINDOW := 0.05
const GOOD_WINDOW := 0.15
const MISS_WINDOW := 0.20

判定を"GREAT"のような文字列で持ち回すこともできますが、その場合は綴りを間違えても実行時にエラーにならず、matchのどの分岐にも入らないまま素通りしてしまいます。列挙型にしておけば、存在しない値を書いた時点でエディタが指摘します。スコアと表示文字列を辞書で対応付けておくと、配点を変えたいときに1か所だけ直せば済みます。

手順2: キー入力を受け付ける

キー入力は_unhandled_inputで受け、押されたレーンごとに_handle_lane_inputを呼びます。

func _unhandled_input(event: InputEvent) -> void:
    for lane in range(LANE_COUNT):
        if event.is_action_pressed(LANE_ACTIONS[lane]):
            _handle_lane_input(lane)

手順3: 最も近いノーツを探して判定する

_handle_lane_inputでは、そのレーンでまだ判定されていないノーツの中から、現在時刻に最も近いものを探します。

func _handle_lane_input(lane: int) -> void:
    # 探索と判定で同じ時刻を使う。_get_song_time() を2回呼ぶとその間に時間が進み、
    # 探したノーツと判定に使うずれ幅がわずかにずれてしまう。
    var song_time := _get_song_time()

    var target := _find_closest_note(lane, song_time)
    if target == null:
        return

    var diff: float = absf(song_time - target.note_time)
    if diff <= GREAT_WINDOW:
        _judge_note(target, Judgment.GREAT)
    elif diff <= GOOD_WINDOW:
        _judge_note(target, Judgment.GOOD)
    else:
        _judge_note(target, Judgment.MISS)


## 指定レーンの未判定ノーツのうち、現在時刻に最も近いものを返す。
## MISS_WINDOW より外側しかない場合は null(=空打ち)。
func _find_closest_note(lane: int, song_time: float) -> Note:
    var closest: Note = null
    var closest_diff := INF

    for view in note_views:
        if view.judged or view.lane != lane:
            continue
        var diff: float = absf(song_time - view.note_time)
        if diff <= MISS_WINDOW and diff < closest_diff:
            closest_diff = diff
            closest = view

    return closest

ノーツの探索を_find_closest_noteとして切り出し、「探す」処理と「判定する」処理を分けています。

時刻をsong_timeという変数に一度だけ受け取っている点が重要です。_get_song_time()は呼ぶたびに現在時刻を計算し直すため、探索用と判定用で別々に呼ぶと、その間に進んだ時間の分だけずれ幅が変わります。判定境界にちょうど乗ったノーツの結果が実行のたびに変わりかねないため、1回の入力処理の中では同じ時刻を使い回します。

MISS_WINDOWより外側のノーツは探索対象から外れるため、判定ラインからかけ離れたタイミングでキーを押しても何も起こりません。この一連の流れは以下のフローチャートにまとめています。

第8章 スコア・コンボ・リザルト表示を実装する

手順1: 判定に応じてスコアとコンボを更新する

スコアは加算方式で、GREAT = 2点、GOOD = 1点、MISS = 0点とします。判定と同時にコンボ数・判定内訳も更新します。

func _judge_note(view: Note, judgment: Judgment) -> void:
    view.judged = true
    view.visible = false
    _judged_count += 1

    score += JUDGMENT_SCORE[judgment]

    match judgment:
        Judgment.GREAT:
            great_count += 1
        Judgment.GOOD:
            good_count += 1
        Judgment.MISS:
            miss_count += 1

    if judgment == Judgment.MISS:
        combo = 0
    else:
        combo += 1
        _play_hit_sound(view.wav_id)

    max_combo = maxi(max_combo, combo)

    _show_judgment(judgment)
    _update_score_label()
    _update_combo_label()
    _check_finished()

コンボはGREAT・GOODで加算され、MISSで0にリセットします。最大値はmax_comboとして別途保持し、リザルト表示に使います。スコアの加算はJUDGMENT_SCOREから引くだけなので、判定ごとにscore += 2と書き分ける必要はありません。

判定文字は出しっぱなしにせず、一定時間で消します。表示を消す処理は_processから呼ばれる_update_judgment_displayが担当します。

func _show_judgment(judgment: Judgment) -> void:
    judgment_label.text = JUDGMENT_TEXT[judgment]
    _judgment_hide_time = _get_song_time() + JUDGMENT_DISPLAY_TIME


## 判定文字を出しっぱなしにせず、一定時間後に消す。
func _update_judgment_display(song_time: float) -> void:
    if _judgment_hide_time < 0.0 or song_time < _judgment_hide_time:
        return
    judgment_label.text = ""
    _judgment_hide_time = -1.0

手順2: 全ノーツ判定後にリザルトを表示する

全ノーツが判定済みになった時点で、スコアと判定内訳をまとめたリザルトをオーバーレイ表示します。

func _check_finished() -> void:
    if finished or _judged_count < note_views.size():
        return
    finished = true
    _show_result()


func _show_result() -> void:
    result_label.visible = true
    result_label.text = (
        "RESULT\nSCORE: %d\nGREAT: %d  GOOD: %d  MISS: %d\nMAX COMBO: %d"
        % [score, great_count, good_count, miss_count, max_combo]
    )

_check_finishedは判定のたびに呼ばれます。ここで毎回note_viewsを全件走査して未判定のノーツを探すこともできますが、それだとノーツ数nに対して判定のたびにn回の確認が入り、譜面が長くなるほど無駄が増えます。判定時に_judged_countを1つ増やしておき、その値とノーツ総数を比べるだけにすれば、確認は常に1回で済みます。

手順3: 和音でヒット音が途切れないようにする

ヒット音の再生には注意が必要です。1つのAudioStreamPlayerに対してstreamを差し替えながらplay()を呼ぶ実装にすると、play()は再生を先頭からやり直す動作のため、4レーン同時押しのような和音では最後の1音しか聞こえません。

AudioStreamPolyphonicを使うと、1つのプレイヤーで複数の音を重ねて鳴らせます。

## 1つの AudioStreamPlayer で複数のヒット音を重ねて鳴らせるようにする。
## 単純に stream を差し替えて play() すると、同時押しのときに前の音が止まってしまう。
func _setup_hit_sound_player() -> void:
    var polyphonic := AudioStreamPolyphonic.new()
    polyphonic.polyphony = HIT_SOUND_POLYPHONY
    hit_sound_player.stream = polyphonic
    hit_sound_player.play()
    _hit_playback = hit_sound_player.get_stream_playback()


func _play_hit_sound(wav_id: String) -> void:
    if _hit_playback == null:
        return
    var stream: AudioStream = _wav_streams.get(wav_id)
    if stream != null:
        _hit_playback.play_stream(stream)

音源は判定のたびにload()するのではなく、譜面の読み込み時にまとめて_wav_streamsへ入れておきます。

load()は2回目以降キャッシュから返るとはいえ、パスの解決が毎回発生するため、判定という時間に敏感な処理からは外しておくほうが安全です。

func _load_wav_streams() -> void:
    for wav_id in chart.wav_table:
        var path := "%s/%s" % [AUDIO_DIR, chart.wav_table[wav_id]]
        if ResourceLoader.exists(path):
            _wav_streams[wav_id] = load(path)
        else:
            push_warning("音源ファイルが見つかりません: %s" % path)

すべてのノーツをGREATで叩いた場合、8ノーツ × 2点でスコアは16点になります。

コメント

タイトルとURLをコピーしました