Initial project sync

This commit is contained in:
wxm
2026-07-06 22:55:14 -07:00
commit 8cca00d0da
1066 changed files with 58585 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
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")