diff --git a/audio/rhythm_game/README.md b/audio/rhythm_game/README.md new file mode 100644 index 00000000..d0d7c9df --- /dev/null +++ b/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) diff --git a/audio/rhythm_game/game_state/conductor.gd b/audio/rhythm_game/game_state/conductor.gd new file mode 100644 index 00000000..39896801 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/game_state/conductor.gd.uid b/audio/rhythm_game/game_state/conductor.gd.uid new file mode 100644 index 00000000..a05a3d59 --- /dev/null +++ b/audio/rhythm_game/game_state/conductor.gd.uid @@ -0,0 +1 @@ +uid://dxdm5hivq6xkf diff --git a/audio/rhythm_game/game_state/metronome.gd b/audio/rhythm_game/game_state/metronome.gd new file mode 100644 index 00000000..bde6b5a3 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/game_state/metronome.gd.uid b/audio/rhythm_game/game_state/metronome.gd.uid new file mode 100644 index 00000000..3abcb275 --- /dev/null +++ b/audio/rhythm_game/game_state/metronome.gd.uid @@ -0,0 +1 @@ +uid://gd4p06mb2biq diff --git a/audio/rhythm_game/game_state/note_manager.gd b/audio/rhythm_game/game_state/note_manager.gd new file mode 100644 index 00000000..22181c53 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/game_state/note_manager.gd.uid b/audio/rhythm_game/game_state/note_manager.gd.uid new file mode 100644 index 00000000..dbce8ed9 --- /dev/null +++ b/audio/rhythm_game/game_state/note_manager.gd.uid @@ -0,0 +1 @@ +uid://d0qi52a8nkb6o diff --git a/audio/rhythm_game/game_state/play_stats.gd b/audio/rhythm_game/game_state/play_stats.gd new file mode 100644 index 00000000..9bab3ab8 --- /dev/null +++ b/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() diff --git a/audio/rhythm_game/game_state/play_stats.gd.uid b/audio/rhythm_game/game_state/play_stats.gd.uid new file mode 100644 index 00000000..81deaceb --- /dev/null +++ b/audio/rhythm_game/game_state/play_stats.gd.uid @@ -0,0 +1 @@ +uid://d137fo6uik460 diff --git a/audio/rhythm_game/globals/chart_data.gd b/audio/rhythm_game/globals/chart_data.gd new file mode 100644 index 00000000..a00261b5 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/globals/chart_data.gd.uid b/audio/rhythm_game/globals/chart_data.gd.uid new file mode 100644 index 00000000..3dd7833f --- /dev/null +++ b/audio/rhythm_game/globals/chart_data.gd.uid @@ -0,0 +1 @@ +uid://ciu0moyvmacic diff --git a/audio/rhythm_game/globals/enums.gd b/audio/rhythm_game/globals/enums.gd new file mode 100644 index 00000000..a8964819 --- /dev/null +++ b/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, +} diff --git a/audio/rhythm_game/globals/enums.gd.uid b/audio/rhythm_game/globals/enums.gd.uid new file mode 100644 index 00000000..aca3d84d --- /dev/null +++ b/audio/rhythm_game/globals/enums.gd.uid @@ -0,0 +1 @@ +uid://xcrq8x2xiprj diff --git a/audio/rhythm_game/globals/global_settings.gd b/audio/rhythm_game/globals/global_settings.gd new file mode 100644 index 00000000..71d941e5 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/globals/global_settings.gd.uid b/audio/rhythm_game/globals/global_settings.gd.uid new file mode 100644 index 00000000..ec91f056 --- /dev/null +++ b/audio/rhythm_game/globals/global_settings.gd.uid @@ -0,0 +1 @@ +uid://dxaqpkhijmwxf diff --git a/audio/rhythm_game/globals/one_euro_filter.gd b/audio/rhythm_game/globals/one_euro_filter.gd new file mode 100644 index 00000000..9aa761d4 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/globals/one_euro_filter.gd.uid b/audio/rhythm_game/globals/one_euro_filter.gd.uid new file mode 100644 index 00000000..6e193b8b --- /dev/null +++ b/audio/rhythm_game/globals/one_euro_filter.gd.uid @@ -0,0 +1 @@ +uid://cig8iydqhxf1i diff --git a/audio/rhythm_game/icon.webp b/audio/rhythm_game/icon.webp new file mode 100644 index 00000000..0f128c4a Binary files /dev/null and b/audio/rhythm_game/icon.webp differ diff --git a/audio/rhythm_game/icon.webp.import b/audio/rhythm_game/icon.webp.import new file mode 100644 index 00000000..2d141412 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav b/audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav new file mode 100644 index 00000000..e410e1fb Binary files /dev/null and b/audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav differ diff --git a/audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav.import b/audio/rhythm_game/music/Perc_MetronomeQuartz_hi.wav.import new file mode 100644 index 00000000..bff7b259 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/music/the_comeback2.ogg b/audio/rhythm_game/music/the_comeback2.ogg new file mode 100644 index 00000000..a6e5d050 Binary files /dev/null and b/audio/rhythm_game/music/the_comeback2.ogg differ diff --git a/audio/rhythm_game/music/the_comeback2.ogg.import b/audio/rhythm_game/music/the_comeback2.ogg.import new file mode 100644 index 00000000..df27d32e --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/objects/guide/guide.gd b/audio/rhythm_game/objects/guide/guide.gd new file mode 100644 index 00000000..27820377 --- /dev/null +++ b/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) diff --git a/audio/rhythm_game/objects/guide/guide.gd.uid b/audio/rhythm_game/objects/guide/guide.gd.uid new file mode 100644 index 00000000..e663998a --- /dev/null +++ b/audio/rhythm_game/objects/guide/guide.gd.uid @@ -0,0 +1 @@ +uid://b6thxwiktedgx diff --git a/audio/rhythm_game/objects/guide/guide.png b/audio/rhythm_game/objects/guide/guide.png new file mode 100644 index 00000000..02148823 Binary files /dev/null and b/audio/rhythm_game/objects/guide/guide.png differ diff --git a/audio/rhythm_game/objects/guide/guide.png.import b/audio/rhythm_game/objects/guide/guide.png.import new file mode 100644 index 00000000..e5e8a378 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/objects/guide/guide.tscn b/audio/rhythm_game/objects/guide/guide.tscn new file mode 100644 index 00000000..b52208c8 --- /dev/null +++ b/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") diff --git a/audio/rhythm_game/objects/note/note.gd b/audio/rhythm_game/objects/note/note.gd new file mode 100644 index 00000000..c79750b6 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/objects/note/note.gd.uid b/audio/rhythm_game/objects/note/note.gd.uid new file mode 100644 index 00000000..c8346966 --- /dev/null +++ b/audio/rhythm_game/objects/note/note.gd.uid @@ -0,0 +1 @@ +uid://dtp0l467x8dhf diff --git a/audio/rhythm_game/objects/note/note.png b/audio/rhythm_game/objects/note/note.png new file mode 100644 index 00000000..6329c360 Binary files /dev/null and b/audio/rhythm_game/objects/note/note.png differ diff --git a/audio/rhythm_game/objects/note/note.png.import b/audio/rhythm_game/objects/note/note.png.import new file mode 100644 index 00000000..1d608f35 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/objects/note/note.tscn b/audio/rhythm_game/objects/note/note.tscn new file mode 100644 index 00000000..bc342e0d --- /dev/null +++ b/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") diff --git a/audio/rhythm_game/project.godot b/audio/rhythm_game/project.godot new file mode 100644 index 00000000..3bc7dbcf --- /dev/null +++ b/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" diff --git a/audio/rhythm_game/scenes/main/main.gd b/audio/rhythm_game/scenes/main/main.gd new file mode 100644 index 00000000..8b71d440 --- /dev/null +++ b/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) diff --git a/audio/rhythm_game/scenes/main/main.gd.uid b/audio/rhythm_game/scenes/main/main.gd.uid new file mode 100644 index 00000000..09779ccb --- /dev/null +++ b/audio/rhythm_game/scenes/main/main.gd.uid @@ -0,0 +1 @@ +uid://bw05ipplwsl4v diff --git a/audio/rhythm_game/scenes/main/main.tscn b/audio/rhythm_game/scenes/main/main.tscn new file mode 100644 index 00000000..98070b52 --- /dev/null +++ b/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"] diff --git a/audio/rhythm_game/scenes/main/pause_handler.gd b/audio/rhythm_game/scenes/main/pause_handler.gd new file mode 100644 index 00000000..8fdc3d49 --- /dev/null +++ b/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 diff --git a/audio/rhythm_game/scenes/main/pause_handler.gd.uid b/audio/rhythm_game/scenes/main/pause_handler.gd.uid new file mode 100644 index 00000000..9399a4b3 --- /dev/null +++ b/audio/rhythm_game/scenes/main/pause_handler.gd.uid @@ -0,0 +1 @@ +uid://c7jdh7pv088ja diff --git a/audio/rhythm_game/screenshots/.gdignore b/audio/rhythm_game/screenshots/.gdignore new file mode 100644 index 00000000..e69de29b diff --git a/audio/rhythm_game/screenshots/rhythm_game.webp b/audio/rhythm_game/screenshots/rhythm_game.webp new file mode 100644 index 00000000..cbc9fb42 Binary files /dev/null and b/audio/rhythm_game/screenshots/rhythm_game.webp differ