extends AudioStreamPlayer signal beat_ticked(beat_index: int) signal judgement_made(quality: StringName, offset_ms: float) signal action_judged(action_name: StringName, rating: Dictionary) @export var bpm: float = 120.0: set(value): bpm = maxf(1.0, value) beat_time = 60.0 / bpm @export var measures := 4 @export var beat_offset := 0.0 @export var perfect_window := 0.060 @export var good_window := 0.120 @export var bad_window := 0.200 @export var judgement_scale := 1.0 @export var starts_on_ready := true const DEFAULT_STREAM_PATH := "res://assets/audio/song.ogg" var beat_time := 0.5 var beat_index := 0 var running := false var _start_time_usec := 0 var _last_reported_beat := -1 func _ready() -> void: if stream == null and DisplayServer.get_name() != "headless": stream = load(DEFAULT_STREAM_PATH) volume_db = -10.0 beat_time = 60.0 / maxf(1.0, bpm) var bus := _event_bus_or_null() if bus != null and not bus.is_connected("rhythm_action_requested", _on_rhythm_action_requested): bus.connect("rhythm_action_requested", _on_rhythm_action_requested) if starts_on_ready: start() func _physics_process(_delta: float) -> void: if not running: return var adjusted_position := _apply_beat_offset(song_position()) beat_index = int(floor(adjusted_position / beat_time)) if _last_reported_beat < beat_index: _last_reported_beat = beat_index beat_ticked.emit(beat_index) var bus := _event_bus_or_null() if bus != null: bus.emit_signal("beat_ticked", beat_index) func configure(next_bpm: float, next_measures: int, next_beat_offset: float, next_windows := Vector3(0.060, 0.120, 0.200)) -> void: bpm = next_bpm measures = next_measures beat_offset = next_beat_offset perfect_window = next_windows.x good_window = next_windows.y bad_window = next_windows.z func start() -> void: running = true _start_time_usec = Time.get_ticks_usec() beat_index = 0 _last_reported_beat = -1 if stream != null and not playing: play() func stop_manager() -> void: if playing: stop() running = false func _exit_tree() -> void: if playing: stop() stream = null func song_position() -> float: if running and playing: var current_position := get_playback_position() + AudioServer.get_time_since_last_mix() current_position -= AudioServer.get_output_latency() return maxf(0.0, current_position) if running: return float(Time.get_ticks_usec() - _start_time_usec) / 1000000.0 return 0.0 func judge(input_timestamp_ms: float) -> Dictionary: return get_rating_for_time(input_timestamp_ms / 1000.0) func judge_action(action_name: StringName) -> Dictionary: var rating := get_current_rating() rating["action"] = action_name action_judged.emit(action_name, rating) judgement_made.emit(StringName(str(rating.get("label", "miss"))), float(rating.get("diff", INF)) * 1000.0) var bus := _event_bus_or_null() if bus != null: bus.emit_signal("judgement_made", StringName(str(rating.get("label", "miss"))), float(rating.get("diff", INF)) * 1000.0) bus.emit_signal("action_judged", action_name, rating) return rating func get_current_rating() -> Dictionary: return get_rating_for_time(song_position()) func get_rating_for_time(time_seconds: float) -> Dictionary: var adjusted_time := _apply_beat_offset(time_seconds) if adjusted_time < 0.0: return _rating_result(&"miss", Color("ff0055"), 0, 0.0, INF, INF) var nearest_beat := int(round(adjusted_time / beat_time)) var nearest_beat_time := nearest_beat * beat_time var diff := adjusted_time - nearest_beat_time var abs_diff := absf(diff) var scale := maxf(0.01, judgement_scale) if abs_diff <= perfect_window * scale: return _rating_result(&"perfect", Color("00f2ff"), nearest_beat, nearest_beat_time, diff, abs_diff) if abs_diff <= good_window * scale: return _rating_result(&"good", Color("ffffff"), nearest_beat, nearest_beat_time, diff, abs_diff) if abs_diff <= bad_window * scale: return _rating_result(&"bad", Color("ffaa00"), nearest_beat, nearest_beat_time, diff, abs_diff) return _rating_result(&"miss", Color("ff0055"), nearest_beat, nearest_beat_time, diff, abs_diff) func get_current_beat_progress() -> float: return get_beat_progress_for_time(song_position()) func get_beat_progress_for_time(time_seconds: float) -> float: var adjusted_time := _apply_beat_offset(time_seconds) if adjusted_time < 0.0: return 0.0 return fposmod(adjusted_time, beat_time) / beat_time func _on_rhythm_action_requested(action_name: StringName) -> void: judge_action(action_name) func _apply_beat_offset(time_seconds: float) -> float: return time_seconds + beat_offset func _rating_result(label: StringName, color: Color, nearest_beat: int, nearest_beat_time: float, diff: float, abs_diff: float) -> Dictionary: return { "label": str(label), "color": color, "nearest_beat": nearest_beat, "nearest_beat_time": nearest_beat_time, "diff": diff, "abs_diff": abs_diff, } func _event_bus_or_null() -> Node: if not is_inside_tree(): return null return get_tree().root.get_node_or_null("EventBus")