extends AudioStreamPlayer signal beat_ticked(beat_index: int) signal judgement_made(quality: StringName, offset_ms: float, beat_index: int) @export var bpm: float = 129.2: 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.115 @export var bad_window := 0.215 @export var judgement_scale := 1.0 ## AnchorV1.0 §9.5: at high BPM the Bad window may never cross 48% of a beat, ## and each tighter window may never exceed the looser one it nests inside. @export var bad_window_beat_ratio_cap := 0.48 ## new1 定案 (2026-07-05): 每个节奏点只能被一次有效输入消耗;无可用节奏点的 ## 按键判 miss 并触发输入锁定,锁定期间的按键完全无效(反狂按)。 @export var input_lockout_seconds := 0.5 ## 同一节奏点消耗后允许忽略的重复按键次数(双击容错),超出即 miss+锁定。 @export var consumed_repeat_grace := 1 @export var clock_volume_db: float = -10.0 @export var starts_on_ready := true const DEFAULT_STREAM_PATH := "res://assets/audio/ev_past1.mp3" var beat_time := 0.5 var beat_index := 0 var running := false var _start_time_usec := 0 var _last_reported_beat := -1 var _clock_offset := 0.0 var _last_raw_playback_position := 0.0 var _playback_loop_offset := 0.0 var _clock_paused := false var _paused_song_position := 0.0 var _consumed_beats: Dictionary = {} var _lockout_until := -1.0 func _ready() -> void: if stream == null and DisplayServer.get_name() != "headless": stream = load(DEFAULT_STREAM_PATH) volume_db = clock_volume_db beat_time = 60.0 / maxf(1.0, bpm) if starts_on_ready: start() func _process(_delta: float) -> void: if not running or not playing: return var raw_song := _unwrapped_playback_position() + AudioServer.get_time_since_last_mix() raw_song -= AudioServer.get_output_latency() var ticks_sec := Time.get_ticks_msec() / 1000.0 _clock_offset = lerpf(_clock_offset, raw_song - ticks_sec, 0.05) 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 apply_judgement_windows(next_perfect: float, next_good: float, next_bad: float) -> void: perfect_window = maxf(0.001, next_perfect) good_window = maxf(perfect_window, next_good) bad_window = maxf(good_window, next_bad) func effective_windows() -> Vector3: var capped_bad := minf(bad_window, beat_time * bad_window_beat_ratio_cap) var capped_good := minf(good_window, capped_bad) var capped_perfect := minf(perfect_window, capped_good) return Vector3(capped_perfect, capped_good, capped_bad) func configure(next_bpm: float, next_measures: int, next_beat_offset: float, next_windows := Vector3(0.060, 0.115, 0.215)) -> void: bpm = next_bpm measures = next_measures beat_offset = next_beat_offset apply_judgement_windows(next_windows.x, next_windows.y, next_windows.z) func start() -> void: running = true _clock_paused = false _paused_song_position = 0.0 _start_time_usec = Time.get_ticks_usec() _clock_offset = -Time.get_ticks_msec() / 1000.0 _last_raw_playback_position = 0.0 _playback_loop_offset = 0.0 beat_index = 0 _last_reported_beat = -1 _consumed_beats.clear() _lockout_until = -1.0 if stream != null: if playing: seek(0.0) else: play() func stop_manager() -> void: if playing: stop() running = false _clock_paused = false func _exit_tree() -> void: if playing: stop() stream = null ## Freezes the musical clock for pause/menu states. The audio presentation can ## be paused separately; this keeps judgement time stable across menu duration. func pause_clock() -> void: if _clock_paused or not running: return _paused_song_position = song_position() _clock_paused = true func resume_clock() -> void: if not _clock_paused: return _clock_offset = _paused_song_position - Time.get_ticks_msec() / 1000.0 _clock_paused = false func song_position() -> float: if _clock_paused: return _paused_song_position if running: return maxf(0.0, Time.get_ticks_msec() / 1000.0 + _clock_offset) return 0.0 func input_to_song_time(timestamp_ms: float) -> float: return timestamp_ms / 1000.0 + _clock_offset func judge(input_timestamp_ms: float) -> Dictionary: return get_rating_for_time(input_to_song_time(input_timestamp_ms)) func is_input_locked() -> bool: return _lockout_until >= 0.0 and song_position() < _lockout_until ## new1 定案:对一次已判定的实时动作输入做有状态门控。 ## 返回 &"ok"(判定成立,消耗该节奏点)、&"ignored"(锁定期/双击容错,视同没按) ## 或 &"miss"(节奏点已被消耗且超出容错 → 降级为 miss,并开启输入锁定)。 ## 纯判定接口 get_rating_for_time / judge 保持无副作用;只有这里改写状态。 func gate_judged_input(rating: Dictionary) -> StringName: if is_input_locked(): return &"ignored" if str(rating.get("label", "miss")) == "miss": _begin_input_lockout() return &"miss" var beat := int(rating.get("nearest_beat", 0)) if _consumed_beats.has(beat): var repeats := int(_consumed_beats[beat]) if repeats < consumed_repeat_grace: _consumed_beats[beat] = repeats + 1 return &"ignored" _begin_input_lockout() return &"miss" _consumed_beats[beat] = 0 _prune_consumed_beats(beat) return &"ok" func _begin_input_lockout() -> void: if input_lockout_seconds > 0.0: _lockout_until = song_position() + input_lockout_seconds func _prune_consumed_beats(latest_beat: int) -> void: if _consumed_beats.size() <= 16: return for beat: int in _consumed_beats.keys(): if beat < latest_beat - 8: _consumed_beats.erase(beat) 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) var windows := effective_windows() if abs_diff <= windows.x * scale: return _rating_result(&"perfect", Color("00f2ff"), nearest_beat, nearest_beat_time, diff, abs_diff) if abs_diff <= windows.y * scale: return _rating_result(&"good", Color("ffffff"), nearest_beat, nearest_beat_time, diff, abs_diff) if abs_diff <= windows.z * 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 _apply_beat_offset(time_seconds: float) -> float: return time_seconds + beat_offset func _unwrapped_playback_position() -> float: var raw_position := get_playback_position() var length := stream.get_length() if stream != null else 0.0 if length > 0.0 and _last_raw_playback_position - raw_position > length * 0.5: _playback_loop_offset += length _last_raw_playback_position = raw_position return raw_position + _playback_loop_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")