extends Node ## Time anchor scheduling + resolution (AnchorV1.0 chapter 10/11). ## Sources v1: explicit chart `time_anchor` events + periodic baseline whose ## interval follows the streak frequency profile. Resolution only consumes ## `judgement_made` facts; it never judges input a second time. const DEFAULT_FREQUENCY_PROFILE_PATH := "res://resources/time_anchor_frequency_default.tres" const DEADLINE_EPSILON := 0.02 const SOURCE_CHART := &"chart" const SOURCE_PERIODIC := &"periodic" @export var lead_beats := 2.0 @export var periodic_scheduling_enabled := true @export var frequency_profile: Resource var current_streak := 0 var _anchors: Dictionary = {} var _anchored_beats: Dictionary = {} var _last_periodic_origin_beat := 0 var _next_periodic_window_start := 0 var _periodic_initialized := false var _last_resolved: Dictionary = {} func _ready() -> void: if frequency_profile == null and ResourceLoader.exists(DEFAULT_FREQUENCY_PROFILE_PATH): frequency_profile = load(DEFAULT_FREQUENCY_PROFILE_PATH) var bus := _event_bus_or_null() if bus == null: return _connect_once(bus, "chart_event_upcoming", _on_chart_event_upcoming) _connect_once(bus, "chart_event_triggered", _on_chart_event_triggered) _connect_once(bus, "judgement_made", _on_judgement_made) _connect_once(bus, "streak_changed", _on_streak_changed) _connect_once(bus, "chart_reset", _on_chart_reset) func _physics_process(_delta: float) -> void: # Only the real autoload singleton self-drives from the global clock. # Test-added instances get auto-renamed on the name collision and are # driven explicitly, so the autoload's wall clock never pollutes them. if name != &"TimeAnchorSystem": return var rhythm := _rhythm_manager_or_null() if rhythm == null or not bool(rhythm.get("running")): return var song_time := float(rhythm.call("song_position")) var beat_time := maxf(0.001, float(rhythm.get("beat_time"))) var beat_offset := float(rhythm.get("beat_offset")) update_periodic_scheduling((song_time + beat_offset) / beat_time) check_deadlines_for_song_time(song_time) func update_periodic_scheduling(judgement_beat_float: float) -> void: if not periodic_scheduling_enabled: return if not _periodic_initialized: _periodic_initialized = true _next_periodic_window_start = maxi(0, int(floorf(judgement_beat_float / float(_window_beats()))) * _window_beats()) _last_periodic_origin_beat = _next_periodic_window_start var lead_limit := judgement_beat_float + lead_beats while true: var has_candidate_after_lead := false for offset: int in _current_window_offsets(): var candidate_beat := _next_periodic_window_start + offset if float(candidate_beat) <= judgement_beat_float: continue if float(candidate_beat) > lead_limit: has_candidate_after_lead = true break if not _anchored_beats.has(candidate_beat): schedule_time_anchor(candidate_beat, SOURCE_PERIODIC) if has_candidate_after_lead: break _next_periodic_window_start += _window_beats() _last_periodic_origin_beat = _next_periodic_window_start func schedule_time_anchor(beat: int, source: StringName, source_event: Resource = null, min_grade: StringName = &"bad") -> Dictionary: if _anchored_beats.has(beat): var existing_key: StringName = _anchored_beats[beat] var existing: Dictionary = _anchors.get(existing_key, {}) if source == SOURCE_CHART and StringName(str(existing.get("source"))) == SOURCE_PERIODIC: existing["source"] = SOURCE_CHART existing["source_event"] = source_event existing["min_grade"] = min_grade _restart_periodic_from_beat(beat) return existing var key := StringName("%s_%d" % [source, beat]) var anchor := { "key": key, "beat": beat, "deadline": _deadline_for_beat(beat), "min_grade": min_grade, "source": source, "source_event": source_event, } _anchors[key] = anchor _anchored_beats[beat] = key if source == SOURCE_CHART: _restart_periodic_from_beat(beat) var bus := _event_bus_or_null() if bus != null and bus.has_signal("time_anchor_scheduled"): bus.emit_signal("time_anchor_scheduled", anchor) return anchor func check_deadlines_for_song_time(song_time: float) -> void: var broken: Array[Dictionary] = [] for key: StringName in _anchors.keys(): var anchor: Dictionary = _anchors[key] if song_time > float(anchor.get("deadline", 0.0)): broken.append(anchor) for anchor: Dictionary in broken: _resolve_anchor(anchor, false, {}) func armed_anchor_summaries() -> Array[Dictionary]: var summaries: Array[Dictionary] = [] for key: StringName in _anchors.keys(): var anchor: Dictionary = _anchors[key] summaries.append({ "beat": int(anchor.get("beat", 0)), "deadline": float(anchor.get("deadline", 0.0)), "min_grade": StringName(str(anchor.get("min_grade", &"bad"))), "source": StringName(str(anchor.get("source", &"-"))), }) summaries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.get("beat", 0)) < int(b.get("beat", 0)) ) return summaries func last_resolved_summary() -> Dictionary: return _last_resolved.duplicate() func anchored_beat_count() -> int: return _anchored_beats.size() func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void: if quality == &"miss": return var key: StringName = _anchored_beats.get(beat_index, &"") if key.is_empty() or not _anchors.has(key): return var anchor: Dictionary = _anchors[key] if _grade_rank(quality) < _grade_rank(StringName(str(anchor.get("min_grade", &"bad")))): return _resolve_anchor(anchor, true, { "label": str(quality), "offset_ms": offset_ms, "beat_index": beat_index, }) func _on_chart_event_upcoming(event: Resource, _time_to_event: float) -> void: _try_schedule_chart_anchor(event) func _on_chart_event_triggered(event: Resource) -> void: if event == null: return var event_type := StringName(str(event.get("event_type"))) if event_type == &"time_anchor": _try_schedule_chart_anchor(event) elif event_type == &"force_time_phase": var payload: Dictionary = event.get("payload") if event.get("payload") is Dictionary else {} var target := StringName(str(payload.get("target_time_phase", ""))) var manager := _time_phase_manager_or_null() if manager != null and manager.has_method("set_time_phase"): manager.call("set_time_phase", target, &"chart_forced") func _try_schedule_chart_anchor(event: Resource) -> void: if event == null or StringName(str(event.get("event_type"))) != &"time_anchor": return if int(event.get("subdivision")) != 0: push_warning("time_anchor events must sit on integer beats; skipping %s" % str(event.call("key"))) return var min_grade := &"bad" var payload: Dictionary = event.get("payload") if event.get("payload") is Dictionary else {} if payload.has("min_grade"): min_grade = StringName(str(payload["min_grade"])) schedule_time_anchor(int(event.get("beat_index")), SOURCE_CHART, event, min_grade) func _on_streak_changed(count: int) -> void: current_streak = maxi(0, count) func _on_chart_reset(_chart_id: StringName) -> void: _anchors.clear() _anchored_beats.clear() _periodic_initialized = false _last_periodic_origin_beat = 0 _next_periodic_window_start = 0 current_streak = 0 _last_resolved = {} func _resolve_anchor(anchor: Dictionary, held: bool, judgement: Dictionary) -> void: var key := StringName(str(anchor.get("key"))) _anchors.erase(key) _anchored_beats.erase(int(anchor.get("beat", 0))) _last_resolved = { "beat": int(anchor.get("beat", 0)), "held": held, "source": StringName(str(anchor.get("source", &"-"))), "judgement": judgement.get("label", ""), } var bus := _event_bus_or_null() if bus != null and bus.has_signal("time_anchor_resolved"): bus.emit_signal("time_anchor_resolved", anchor, held, judgement) if not held: var manager := _time_phase_manager_or_null() if manager != null and manager.has_method("toggle_time_phase"): manager.call("toggle_time_phase", &"time_anchor_broken") func _restart_periodic_from_beat(beat: int) -> void: _last_periodic_origin_beat = maxi(_last_periodic_origin_beat, beat) _next_periodic_window_start = maxi(_next_periodic_window_start, beat) func _current_interval() -> int: if frequency_profile != null and frequency_profile.has_method("interval_for_streak"): return maxi(1, int(frequency_profile.call("interval_for_streak", current_streak))) return _window_beats() func _current_window_offsets() -> Array[int]: if frequency_profile != null and frequency_profile.has_method("anchor_offsets_for_streak"): var offsets = frequency_profile.call("anchor_offsets_for_streak", current_streak) if offsets is Array and not offsets.is_empty(): var result: Array[int] = [] for offset: Variant in offsets: result.append(clampi(int(offset), 1, _window_beats())) return result return [_window_beats()] func _window_beats() -> int: if frequency_profile != null: var value = frequency_profile.get("window_beats") if value != null: return maxi(1, int(value)) return 4 func _deadline_for_beat(beat: int) -> float: var beat_time := 0.5 var beat_offset := 0.0 var bad_window := 0.2 var judgement_scale := 1.0 var rhythm := _rhythm_manager_or_null() if rhythm != null: beat_time = maxf(0.001, float(rhythm.get("beat_time"))) beat_offset = float(rhythm.get("beat_offset")) bad_window = float(rhythm.get("bad_window")) judgement_scale = maxf(0.01, float(rhythm.get("judgement_scale"))) return float(beat) * beat_time - beat_offset + bad_window * judgement_scale + DEADLINE_EPSILON func _grade_rank(grade: StringName) -> int: match grade: &"perfect": return 3 &"good": return 2 &"bad": return 1 return 0 func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void: if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback): bus.connect(signal_name, callback) func _rhythm_manager_or_null() -> Node: # Reverse root scan like the bus/manager lookups: a test-added rhythm clock # wins over the autoload, so the autoload's wall-clock never leaks into a # test instance's periodic scheduling. if not is_inside_tree(): return null return _last_root_child_matching(func(child: Node) -> bool: return child.has_method("song_position") and child.get("beat_time") != null ) func _time_phase_manager_or_null() -> Node: if not is_inside_tree(): return null return _last_root_child_matching(func(child: Node) -> bool: return child.has_method("set_time_phase") and child.get("current_time_phase") != null ) func _event_bus_or_null() -> Node: if not is_inside_tree(): return null return _last_root_child_matching(func(child: Node) -> bool: return child.has_signal("chart_event_triggered") and child.has_signal("time_phase_changed") ) func _last_root_child_matching(predicate: Callable) -> Node: var children := get_tree().root.get_children() for index: int in range(children.size() - 1, -1, -1): var child: Node = children[index] if bool(predicate.call(child)): return child return null