瀏覽代碼

Merge pull request #1197 from PizzaLovers007/rhythm-game

Add a simple rhythm game demo
K. S. Ernest (iFire) Lee 3 月之前
父節點
當前提交
1f58baa94c
共有 41 個文件被更改,包括 1437 次插入0 次删除
  1. 20 0
      audio/rhythm_game/README.md
  2. 160 0
      audio/rhythm_game/game_state/conductor.gd
  3. 1 0
      audio/rhythm_game/game_state/conductor.gd.uid
  4. 25 0
      audio/rhythm_game/game_state/metronome.gd
  5. 1 0
      audio/rhythm_game/game_state/metronome.gd.uid
  6. 153 0
      audio/rhythm_game/game_state/note_manager.gd
  7. 1 0
      audio/rhythm_game/game_state/note_manager.gd.uid
  8. 23 0
      audio/rhythm_game/game_state/play_stats.gd
  9. 1 0
      audio/rhythm_game/game_state/play_stats.gd.uid
  10. 135 0
      audio/rhythm_game/globals/chart_data.gd
  11. 1 0
      audio/rhythm_game/globals/chart_data.gd.uid
  12. 15 0
      audio/rhythm_game/globals/enums.gd
  13. 1 0
      audio/rhythm_game/globals/enums.gd.uid
  14. 17 0
      audio/rhythm_game/globals/global_settings.gd
  15. 1 0
      audio/rhythm_game/globals/global_settings.gd.uid
  16. 45 0
      audio/rhythm_game/globals/one_euro_filter.gd
  17. 1 0
      audio/rhythm_game/globals/one_euro_filter.gd.uid
  18. 二進制
      audio/rhythm_game/icon.webp
  19. 34 0
      audio/rhythm_game/icon.webp.import
  20. 二進制
      audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav
  21. 24 0
      audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav.import
  22. 二進制
      audio/rhythm_game/music/the_comeback2.ogg
  23. 19 0
      audio/rhythm_game/music/the_comeback2.ogg.import
  24. 12 0
      audio/rhythm_game/objects/guide/guide.gd
  25. 1 0
      audio/rhythm_game/objects/guide/guide.gd.uid
  26. 二進制
      audio/rhythm_game/objects/guide/guide.png
  27. 34 0
      audio/rhythm_game/objects/guide/guide.png.import
  28. 8 0
      audio/rhythm_game/objects/guide/guide.tscn
  29. 83 0
      audio/rhythm_game/objects/note/note.gd
  30. 1 0
      audio/rhythm_game/objects/note/note.gd.uid
  31. 二進制
      audio/rhythm_game/objects/note/note.png
  32. 34 0
      audio/rhythm_game/objects/note/note.png.import
  33. 10 0
      audio/rhythm_game/objects/note/note.tscn
  34. 65 0
      audio/rhythm_game/project.godot
  35. 219 0
      audio/rhythm_game/scenes/main/main.gd
  36. 1 0
      audio/rhythm_game/scenes/main/main.gd.uid
  37. 282 0
      audio/rhythm_game/scenes/main/main.tscn
  38. 8 0
      audio/rhythm_game/scenes/main/pause_handler.gd
  39. 1 0
      audio/rhythm_game/scenes/main/pause_handler.gd.uid
  40. 0 0
      audio/rhythm_game/screenshots/.gdignore
  41. 二進制
      audio/rhythm_game/screenshots/rhythm_game.webp

+ 20 - 0
audio/rhythm_game/README.md

@@ -0,0 +1,20 @@
+# Rhythm Game
+
+A simple rhythm game that utilizes strategies described in the article
+["Sync the gameplay with audio and music"](https://docs.godotengine.org/en/stable/tutorials/audio/sync_with_audio.html).
+
+Playback position jitter is resolved using the
+[1€ filter](https://gery.casiez.net/1euro/) to achieve smooth note movements.
+
+The metronome sound was recorded by Ludwig Peter Müller in December 2020 under
+the "Creative Commons CC0 1.0 Universal" license.
+
+Language: GDScript
+
+Renderer: Compatibility
+
+Check out this demo on the asset library: TBD
+
+## Screenshots
+
+![Screenshot](screenshots/rhythm_game.webp)

+ 160 - 0
audio/rhythm_game/game_state/conductor.gd

@@ -0,0 +1,160 @@
+## Accurately tracks the current beat of a song.
+class_name Conductor
+extends Node
+
+## If [code]true[/code], the song is paused. Setting [member is_paused] to
+## [code]false[/code] resumes the song.
+@export var is_paused: bool = false:
+	get:
+		if player:
+			return player.stream_paused
+		return false
+	set(value):
+		if player:
+			player.stream_paused = value
+
+@export_group("Nodes")
+## The song player.
+@export var player: AudioStreamPlayer
+
+@export_group("Song Parameters")
+## Beats per minute of the song.
+@export var bpm: float = 100
+## Offset (in milliseconds) of when the 1st beat of the song is in the audio
+## file. [code]5000[/code] means the 1st beat happens 5 seconds into the track.
+@export var first_beat_offset_ms: int = 0
+
+@export_group("Filter Parameters")
+## [code]cutoff[/code] for the 1€ filter. Decrease to reduce jitter.
+@export var allowed_jitter: float = 0.1
+## [code]beta[/code] for the 1€ filter. Increase to reduce lag.
+@export var lag_reduction: float = 5
+
+# Calling this is expensive, so cache the value. This should not change.
+var _cached_output_latency: float = AudioServer.get_output_latency()
+
+# General conductor state
+var _is_playing: bool = false
+
+# Audio thread state
+var _song_time_audio: float = -100
+
+# System time state
+var _song_time_begin: float = 0
+var _song_time_system: float = -100
+
+# Filtered time state
+var _filter: OneEuroFilter
+var _filtered_audio_system_delta: float = 0
+
+
+func _ready() -> void:
+	# Ensure that playback state is always updating, otherwise the smoothing
+	# filter causes issues.
+	process_mode = Node.PROCESS_MODE_ALWAYS
+
+
+func _process(_delta: float) -> void:
+	if not _is_playing:
+		return
+
+	# Handle a web bug where AudioServer.get_time_since_last_mix() occasionally
+	# returns unsigned 64-bit integer max value. This is likely due to minor
+	# timing issues between the main/audio threads, thus causing an underflow
+	# in the engine code.
+	var last_mix := AudioServer.get_time_since_last_mix()
+	if last_mix > 1000:
+		last_mix = 0
+
+	# First, calculate the song time using data from the audio thread. This
+	# value is very jittery, but will always match what the player is hearing.
+	_song_time_audio = (
+		player.get_playback_position()
+		# The 1st beat may not start at second 0 of the audio track. Compensate
+		# with an offset setting.
+		- first_beat_offset_ms / 1000.0
+		# For most platforms, the playback position value updates in chunks,
+		# with each chunk being one "mix". Smooth this out by adding in the time
+		# since the last chunk was processed.
+		+ last_mix
+		# Current processed audio is heard later.
+		- _cached_output_latency
+	)
+
+	# Next, calculate the song time using the system clock at render rate. This
+	# value is very stable, but can drift from the playing audio due to pausing,
+	# stuttering, etc.
+	_song_time_system = (Time.get_ticks_usec() / 1000000.0) - _song_time_begin
+	_song_time_system *= player.pitch_scale
+
+	# We don't do anything else here. Check _physics_process next.
+
+
+func _physics_process(delta: float) -> void:
+	if not _is_playing:
+		return
+
+	# To have the best of both the audio-based time and system-based time, we
+	# apply a smoothing filter (1€ filter) on the delta between the two values,
+	# then add it to the system-based time. This allows us to have a stable
+	# value that is also always accurate to what the player hears.
+	#
+	# Notes:
+	# - The 1€ filter jitter reduction is more effective on values that don't
+	#   change drastically between samples, so we filter on the delta (generally
+	#   less variable between frames) rather than the time itself.
+	# - We run the filter step in _physics_process to reduce the variability of
+	#   different systems' update rates. The filter params are specifically
+	#   tuned for 60 UPS.
+	var audio_system_delta := _song_time_audio - _song_time_system
+	_filtered_audio_system_delta = _filter.filter(audio_system_delta, delta)
+
+	# Uncomment this to show the difference between raw and filtered time.
+	#var song_time := _song_time_system + _filtered_audio_system_delta
+	#print("Error: %+.1f ms" % [abs(song_time - _song_time_audio) * 1000.0])
+
+
+func play() -> void:
+	var filter_args := {
+		"cutoff": allowed_jitter,
+		"beta": lag_reduction,
+	}
+	_filter = OneEuroFilter.new(filter_args)
+
+	player.play()
+	_is_playing = true
+
+	# Capture the start of the song using the system clock.
+	_song_time_begin = (
+		Time.get_ticks_usec() / 1000000.0
+		# The 1st beat may not start at second 0 of the audio track. Compensate
+		# with an offset setting.
+		+ first_beat_offset_ms / 1000.0
+		# Playback does not start immediately, but only when the next audio
+		# chunk is processed (the "mix" step). Add in the time until that
+		# happens.
+		+ AudioServer.get_time_to_next_mix()
+		# Add in additional output latency.
+		+ _cached_output_latency
+	)
+
+
+func stop() -> void:
+	player.stop()
+	_is_playing = false
+
+
+## Returns the current beat of the song.
+func get_current_beat() -> float:
+	var song_time := _song_time_system + _filtered_audio_system_delta
+	return song_time / get_beat_duration()
+
+
+## Returns the current beat of the song without smoothing.
+func get_current_beat_raw() -> float:
+	return _song_time_audio / get_beat_duration()
+
+
+## Returns the duration of one beat (in seconds).
+func get_beat_duration() -> float:
+	return 60 / bpm

+ 1 - 0
audio/rhythm_game/game_state/conductor.gd.uid

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

+ 25 - 0
audio/rhythm_game/game_state/metronome.gd

@@ -0,0 +1,25 @@
+extends AudioStreamPlayer
+
+@export var conductor: Conductor
+
+var _playing: bool = false
+var _last_beat: float = -17  # 16 beat count-in
+var _cached_latency: float = AudioServer.get_output_latency()
+
+
+func _process(_delta: float) -> void:
+	if not _playing:
+		return
+
+	# Note that this implementation is flawed since every tick is rounded to the
+	# next mix window (~11ms at the default 44100 Hz mix rate) due to Godot's
+	# audio mix buffer. Precise audio scheduling is requested in
+	# https://github.com/godotengine/godot-proposals/issues/1151.
+	var curr_beat := conductor.get_current_beat() + _cached_latency
+	if GlobalSettings.enable_metronome and floor(curr_beat) > floor(_last_beat):
+		play()
+	_last_beat = max(_last_beat, curr_beat)
+
+
+func start() -> void:
+	_playing = true

+ 1 - 0
audio/rhythm_game/game_state/metronome.gd.uid

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

+ 153 - 0
audio/rhythm_game/game_state/note_manager.gd

@@ -0,0 +1,153 @@
+class_name NoteManager
+extends Node2D
+
+signal play_stats_updated(play_stats: PlayStats)
+signal note_hit(beat: float, hit_type: Enums.HitType, hit_error: float)
+signal song_finished(play_stats: PlayStats)
+
+const NOTE_SCENE = preload("res://objects/note/note.tscn")
+const HIT_MARGIN_PERFECT = 0.050
+const HIT_MARGIN_GOOD = 0.150
+const HIT_MARGIN_MISS = 0.300
+
+@export var conductor: Conductor
+@export var time_type: Enums.TimeType = Enums.TimeType.FILTERED
+@export var chart: ChartData.Chart = ChartData.Chart.THE_COMEBACK
+
+var _notes: Array[Note] = []
+
+var _play_stats: PlayStats
+var _hit_error_acc: float = 0.0
+var _hit_count: int = 0
+
+
+func _ready() -> void:
+	_play_stats = PlayStats.new()
+	_play_stats.changed.connect(
+		func() -> void:
+			play_stats_updated.emit(_play_stats)
+	)
+
+	var chart_data := ChartData.get_chart_data(chart)
+
+	var note_beats: Array[float] = []
+	for measure_i in range(chart_data.size()):
+		var measure: Array = chart_data[measure_i]
+		var subdivision := 1.0 / measure.size() * 4
+		for note_i: int in range(measure.size()):
+			var beat := measure_i * 4 + note_i * subdivision
+			if measure[note_i] == 1:
+				note_beats.append(beat)
+
+	for beat in note_beats:
+		var note := NOTE_SCENE.instantiate() as Note
+		note.beat = beat
+		note.conductor = conductor
+		note.update_beat(-100)
+		add_child(note)
+		_notes.append(note)
+
+
+func _process(_delta: float) -> void:
+	if _notes.is_empty():
+		return
+
+	var curr_beat := _get_curr_beat()
+	for i in range(_notes.size()):
+		_notes[i].update_beat(curr_beat)
+
+	_miss_old_notes()
+
+	if Input.is_action_just_pressed("main_key"):
+		_handle_keypress()
+
+	if _notes.is_empty():
+		_finish_song()
+
+
+func _miss_old_notes() -> void:
+	while not _notes.is_empty():
+		var note := _notes[0] as Note
+		var note_delta := _get_note_delta(note)
+
+		if note_delta > HIT_MARGIN_GOOD:
+			# Time is past the note's hit window, miss.
+			note.miss(false)
+			_notes.remove_at(0)
+			_play_stats.miss_count += 1
+			note_hit.emit(note.beat, Enums.HitType.MISS_LATE, note_delta)
+		else:
+			# Note is still hittable, so stop checking rest of the (later)
+			# notes.
+			break
+
+
+func _handle_keypress() -> void:
+	var note := _notes[0] as Note
+	var hit_delta := _get_note_delta(note)
+	if hit_delta < -HIT_MARGIN_MISS:
+		# Note is not hittable, do nothing.
+		pass
+	elif -HIT_MARGIN_PERFECT <= hit_delta and hit_delta <= HIT_MARGIN_PERFECT:
+		# Hit on time, perfect.
+		note.hit_perfect()
+		_notes.remove_at(0)
+		_hit_error_acc += hit_delta
+		_hit_count += 1
+		_play_stats.perfect_count += 1
+		_play_stats.mean_hit_error = _hit_error_acc / _hit_count
+		note_hit.emit(note.beat, Enums.HitType.PERFECT, hit_delta)
+	elif -HIT_MARGIN_GOOD <= hit_delta and hit_delta <= HIT_MARGIN_GOOD:
+		# Hit slightly off time, good.
+		note.hit_good()
+		_notes.remove_at(0)
+		_hit_error_acc += hit_delta
+		_hit_count += 1
+		_play_stats.good_count += 1
+		_play_stats.mean_hit_error = _hit_error_acc / _hit_count
+		if hit_delta < 0:
+			note_hit.emit(note.beat, Enums.HitType.GOOD_EARLY, hit_delta)
+		else:
+			note_hit.emit(note.beat, Enums.HitType.GOOD_LATE, hit_delta)
+	elif -HIT_MARGIN_MISS <= hit_delta and hit_delta <= HIT_MARGIN_MISS:
+		# Hit way off time, miss.
+		note.miss()
+		_notes.remove_at(0)
+		_hit_error_acc += hit_delta
+		_hit_count += 1
+		_play_stats.miss_count += 1
+		_play_stats.mean_hit_error = _hit_error_acc / _hit_count
+		if hit_delta < 0:
+			note_hit.emit(note.beat, Enums.HitType.MISS_EARLY, hit_delta)
+		else:
+			note_hit.emit(note.beat, Enums.HitType.MISS_LATE, hit_delta)
+
+
+func _finish_song() -> void:
+	song_finished.emit(_play_stats)
+
+
+func _get_note_delta(note: Note) -> float:
+	var curr_beat := _get_curr_beat()
+	var beat_delta := curr_beat - note.beat
+	return beat_delta * conductor.get_beat_duration()
+
+
+func _get_curr_beat() -> float:
+	var curr_beat: float
+	match time_type:
+		Enums.TimeType.FILTERED:
+			curr_beat = conductor.get_current_beat()
+		Enums.TimeType.RAW:
+			curr_beat = conductor.get_current_beat_raw()
+		_:
+			assert(false, "Unknown TimeType: %s" % time_type)
+			curr_beat = conductor.get_current_beat()
+
+	# Adjust the timing for input delay. While this will shift the note
+	# positions such that "on time" does not line up visually with the guide
+	# sprite, the resulting visual is a lot smoother compared to readjusting the
+	# note position after hitting it.
+	curr_beat -= GlobalSettings.input_latency_ms / 1000.0 / conductor.get_beat_duration()
+
+	return curr_beat

+ 1 - 0
audio/rhythm_game/game_state/note_manager.gd.uid

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

+ 23 - 0
audio/rhythm_game/game_state/play_stats.gd

@@ -0,0 +1,23 @@
+class_name PlayStats
+extends Resource
+
+@export var mean_hit_error: float = 0.0:
+	set(value):
+		if mean_hit_error != value:
+			mean_hit_error = value
+			emit_changed()
+@export var perfect_count: int = 0:
+	set(value):
+		if perfect_count != value:
+			perfect_count = value
+			emit_changed()
+@export var good_count: int = 0:
+	set(value):
+		if good_count != value:
+			good_count = value
+			emit_changed()
+@export var miss_count: int = 0:
+	set(value):
+		if miss_count != value:
+			miss_count = value
+			emit_changed()

+ 1 - 0
audio/rhythm_game/game_state/play_stats.gd.uid

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

+ 135 - 0
audio/rhythm_game/globals/chart_data.gd

@@ -0,0 +1,135 @@
+class_name ChartData
+
+enum Chart {
+	THE_COMEBACK = 0,
+	SYNC_TEST = 1,
+}
+
+const THE_COMEBACK_DATA: Array[Array] = [
+	[1,0,0,0, 0,0,0,0, 1,0,0,1, 0,0,1,0],
+	[0,0,0,0, 0,0,0,0, 1,0,0,1, 0,0,1,0],
+	[0,0,0,0, 0,0,0,0, 1,0,0,1, 0,0,1,0],
+	[0,0,0,0, 0,0,1,0, 0,0,0,0, 0,0,0,0],
+
+	[0,0,1,0, 0,0,1,1, 0,0,0,1, 0,0,1,1],
+	[0,0,0,0, 0,0,1,0, 1,1,0,1, 0,1,0,1],
+	[0,1,1,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
+	[0,0,1,0, 0,1,0,0, 1,0,0,1, 0,0,1,0],
+
+	[0,0,1,0, 0,0,1,1, 0,0,0,1, 0,0,1,1],
+	[0,0,0,0, 0,0,1,0, 1,1,0,1, 0,1,0,1],
+	[0,1,1,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
+	[0,0,1,0, 0,1,0,0, 1,0,0,1, 0,0,1,0],
+
+	[0,0,1,0, 0,0,1,1, 0,0,0,1, 0,0,1,1],
+	[0,0,0,0, 0,0,1,0, 1,1,0,1, 0,1,0,1],
+	[0,1,1,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
+	[0,0,1,0, 0,1,0,0, 1,0,0,1, 0,0,1,0],
+
+	[0,0,1,0, 0,0,1,1, 0,0,0,1, 0,0,1,1],
+	[0,0,0,0, 0,0,1,0, 1,1,0,1, 0,1,0,1],
+	[0,1,1,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
+	[0,0,1,0, 0,1,0,0, 1,0,1,0, 1,0,1,0],
+
+	[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,1],
+	[1,0,1,1, 0,0,0,1, 0,0,0,0, 0,0,0,0],
+	[1,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0],
+	[0,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0],
+
+	[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,1],
+	[1,0,1,1, 0,0,0,1, 0,0,0,0, 0,0,0,0],
+	[1,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0],
+	[0,0,1,0, 1,0,1,0, 0,0,0,0, 1,0,1,0],
+
+	[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,1],
+	[1,0,1,1, 0,0,0,1, 0,0,0,0, 0,0,0,0],
+	[1,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0],
+	[0,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0],
+
+	[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,1],
+	[1,0,1,1, 0,0,0,1, 0,0,0,0, 0,0,0,0],
+	[1,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0],
+	[0,0,1,0, 1,0,1,0, 0,0,0,0, 0,0,1,1],
+
+	[1,0,1,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
+	[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,1],
+	[1,0,1,0, 0,0,0,0, 0,0,0,0, 1,0,1,0],
+	[0,0,0,0, 0,0,1,0, 0,0,0,0, 0,0,1,1],
+
+	[1,0,1,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
+	[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,1],
+	[1,0,1,0, 0,0,0,0, 0,0,0,0, 1,0,1,0],
+	[0,0,0,0, 0,0,1,0, 0,0,0,0, 0,0,0,0],
+
+	[1,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
+]
+
+const SYNC_TEST_DATA: Array[Array] = [
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+	[1,1,1,1],
+
+	[1,0,0,0],
+]
+
+
+static func get_chart_data(chart: Chart) -> Array[Array]:
+	match chart:
+		ChartData.Chart.THE_COMEBACK:
+			return ChartData.THE_COMEBACK_DATA
+		ChartData.Chart.SYNC_TEST:
+			return ChartData.SYNC_TEST_DATA
+		_:
+			assert(false, "Unknown chart: %d" % chart)
+			return ChartData.THE_COMEBACK_DATA

+ 1 - 0
audio/rhythm_game/globals/chart_data.gd.uid

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

+ 15 - 0
audio/rhythm_game/globals/enums.gd

@@ -0,0 +1,15 @@
+## Global enums.
+class_name Enums
+
+enum TimeType {
+	FILTERED,
+	RAW,
+}
+
+enum HitType {
+	MISS_EARLY,
+	GOOD_EARLY,
+	PERFECT,
+	GOOD_LATE,
+	MISS_LATE,
+}

+ 1 - 0
audio/rhythm_game/globals/enums.gd.uid

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

+ 17 - 0
audio/rhythm_game/globals/global_settings.gd

@@ -0,0 +1,17 @@
+extends Node
+
+signal scroll_speed_changed(speed: float)
+
+@export var use_filtered_playback: bool = true
+
+@export var enable_metronome: bool = false
+@export var input_latency_ms: int = 20
+
+@export var scroll_speed: float = 400:
+	set(value):
+		if scroll_speed != value:
+			scroll_speed = value
+			scroll_speed_changed.emit(value)
+@export var show_offsets: bool = false
+
+@export var selected_chart: ChartData.Chart = ChartData.Chart.THE_COMEBACK

+ 1 - 0
audio/rhythm_game/globals/global_settings.gd.uid

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

+ 45 - 0
audio/rhythm_game/globals/one_euro_filter.gd

@@ -0,0 +1,45 @@
+# Copyright (c) 2023 Patryk Kalinowski (patrykkalinowski.com)
+# SPDX-License-Identifier: MIT
+
+## Implementation of the 1€ filter (https://gery.casiez.net/1euro/).[br]
+## Modification of https://github.com/patrykkalinowski/godot-xr-kit/blob/master/addons/xr-kit/smooth-input-filter/scripts/one_euro_filter.gd
+class_name OneEuroFilter
+
+var min_cutoff: float
+var beta: float
+var d_cutoff: float
+var x_filter: LowPassFilter
+var dx_filter: LowPassFilter
+
+func _init(args: Variant) -> void:
+	min_cutoff = args.cutoff
+	beta = args.beta
+	d_cutoff = args.cutoff
+	x_filter = LowPassFilter.new()
+	dx_filter = LowPassFilter.new()
+
+func alpha(rate: float, cutoff: float) -> float:
+	var tau: float = 1.0 / (2 * PI * cutoff)
+	var te: float = 1.0 / rate
+
+	return 1.0 / (1.0 + tau/te)
+
+func filter(value: float, delta: float) -> float:
+	var rate: float = 1.0 / delta
+	var dx: float = (value - x_filter.last_value) * rate
+
+	var edx: float = dx_filter.filter(dx, alpha(rate, d_cutoff))
+	var cutoff: float = min_cutoff + beta * abs(edx)
+	return x_filter.filter(value, alpha(rate, cutoff))
+
+class LowPassFilter:
+	var last_value: float
+
+	func _init() -> void:
+		last_value = 0
+
+	func filter(value: float, alpha: float) -> float:
+		var result := alpha * value + (1 - alpha) * last_value
+		last_value = result
+
+		return result

+ 1 - 0
audio/rhythm_game/globals/one_euro_filter.gd.uid

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

二進制
audio/rhythm_game/icon.webp


+ 34 - 0
audio/rhythm_game/icon.webp.import

@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdd4ws8b7jdqh"
+path="res://.godot/imported/icon.webp-e94f9a68b0f625a567a797079e4d325f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.webp"
+dest_files=["res://.godot/imported/icon.webp-e94f9a68b0f625a567a797079e4d325f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

二進制
audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav


+ 24 - 0
audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav.import

@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://dbs7gpp3wnsrd"
+path="res://.godot/imported/Perc_MetronomeQuartz_hi.wav-3da327bdefbd27ee612e1cef533f46ee.sample"
+
+[deps]
+
+source_file="res://music/Perc_MetronomeQuartz_hi.wav"
+dest_files=["res://.godot/imported/Perc_MetronomeQuartz_hi.wav-3da327bdefbd27ee612e1cef533f46ee.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2

二進制
audio/rhythm_game/music/the_comeback2.ogg


+ 19 - 0
audio/rhythm_game/music/the_comeback2.ogg.import

@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://cdrr8wk42fkfg"
+path="res://.godot/imported/the_comeback2.ogg-c6401f04c274bd0ff2fb70a543ae6ac6.oggvorbisstr"
+
+[deps]
+
+source_file="res://music/the_comeback2.ogg"
+dest_files=["res://.godot/imported/the_comeback2.ogg-c6401f04c274bd0ff2fb70a543ae6ac6.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0.0
+bpm=116.0
+beat_count=0
+bar_beats=4

+ 12 - 0
audio/rhythm_game/objects/guide/guide.gd

@@ -0,0 +1,12 @@
+extends Sprite2D
+
+var _guide_tween: Tween
+
+
+func _process(_delta: float) -> void:
+	if Input.is_action_just_pressed("main_key"):
+		scale = 1.2 * Vector2.ONE
+		if _guide_tween:
+			_guide_tween.kill()
+		_guide_tween = create_tween().set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
+		_guide_tween.tween_property(self, "scale", Vector2.ONE, 0.2)

+ 1 - 0
audio/rhythm_game/objects/guide/guide.gd.uid

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

二進制
audio/rhythm_game/objects/guide/guide.png


+ 34 - 0
audio/rhythm_game/objects/guide/guide.png.import

@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dmatn1tgn5pn6"
+path="res://.godot/imported/guide.png-36e780c483986c2cd13ea8423ff7de13.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://objects/guide/guide.png"
+dest_files=["res://.godot/imported/guide.png-36e780c483986c2cd13ea8423ff7de13.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

+ 8 - 0
audio/rhythm_game/objects/guide/guide.tscn

@@ -0,0 +1,8 @@
+[gd_scene load_steps=3 format=3 uid="uid://rrnv37orejuv"]
+
+[ext_resource type="Texture2D" uid="uid://dmatn1tgn5pn6" path="res://objects/guide/guide.png" id="1_fpaen"]
+[ext_resource type="Script" uid="uid://b6thxwiktedgx" path="res://objects/guide/guide.gd" id="2_euo2a"]
+
+[node name="Guide" type="Sprite2D"]
+texture = ExtResource("1_fpaen")
+script = ExtResource("2_euo2a")

+ 83 - 0
audio/rhythm_game/objects/note/note.gd

@@ -0,0 +1,83 @@
+class_name Note
+extends Node2D
+
+@export_category("Nodes")
+@export var conductor: Conductor
+
+@export_category("Settings")
+@export var x_offset: float = 0
+@export var beat: float = 0
+
+var _speed: float
+var _movement_paused: bool = false
+var _song_time_delta: float = 0
+
+
+func _init() -> void:
+	_speed = GlobalSettings.scroll_speed
+
+
+func _ready() -> void:
+	GlobalSettings.scroll_speed_changed.connect(_on_scroll_speed_changed)
+
+
+func _process(_delta: float) -> void:
+	if _movement_paused:
+		return
+
+	_update_position()
+
+
+func update_beat(curr_beat: float) -> void:
+	_song_time_delta = (curr_beat - beat) * conductor.get_beat_duration()
+
+	_update_position()
+
+
+func hit_perfect() -> void:
+	_movement_paused = true
+
+	modulate = Color.YELLOW
+
+	var tween := create_tween()
+	tween.set_ease(Tween.EASE_OUT)
+	tween.set_trans(Tween.TRANS_QUAD)
+	tween.parallel().tween_property(self, "modulate:a", 0, 0.2)
+	tween.parallel().tween_property($Sprite2D, "scale", 1.5 * Vector2.ONE, 0.2)
+	tween.tween_callback(queue_free)
+
+
+func hit_good() -> void:
+	_movement_paused = true
+
+	modulate = Color.DEEP_SKY_BLUE
+
+	var tween := create_tween()
+	tween.set_ease(Tween.EASE_OUT)
+	tween.set_trans(Tween.TRANS_QUAD)
+	tween.parallel().tween_property(self, "modulate:a", 0, 0.2)
+	tween.parallel().tween_property($Sprite2D, "scale", 1.2 * Vector2.ONE, 0.2)
+	tween.tween_callback(queue_free)
+
+
+func miss(stop_movement: bool = true) -> void:
+	_movement_paused = stop_movement
+
+	modulate = Color.DARK_RED
+
+	var tween := create_tween()
+	tween.parallel().tween_property(self, "modulate:a", 0, 0.5)
+	tween.tween_callback(queue_free)
+
+
+func _update_position() -> void:
+	if _song_time_delta > 0:
+		# Slow the note down past the judgment line.
+		position.y = _speed * _song_time_delta - _speed * pow(_song_time_delta, 2)
+	else:
+		position.y = _speed * _song_time_delta
+	position.x = x_offset
+
+
+func _on_scroll_speed_changed(speed: float) -> void:
+	_speed = speed

+ 1 - 0
audio/rhythm_game/objects/note/note.gd.uid

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

二進制
audio/rhythm_game/objects/note/note.png


+ 34 - 0
audio/rhythm_game/objects/note/note.png.import

@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b7gri822pdstu"
+path="res://.godot/imported/note.png-5d2572a024e219e8fd10eaed58eb7c40.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://objects/note/note.png"
+dest_files=["res://.godot/imported/note.png-5d2572a024e219e8fd10eaed58eb7c40.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

+ 10 - 0
audio/rhythm_game/objects/note/note.tscn

@@ -0,0 +1,10 @@
+[gd_scene load_steps=3 format=3 uid="uid://bkefp51bal7ci"]
+
+[ext_resource type="Script" uid="uid://dtp0l467x8dhf" path="res://objects/note/note.gd" id="1_doocs"]
+[ext_resource type="Texture2D" uid="uid://b7gri822pdstu" path="res://objects/note/note.png" id="2_wn01w"]
+
+[node name="Note" type="Node2D"]
+script = ExtResource("1_doocs")
+
+[node name="Sprite2D" type="Sprite2D" parent="."]
+texture = ExtResource("2_wn01w")

+ 65 - 0
audio/rhythm_game/project.godot

@@ -0,0 +1,65 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+;   [section] ; section goes between []
+;   param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Rhythm Game"
+config/description="Simple rhythm game utilizing precise playback position."
+run/main_scene="uid://cv53erwosrk7o"
+config/features=PackedStringArray("4.4", "GL Compatibility")
+run/max_fps=1000
+config/icon="uid://bdd4ws8b7jdqh"
+
+[audio]
+
+general/default_playback_type.web=0
+
+[autoload]
+
+GlobalSettings="*res://globals/global_settings.gd"
+
+[debug]
+
+gdscript/warnings/untyped_declaration=1
+
+[display]
+
+window/size/viewport_width=1280
+window/size/viewport_height=720
+window/stretch/mode="canvas_items"
+window/stretch/aspect="expand"
+window/vsync/vsync_mode=0
+
+[input]
+
+main_key={
+"deadzone": 0.2,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+pause={
+"deadzone": 0.2,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":80,"key_label":0,"unicode":112,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+restart={
+"deadzone": 0.2,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null)
+]
+}
+
+[rendering]
+
+renderer/rendering_method="gl_compatibility"
+renderer/rendering_method.mobile="gl_compatibility"

+ 219 - 0
audio/rhythm_game/scenes/main/main.gd

@@ -0,0 +1,219 @@
+extends Node2D
+
+class NoteHitData:
+	var beat_time: float
+	var type: Enums.HitType
+	var error: float
+
+	@warning_ignore("shadowed_variable")
+	func _init(beat_time: float, type: Enums.HitType, error: float) -> void:
+		self.beat_time = beat_time
+		self.type = type
+		self.error = error
+
+var _judgment_tween: Tween
+var _hit_data: Array[NoteHitData] = []
+
+
+func _enter_tree() -> void:
+	$Notes.chart = GlobalSettings.selected_chart
+
+
+func _ready() -> void:
+	$Control/SettingsVBox/UseFilteredCheckBox.button_pressed = GlobalSettings.use_filtered_playback
+	$Control/SettingsVBox/ShowOffsetCheckBox.button_pressed = GlobalSettings.show_offsets
+	$Control/SettingsVBox/MetronomeCheckBox.button_pressed = GlobalSettings.enable_metronome
+	$Control/SettingsVBox/InputLatencyHBox/SpinBox.value = GlobalSettings.input_latency_ms
+	$Control/SettingsVBox/ScrollSpeedHBox/CenterContainer/HSlider.value = GlobalSettings.scroll_speed
+	$Control/ChartVBox/OptionButton.selected = GlobalSettings.selected_chart
+	$Control/JudgmentHBox/LJudgmentLabel.modulate.a = 0
+	$Control/JudgmentHBox/RJudgmentLabel.modulate.a = 0
+
+	var latency_line_edit: LineEdit = $Control/SettingsVBox/InputLatencyHBox/SpinBox.get_line_edit()
+	latency_line_edit.text_submitted.connect(
+		func(_text: String) -> void:
+			latency_line_edit.release_focus())
+
+	await get_tree().create_timer(0.5).timeout
+
+	$Conductor.play()
+	$Metronome.start()
+
+
+func _process(_delta: float) -> void:
+	if Input.is_action_just_pressed("restart"):
+		get_tree().reload_current_scene()
+	$Control/ErrorGraphVBox/CenterContainer/TimeGraph.queue_redraw()
+
+
+func _update_stats(play_stats: PlayStats) -> void:
+	$Control/StatsVBox/PerfectLabel.text = "Perfect: %d" % play_stats.perfect_count
+	$Control/StatsVBox/GoodLabel.text = "Good: %d" % play_stats.good_count
+	$Control/StatsVBox/MissLabel.text = "Miss: %d" % play_stats.miss_count
+	var hit_error_ms := play_stats.mean_hit_error * 1000
+	if hit_error_ms < 0:
+		$Control/StatsVBox/HitErrorLabel.text = "Avg Error: %+.1f ms (Early)" % hit_error_ms
+	else:
+		$Control/StatsVBox/HitErrorLabel.text = "Avg Error: %+.1f ms (Late)" % hit_error_ms
+
+
+func _update_filter_state(use_filter: bool) -> void:
+	GlobalSettings.use_filtered_playback = use_filter
+	if use_filter:
+		$Notes.time_type = Enums.TimeType.FILTERED
+	else:
+		$Notes.time_type = Enums.TimeType.RAW
+
+
+func _hit_type_to_string(hit_type: Enums.HitType) -> String:
+	match hit_type:
+		Enums.HitType.MISS_EARLY:
+			return "Too Early..."
+		Enums.HitType.GOOD_EARLY:
+			return "Good"
+		Enums.HitType.PERFECT:
+			return "Perfect!"
+		Enums.HitType.GOOD_LATE:
+			return "Good"
+		Enums.HitType.MISS_LATE:
+			return "Miss..."
+		_:
+			assert(false, "Unknown HitType: %s" % hit_type)
+			return "Unknown"
+
+
+func _on_use_filtered_check_box_toggled(toggled_on: bool) -> void:
+	_update_filter_state(toggled_on)
+
+
+func _on_show_offset_check_box_toggled(toggled_on: bool) -> void:
+	GlobalSettings.show_offsets = toggled_on
+
+
+func _on_metronome_check_box_toggled(toggled_on: bool) -> void:
+	GlobalSettings.enable_metronome = toggled_on
+
+
+func _on_note_hit(beat: float, hit_type: Enums.HitType, hit_error: float) -> void:
+	var hit_type_str := _hit_type_to_string(hit_type)
+	if GlobalSettings.show_offsets:
+		var hit_error_ms := hit_error * 1000
+		if hit_error_ms < 0:
+			hit_type_str += "\n(Early %+d ms)" % hit_error_ms
+		else:
+			hit_type_str += "\n(Late %+d ms)" % hit_error_ms
+	$Control/JudgmentHBox/LJudgmentLabel.text = hit_type_str
+	$Control/JudgmentHBox/RJudgmentLabel.text = hit_type_str
+
+	$Control/JudgmentHBox/LJudgmentLabel.modulate.a = 1
+	$Control/JudgmentHBox/RJudgmentLabel.modulate.a = 1
+
+	if _judgment_tween:
+		_judgment_tween.kill()
+	_judgment_tween = create_tween()
+	_judgment_tween.tween_interval(0.2)
+	_judgment_tween.tween_property($Control/JudgmentHBox/LJudgmentLabel, "modulate:a", 0, 0.5)
+	_judgment_tween.parallel().tween_property($Control/JudgmentHBox/RJudgmentLabel, "modulate:a", 0, 0.5)
+
+	_hit_data.append(NoteHitData.new(beat, hit_type, hit_error))
+	$Control/ErrorGraphVBox/CenterContainer/JudgmentsGraph.queue_redraw()
+
+
+func _on_play_stats_updated(play_stats: PlayStats) -> void:
+	_update_stats(play_stats)
+
+
+func _on_song_finished(play_stats: PlayStats) -> void:
+	$Control/SongCompleteLabel.show()
+	_update_stats(play_stats)
+
+
+func _on_input_latency_spin_box_value_changed(value: float) -> void:
+	var latency_ms := roundi(value)
+	GlobalSettings.input_latency_ms = latency_ms
+	$Control/SettingsVBox/InputLatencyHBox/SpinBox.get_line_edit().release_focus()
+
+
+func _on_scroll_speed_h_slider_value_changed(value: float) -> void:
+	GlobalSettings.scroll_speed = value
+	$Control/SettingsVBox/ScrollSpeedHBox/Label.text = str(roundi(value))
+
+
+func _on_chart_option_button_item_selected(index: int) -> void:
+	if GlobalSettings.selected_chart != index:
+		GlobalSettings.selected_chart = index as ChartData.Chart
+		get_tree().reload_current_scene()
+
+
+func _on_judgments_graph_draw() -> void:
+	var graph: Control = $Control/ErrorGraphVBox/CenterContainer/JudgmentsGraph
+	var song_beats := ChartData.get_chart_data(GlobalSettings.selected_chart).size() * 4
+
+	# Draw horizontal lines for judgment edges
+	var abs_error_bound := NoteManager.HIT_MARGIN_GOOD + 0.01
+	var early_edge_good_y: float = remap(
+			-NoteManager.HIT_MARGIN_GOOD,
+			-abs_error_bound, abs_error_bound,
+			0, graph.size.y)
+	var early_edge_perfect_y: float = remap(
+			-NoteManager.HIT_MARGIN_PERFECT,
+			-abs_error_bound, abs_error_bound,
+			0, graph.size.y)
+	var late_edge_perfect_y: float = remap(
+			NoteManager.HIT_MARGIN_PERFECT,
+			-abs_error_bound, abs_error_bound,
+			0, graph.size.y)
+	var late_edge_good_y: float = remap(
+			NoteManager.HIT_MARGIN_GOOD,
+			-abs_error_bound, abs_error_bound,
+			0, graph.size.y)
+	graph.draw_line(
+			Vector2(0, early_edge_good_y),
+			Vector2(graph.size.x, early_edge_good_y),
+			Color.DIM_GRAY)
+	graph.draw_line(
+			Vector2(0, early_edge_perfect_y),
+			Vector2(graph.size.x, early_edge_perfect_y),
+			Color.DIM_GRAY)
+	graph.draw_line(
+			Vector2(0, graph.size.y / 2),
+			Vector2(graph.size.x, graph.size.y / 2),
+			Color.WHITE)
+	graph.draw_line(
+			Vector2(0, late_edge_perfect_y),
+			Vector2(graph.size.x, late_edge_perfect_y),
+			Color.DIM_GRAY)
+	graph.draw_line(
+			Vector2(0, late_edge_good_y),
+			Vector2(graph.size.x, late_edge_good_y),
+			Color.DIM_GRAY)
+
+	# Draw the judgments on the graph
+	for data in _hit_data:
+		var error := data.error
+		var color: Color
+		match data.type:
+			Enums.HitType.MISS_EARLY:
+				error = -NoteManager.HIT_MARGIN_GOOD - 0.005
+				color = Color.DARK_RED
+			Enums.HitType.MISS_LATE:
+				error = NoteManager.HIT_MARGIN_GOOD + 0.005
+				color = Color.DARK_RED
+			Enums.HitType.GOOD_EARLY, Enums.HitType.GOOD_LATE:
+				color = Color.DEEP_SKY_BLUE
+			Enums.HitType.PERFECT:
+				color = Color.YELLOW
+			_:
+				assert(false, "Unknown hit type: %d" % data.type)
+				color = Color.WHITE
+		var px: float = round(remap(data.beat_time, 0, song_beats, 0, graph.size.x))
+		var py: float = round(remap(error, -abs_error_bound, abs_error_bound, 0, graph.size.y))
+		graph.draw_rect(Rect2(px-1, py-1, 3, 3), Color(color, 0.8))
+
+
+func _on_time_graph_draw() -> void:
+	var graph: Control = $Control/ErrorGraphVBox/CenterContainer/TimeGraph
+	var song_beats := ChartData.get_chart_data(GlobalSettings.selected_chart).size() * 4
+	var curr_beat := clampf($Conductor.get_current_beat(), 0, song_beats)
+	var time_x: float = remap(curr_beat, 0, song_beats, 0, graph.size.x)
+	graph.draw_line(Vector2(time_x, 0), Vector2(time_x, graph.size.y), Color.WHITE, 2)

+ 1 - 0
audio/rhythm_game/scenes/main/main.gd.uid

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

+ 282 - 0
audio/rhythm_game/scenes/main/main.tscn

@@ -0,0 +1,282 @@
+[gd_scene load_steps=9 format=3 uid="uid://cv53erwosrk7o"]
+
+[ext_resource type="Script" uid="uid://bw05ipplwsl4v" path="res://scenes/main/main.gd" id="1_0xm2m"]
+[ext_resource type="Script" uid="uid://dxdm5hivq6xkf" path="res://game_state/conductor.gd" id="2_1bvp3"]
+[ext_resource type="Script" uid="uid://c7jdh7pv088ja" path="res://scenes/main/pause_handler.gd" id="2_7mycd"]
+[ext_resource type="AudioStream" uid="uid://cdrr8wk42fkfg" path="res://music/the_comeback2.ogg" id="2_h2yge"]
+[ext_resource type="Script" uid="uid://d0qi52a8nkb6o" path="res://game_state/note_manager.gd" id="4_lquwl"]
+[ext_resource type="PackedScene" uid="uid://rrnv37orejuv" path="res://objects/guide/guide.tscn" id="6_ow5a4"]
+[ext_resource type="Script" uid="uid://gd4p06mb2biq" path="res://game_state/metronome.gd" id="7_hujxm"]
+[ext_resource type="AudioStream" uid="uid://dbs7gpp3wnsrd" path="res://music/Perc_MetronomeQuartz_hi.wav" id="8_yyfjg"]
+
+[node name="Main" type="Node2D"]
+script = ExtResource("1_0xm2m")
+
+[node name="PauseHandler" type="Node" parent="."]
+process_mode = 3
+script = ExtResource("2_7mycd")
+
+[node name="Conductor" type="Node" parent="." node_paths=PackedStringArray("player")]
+process_mode = 3
+script = ExtResource("2_1bvp3")
+player = NodePath("../Player")
+bpm = 116.052
+first_beat_offset_ms = 8283
+
+[node name="Player" type="AudioStreamPlayer" parent="."]
+stream = ExtResource("2_h2yge")
+volume_db = -12.0
+
+[node name="Notes" type="Node2D" parent="." node_paths=PackedStringArray("conductor")]
+position = Vector2(640, 594)
+script = ExtResource("4_lquwl")
+conductor = NodePath("../Conductor")
+
+[node name="Guide" parent="." instance=ExtResource("6_ow5a4")]
+position = Vector2(640, 594)
+
+[node name="Control" type="Control" parent="."]
+layout_mode = 3
+anchors_preset = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+
+[node name="TutorialLabel" type="Label" parent="Control"]
+layout_mode = 1
+offset_left = 16.0
+offset_top = 16.0
+offset_right = 365.0
+offset_bottom = 91.0
+text = "Space to hit notes
+Esc or P to pause
+R to restart"
+
+[node name="SettingsVBox" type="VBoxContainer" parent="Control"]
+layout_mode = 0
+offset_left = 16.0
+offset_top = 157.0
+offset_right = 367.0
+offset_bottom = 398.0
+
+[node name="UseFilteredCheckBox" type="CheckBox" parent="Control/SettingsVBox"]
+layout_mode = 2
+focus_mode = 0
+button_pressed = true
+text = "Enable smoothing filter on playback position"
+
+[node name="Control" type="Control" parent="Control/SettingsVBox"]
+custom_minimum_size = Vector2(0, 36)
+layout_mode = 2
+
+[node name="MetronomeCheckBox" type="CheckBox" parent="Control/SettingsVBox"]
+layout_mode = 2
+focus_mode = 0
+text = "Enable metronome"
+
+[node name="InputLatencyHBox" type="HBoxContainer" parent="Control/SettingsVBox"]
+layout_mode = 2
+theme_override_constants/separation = 8
+
+[node name="Label" type="Label" parent="Control/SettingsVBox/InputLatencyHBox"]
+layout_mode = 2
+text = "Input Latency (ms):"
+
+[node name="SpinBox" type="SpinBox" parent="Control/SettingsVBox/InputLatencyHBox"]
+layout_mode = 2
+min_value = -100.0
+max_value = 1000.0
+value = 20.0
+rounded = true
+
+[node name="LatencyInstructionsLabel" type="Label" parent="Control/SettingsVBox"]
+layout_mode = 2
+text = "(Increase this if you are hitting notes too late)"
+
+[node name="Control2" type="Control" parent="Control/SettingsVBox"]
+custom_minimum_size = Vector2(0, 36)
+layout_mode = 2
+
+[node name="ScrollSpeedInstructionsLabel" type="Label" parent="Control/SettingsVBox"]
+layout_mode = 2
+text = "Scroll Speed:"
+
+[node name="ScrollSpeedHBox" type="HBoxContainer" parent="Control/SettingsVBox"]
+layout_mode = 2
+theme_override_constants/separation = 12
+
+[node name="CenterContainer" type="CenterContainer" parent="Control/SettingsVBox/ScrollSpeedHBox"]
+layout_mode = 2
+
+[node name="HSlider" type="HSlider" parent="Control/SettingsVBox/ScrollSpeedHBox/CenterContainer"]
+custom_minimum_size = Vector2(300, 0)
+layout_mode = 2
+focus_mode = 0
+min_value = 200.0
+max_value = 1000.0
+step = 50.0
+value = 400.0
+tick_count = 17
+ticks_on_borders = true
+
+[node name="Label" type="Label" parent="Control/SettingsVBox/ScrollSpeedHBox"]
+layout_mode = 2
+text = "400"
+
+[node name="ShowOffsetCheckBox" type="CheckBox" parent="Control/SettingsVBox"]
+layout_mode = 2
+focus_mode = 0
+text = "Show offset on judgment"
+
+[node name="StatsVBox" type="VBoxContainer" parent="Control"]
+layout_mode = 0
+offset_left = 900.0
+offset_top = 361.0
+offset_right = 1066.0
+offset_bottom = 465.0
+
+[node name="PerfectLabel" type="Label" parent="Control/StatsVBox"]
+layout_mode = 2
+text = "Perfect: 0"
+
+[node name="GoodLabel" type="Label" parent="Control/StatsVBox"]
+layout_mode = 2
+text = "Good: 0"
+
+[node name="MissLabel" type="Label" parent="Control/StatsVBox"]
+layout_mode = 2
+text = "Miss: 0"
+
+[node name="HitErrorLabel" type="Label" parent="Control/StatsVBox"]
+layout_mode = 2
+text = "Avg Hit Offset: 0.0 ms"
+
+[node name="ChartVBox" type="VBoxContainer" parent="Control"]
+layout_mode = 2
+offset_left = 900.0
+offset_top = 60.0
+offset_right = 1066.0
+offset_bottom = 118.0
+
+[node name="Label" type="Label" parent="Control/ChartVBox"]
+layout_mode = 2
+text = "Chart:"
+
+[node name="OptionButton" type="OptionButton" parent="Control/ChartVBox"]
+layout_mode = 2
+focus_mode = 0
+selected = 0
+allow_reselect = true
+item_count = 2
+popup/item_0/text = "The Comeback"
+popup/item_0/id = 1
+popup/item_1/text = "Sync Test"
+popup/item_1/id = 0
+
+[node name="SongCompleteLabel" type="Label" parent="Control"]
+visible = false
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -79.5
+offset_top = -37.5
+offset_right = 79.5
+offset_bottom = 37.5
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_font_sizes/font_size = 40
+text = "Song Complete!
+
+Press R to play again"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="PauseLabel" type="Label" parent="Control"]
+visible = false
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -79.5
+offset_top = -37.5
+offset_right = 79.5
+offset_bottom = 37.5
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_font_sizes/font_size = 40
+text = "Paused"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="JudgmentHBox" type="HBoxContainer" parent="Control"]
+layout_mode = 1
+anchors_preset = 7
+anchor_left = 0.5
+anchor_top = 1.0
+anchor_right = 0.5
+anchor_bottom = 1.0
+offset_left = -20.0
+offset_top = -184.0
+offset_right = 20.0
+offset_bottom = -144.0
+grow_horizontal = 2
+grow_vertical = 0
+theme_override_constants/separation = 200
+
+[node name="LJudgmentLabel" type="Label" parent="Control/JudgmentHBox"]
+layout_mode = 2
+text = "Perfect!!"
+horizontal_alignment = 1
+
+[node name="RJudgmentLabel" type="Label" parent="Control/JudgmentHBox"]
+layout_mode = 2
+text = "Perfect!!"
+horizontal_alignment = 1
+
+[node name="ErrorGraphVBox" type="VBoxContainer" parent="Control"]
+layout_mode = 1
+offset_left = 900.0
+offset_top = 518.0
+offset_right = 1250.0
+offset_bottom = 665.0
+
+[node name="Label" type="Label" parent="Control/ErrorGraphVBox"]
+layout_mode = 2
+text = "Error Graph:"
+
+[node name="CenterContainer" type="CenterContainer" parent="Control/ErrorGraphVBox"]
+layout_mode = 2
+
+[node name="GraphBackground" type="Panel" parent="Control/ErrorGraphVBox/CenterContainer"]
+custom_minimum_size = Vector2(350, 120)
+layout_mode = 2
+
+[node name="TimeGraph" type="Control" parent="Control/ErrorGraphVBox/CenterContainer"]
+custom_minimum_size = Vector2(350, 120)
+layout_mode = 2
+
+[node name="JudgmentsGraph" type="Control" parent="Control/ErrorGraphVBox/CenterContainer"]
+custom_minimum_size = Vector2(350, 120)
+layout_mode = 2
+
+[node name="Metronome" type="AudioStreamPlayer" parent="." node_paths=PackedStringArray("conductor")]
+stream = ExtResource("8_yyfjg")
+volume_db = 2.0
+script = ExtResource("7_hujxm")
+conductor = NodePath("../Conductor")
+
+[connection signal="note_hit" from="Notes" to="." method="_on_note_hit"]
+[connection signal="play_stats_updated" from="Notes" to="." method="_on_play_stats_updated"]
+[connection signal="song_finished" from="Notes" to="." method="_on_song_finished"]
+[connection signal="toggled" from="Control/SettingsVBox/UseFilteredCheckBox" to="." method="_on_use_filtered_check_box_toggled"]
+[connection signal="toggled" from="Control/SettingsVBox/MetronomeCheckBox" to="." method="_on_metronome_check_box_toggled"]
+[connection signal="value_changed" from="Control/SettingsVBox/InputLatencyHBox/SpinBox" to="." method="_on_input_latency_spin_box_value_changed"]
+[connection signal="value_changed" from="Control/SettingsVBox/ScrollSpeedHBox/CenterContainer/HSlider" to="." method="_on_scroll_speed_h_slider_value_changed"]
+[connection signal="toggled" from="Control/SettingsVBox/ShowOffsetCheckBox" to="." method="_on_show_offset_check_box_toggled"]
+[connection signal="item_selected" from="Control/ChartVBox/OptionButton" to="." method="_on_chart_option_button_item_selected"]
+[connection signal="draw" from="Control/ErrorGraphVBox/CenterContainer/TimeGraph" to="." method="_on_time_graph_draw"]
+[connection signal="draw" from="Control/ErrorGraphVBox/CenterContainer/JudgmentsGraph" to="." method="_on_judgments_graph_draw"]

+ 8 - 0
audio/rhythm_game/scenes/main/pause_handler.gd

@@ -0,0 +1,8 @@
+# Pause logic is separated out since it needs to run with PROCESS_MODE_ALWAYS.
+extends Node
+
+
+func _process(_delta: float) -> void:
+	if Input.is_action_just_pressed("pause"):
+		get_tree().paused = not get_tree().paused
+		$"../Control/PauseLabel".visible = get_tree().paused

+ 1 - 0
audio/rhythm_game/scenes/main/pause_handler.gd.uid

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

+ 0 - 0
audio/rhythm_game/screenshots/.gdignore


二進制
audio/rhythm_game/screenshots/rhythm_game.webp