Initial commit. Implement music sync features.

This commit is contained in:
2026-01-19 22:48:17 +08:00
commit f16cf0fb96
37 changed files with 757 additions and 0 deletions

9
rhythm_game/lane/lane.gd Normal file
View File

@@ -0,0 +1,9 @@
class_name Lane extends Node2D
func get_hit_pos() -> Vector2:
return position
func update(_beat: BeatUpdate) -> void:
pass

View File

@@ -0,0 +1 @@
uid://u42y08gn6bi7

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://ch06ds4gr82nl"]
[ext_resource type="Script" uid="uid://u42y08gn6bi7" path="res://rhythm_game/lane/lane.gd" id="1_p48sd"]
[node name="Lane" type="Node2D"]
script = ExtResource("1_p48sd")

5
rhythm_game/metronome.gd Normal file
View File

@@ -0,0 +1,5 @@
class_name Metronome extends AudioStreamPlayer
func on_conductor_ticked(update: BeatUpdate) -> void:
if update.new_beat():
play()

View File

@@ -0,0 +1 @@
uid://bbpyym0kgujev

View File

@@ -0,0 +1,26 @@
class_name BeatUpdate
## Beat this frame.
var current: float
## Beat last frame.
var previous: float
## Beat updated forward in time.
func forward() -> bool:
return current > previous
## Beat updated backward in time.
func backward() -> bool:
return current < previous
func new_beat() -> bool:
return floori(previous) != floori(current)
func _init(current_beat: float = 0.0, previous_beat: float = 0.0) -> void:
current = current_beat
previous = previous_beat

View File

@@ -0,0 +1 @@
uid://dyjoi5i3iha0h

View File

@@ -0,0 +1,142 @@
## Class for playing music and providing an interface to keep events in sync.
class_name Conductor extends Node
## If true, start immediately when ready.
@export var autostart: bool = false
## Music to play.
@export var music: Music
signal started
signal ticked(beat_update: BeatUpdate)
signal ticked_forward(new_beat: float)
#signal ticked_backward(new_beat: float)
signal finished
func start() -> void:
assert(music != null)
assert(_audio_player != null)
_stop_playback_and_initialize()
_active = true
started.emit()
func is_active() -> bool:
return _active
func current_time() -> float:
return(_audio_player.get_playback_position() +
AudioServer.get_time_since_last_mix() +
_music_offset - _remaining_silence)
func previous_time() -> float:
return _previous_time
func current_beat() -> float:
return _sync_track.to_beat(current_time())
func previous_beat() -> float:
return _previous_beat
func current_bpm() -> float:
return _sync_track.bpm_at_time(current_time())
func add_time(seconds: float) -> void:
if _audio_player == null:
return
_audio_player.seek(_audio_player.get_playback_position() + seconds)
_previous_time = current_time()
_previous_beat = current_beat()
# ====== IMPLEMENTATION ====== #
var _active: bool = false
var _previous_time: float = -1.0
var _previous_beat: float = -1.0
var _audio_player: AudioStreamPlayer = null
var _sync_track: SyncTrack = SyncTrack.new()
var _music_offset: float
var _music_playing: bool = false
var _remaining_silence: float = 0.0
func _ready() -> void:
_audio_player = AudioStreamPlayer.new()
add_child(_audio_player)
_audio_player.finished.connect(_on_music_finished)
if autostart:
start()
func _on_music_finished() -> void:
_music_playing = false
_active = false
finished.emit()
func _process(delta: float) -> void:
if not _active:
return
if _music_playing:
_beat_update()
else:
_update_silence(delta)
func _update_silence(elapsed_time: float) -> void:
_remaining_silence -= elapsed_time
if _remaining_silence < 0:
_play_music_from_offset(-_remaining_silence)
_remaining_silence = 0.0
func _beat_update() -> void:
var _current_time = current_time()
# _audio_player.get_playback_position() used in current_time()
# is not perfect and may return a lesser value than last frame.
# This makes sure the beat never updates backward.
if _current_time <= _previous_time:
return
var _current_beat = _sync_track.to_beat(_current_time)
ticked.emit(BeatUpdate.new(_current_beat, _previous_beat))
ticked_forward.emit(_current_beat)
_previous_beat = _current_beat
_previous_time = _current_time
func _stop_playback_and_initialize() -> void:
if _audio_player.playing:
_audio_player.stop()
_active = false
_music_playing = false
_previous_time = -1.0
_previous_beat = -1.0
_music_offset = music.offset
_remaining_silence = _music_offset
_audio_player.stream = music.stream
_sync_track.initialize(music.tempo)
func _play_music_from_offset(offset: float) -> void:
_audio_player.play(offset)
_music_playing = true

View File

@@ -0,0 +1 @@
uid://s16dt0bu0jrg

View File

@@ -0,0 +1,25 @@
class_name EventLayout extends Node
#var _beats: Array[float]
#var _callables: Array[Callable]
#var _index: int = 0
### Schedule the callable to be called on the specified beat.
#func schedule(beat: float, callable: Callable) -> void:
#var i := schedule_beats.find_custom(func(s_beat: float): return s_beat > beat )
#if i == -1:
#i = schedule_beats.size()
#schedule_beats.insert(i, beat)
#schedule_callables.insert(i, callable)
#
### Clears all scheduled callables.
#func reset_schedule() -> void:
#schedule_beats.clear()
#schedule_callables.clear()
#schedule_index = 0
#
#while(schedule_index < schedule_beats.size()
#and get_beat() >= schedule_beats.get(schedule_index)):
#schedule_callables.get(schedule_index).call()
#schedule_index += 1

View File

@@ -0,0 +1 @@
uid://102cl75cfpgw

View File

@@ -0,0 +1,167 @@
## Class for converting beats to time (since start of music) and vice versa.
class_name SyncTrack extends Node
func initialize(tempo: Tempo) -> void:
_set_bpm_data(tempo)
_calculate_cache()
## Get the number of valid BPM changes.
func size() -> int:
return min(_bpms.size(), _lengths.size() + 1)
## Get the BPM at the given time (since music start).
func bpm_at_time(time: float) -> float:
var i: int = _get_bpm_index_at_time(time)
if _types[i] == Tempo.BPM_TYPE.LINEAR:
var delta = inverse_lerp(_elapsed_seconds[i], _elapsed_seconds[i+1], time)
return lerp(_bpms[i], _bpms[i+1], delta)
else:
return _bpms.get(i)
## Get the beat at the given time (since music start).
func to_beat(time: float) -> float:
var i: int = _get_bpm_index_at_time(time)
# If i is not the last BPM:
if i < _bpms.size() - 1:
var delta = inverse_lerp(_elapsed_seconds[i], _elapsed_seconds[i+1], time)
return lerp(_elapsed_beats[i], _elapsed_beats[i+1], delta)
# Else i is the last BPM:
else:
var bps: float = _bpms[i] / 60.0
var remaining_seconds: float = time - _elapsed_seconds[i]
var remaining_beats: float = remaining_seconds * bps
return _elapsed_beats[i] + remaining_beats
## Get the time (since music start) at the given beat.
func to_time(beat: float) -> float:
var i: int = _get_bpm_index_at_beat(beat)
if i == -1:
return -1.0
# Else if i is not the last BPM:
elif i < _bpms.size() - 1:
var delta = inverse_lerp(_elapsed_beats[i], _elapsed_beats[i+1], beat)
return lerp(_elapsed_seconds[i], _elapsed_seconds[i+1], delta)
# Else i is the last BPM:
else:
var bps: float = _bpms[i] / 60.0
var remaining_beats: float = beat - _elapsed_beats[i]
var remaining_seconds: float = remaining_beats / bps
return _elapsed_seconds[i] + remaining_seconds
func _init(tempo: Tempo = Tempo.new()) -> void:
initialize(tempo)
# IMPLEMENTATION
# === Note about BPM data ===
# The data for each BPM is stored in the same index
# across the different arrays.
# ===========================
## BPM array (Beats per Minute).
var _bpms: Array[float]
## The duration of each BPM in beats.
var _lengths: Array[float]
## The BpmData.BPM_TYPE of each BPM.
var _types: Array[Tempo.BPM_TYPE]
# Cache variables that make calculations easier.
## The duration of each BPM in seconds.
var _length_seconds: Array[float]
## Seconds passed before the corresponding BPM.
var _elapsed_seconds: Array[float] = [0.0]
## Beats passed before the corresponding BPM.
var _elapsed_beats: Array[float] = [0.0]
## Set the BPM data arrays.
func _set_bpm_data(tempo: Tempo) -> void:
_clear_bpm_data()
tempo.normalize()
_bpms = tempo.bpms
_lengths = tempo.lengths
_types = tempo.types
func _calculate_cache() -> void:
_clear_cache()
for i: int in range(0, _bpms.size() - 1):
var bps: float
var length_in_beats: float = _lengths[i]
if _types[i] == Tempo.BPM_TYPE.LINEAR:
# Average BPS.
bps = (_bpms[i] + _bpms[i+1]) / 120.0
else:
bps = _bpms[i] / 60.0
var length_in_seconds: float = length_in_beats / bps
_length_seconds.append(length_in_seconds)
_elapsed_seconds.append(_elapsed_seconds.back() + length_in_seconds)
_elapsed_beats.append(_elapsed_beats.back() + length_in_beats)
if _length_seconds.size() > 0:
_length_seconds[-1] = -1.0
func _clear_bpm_data() -> void:
_bpms = []
_lengths = []
_types = []
func _clear_cache() -> void:
_length_seconds = []
_elapsed_seconds = [0.0]
_elapsed_beats = [0.0]
func _get_bpm_index_at_time(time: float) -> int:
assert(_elapsed_seconds.size() > 0)
assert(_bpms.size() > 0)
var _find_index: Callable = func (p_elapsed_seconds: float) -> bool:
return time < p_elapsed_seconds
var index: int = _elapsed_seconds.find_custom(_find_index)
if index == -1:
index = _bpms.size() - 1
else:
index -= 1
return index
func _get_bpm_index_at_beat(beat: float) -> int:
var _find_index: Callable = func (p_elapsed_beats: float) -> bool:
return beat < p_elapsed_beats
if _elapsed_beats.size() == 0:
return -1
var index: int = _elapsed_beats.find_custom(_find_index)
if index > 0:
index -= 1
elif index == -1:
index = max(0, _bpms.size()-1)
return index
func _print_data() -> void:
print("=== SyncTrack ===")
print("BPMs: ", _bpms)
print("BPM Lengths (Beats): ", _lengths)
print("BPM Types: ", _types)
print("Elapsed Seconds: ", _elapsed_seconds)
print("Elapsed Beats: ", _elapsed_beats)

View File

@@ -0,0 +1 @@
uid://wbqg55ld6py5

18
rhythm_game/note.gd Normal file
View File

@@ -0,0 +1,18 @@
class_name Note extends Object
enum TYPE {
TAP = 0,
HOLD_START = 1,
HOLD_END = 2
}
var hit_beat: float
var type: TYPE
var lane: int
func _init(p_hit_beat: float = -99.0, p_lane: int = 0, p_type: TYPE = TYPE.TAP) -> void:
hit_beat = p_hit_beat
lane = p_lane
type = p_type

1
rhythm_game/note.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://cgxrnivh6rjje

View File

@@ -0,0 +1,84 @@
#TODO Split class into two.
# One should be responsible for storing arrays of data, like a NoteData class?
# Another represents a slice of the data, NoteView.
# Before that, figure out how the part of a hold note between the hold start
# and hold end will be implemented.
class_name NoteLayout extends Node
## Controls at what beat notes are visible.
@export_group("Note View Offset")
## At what beat offset from the current beat will notes will be visible.
## Example, spawn = 4.0 means notes are spawned 4 beats before
## they are meant to be hit.
@export var spawn: float = 4.0
## At what beat offset from the current beat will notes be invisible.
## Example, despawn = 4.0 means notes are despawned 4 beats after
## they are meant to be hit.
@export var despawn: float = 4.0
## The index in notes of the first active note.
func start() -> int:
return _start
## The index in notes after the last active note.
func end() -> int:
return _end
func size() -> int:
return _beat.size()
func beat(index: int) -> float:
return _beat[index]
func lane(index: int) -> int:
return _lane[index]
func type(index: int) -> Note.TYPE:
return _type[index]
# ======= IMPLEMENTATION ======== #
# All notes in the chart, arranged by ascending beat.
var _beat: Array[float] = []
var _lane: Array[int] = []
var _type: Array[Note.TYPE] = []
var _start: int = 0
var _end: int = 0
func _push_note(_note: Note) -> void:
pass
# TODO FIX THESE
func _update_forward(new_beat: float) -> void:
var spawn_beat = new_beat + spawn
while _end < size() and _beat[_end] <= spawn_beat:
_end += 1
var despawn_beat = new_beat - despawn
while _start < size() and _beat[_start] < despawn_beat:
_start += 1
# TODO FIX THESE
#func _update_backward(new_beat: float) -> void:
#var spawn_beat = new_beat + note_spawn_offset
#
#while _end >= 0 and _beat[_end] > spawn_beat:
#_end -= 1
#
#var despawn_beat = new_beat - note_despawn_offset
#
#while _start >= 0 and _beat[_start] < despawn_beat:
#_start -= 1

View File

@@ -0,0 +1 @@
uid://dlnnbx2wvn66t

View File

@@ -0,0 +1,37 @@
[gd_scene load_steps=10 format=3 uid="uid://dwro2d0482v0p"]
[ext_resource type="Script" uid="uid://dlnnbx2wvn66t" path="res://rhythm_game/note_layout.gd" id="1_cr5rn"]
[ext_resource type="Script" uid="uid://102cl75cfpgw" path="res://rhythm_game/music_sync/event_layout.gd" id="1_jnfl3"]
[ext_resource type="Script" uid="uid://s16dt0bu0jrg" path="res://rhythm_game/music_sync/conductor.gd" id="2_62aw1"]
[ext_resource type="AudioStream" uid="uid://btmy8ffph5gn3" path="res://chart/test_nibelungen/audio.mp3" id="3_txi6k"]
[ext_resource type="Resource" uid="uid://b34uhnkvwfyc1" path="res://chart/test_nibelungen/tempo.tres" id="5_10cpq"]
[ext_resource type="Script" uid="uid://rg6orh6kutai" path="res://resource_type/music.gd" id="5_nsyv8"]
[ext_resource type="AudioStream" uid="uid://be8dyt7nfpffw" path="res://sfx/sfx_cowbell.ogg" id="6_ecbku"]
[ext_resource type="Script" uid="uid://bbpyym0kgujev" path="res://rhythm_game/metronome.gd" id="7_nsyv8"]
[sub_resource type="Resource" id="Resource_10cpq"]
script = ExtResource("5_nsyv8")
stream = ExtResource("3_txi6k")
offset = -0.17
tempo = ExtResource("5_10cpq")
metadata/_custom_type_script = "uid://rg6orh6kutai"
[node name="RhythmGame" type="Node2D"]
script = ExtResource("1_jnfl3")
[node name="Conductor" type="Node" parent="."]
script = ExtResource("2_62aw1")
autostart = true
music = SubResource("Resource_10cpq")
metadata/_custom_type_script = "uid://s16dt0bu0jrg"
[node name="NoteLayout" type="Node" parent="."]
script = ExtResource("1_cr5rn")
metadata/_custom_type_script = "uid://dlnnbx2wvn66t"
[node name="Metronome" type="AudioStreamPlayer" parent="."]
stream = ExtResource("6_ecbku")
script = ExtResource("7_nsyv8")
metadata/_custom_type_script = "uid://bbpyym0kgujev"
[connection signal="ticked" from="Conductor" to="Metronome" method="on_conductor_ticked"]