Merge pull request #1197 from PizzaLovers007/rhythm-game

Add a simple rhythm game demo
This commit is contained in:
K. S. Ernest (iFire) Lee
2025-05-22 14:15:54 -07:00
committed by GitHub
41 changed files with 1437 additions and 0 deletions

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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()

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

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

BIN
audio/rhythm_game/icon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

View File

@@ -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

Binary file not shown.

View File

@@ -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

Binary file not shown.

View File

@@ -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

View File

@@ -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)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

View File

@@ -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

View File

@@ -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")

View File

@@ -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"

View File

@@ -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)

View File

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

View File

@@ -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"]

View File

@@ -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

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB