263 lines
8.1 KiB
GDScript
263 lines
8.1 KiB
GDScript
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")
|