class_name ChartRunner extends Node signal chart_event_upcoming(event: Resource, time_to_event: float) signal chart_event_triggered(event: Resource) signal chart_reset(chart_id: StringName) signal chart_finished(chart_id: StringName) const UPCOMING_TIME_EPSILON := 0.02 @export var chart: Resource @export var rhythm_manager_path: NodePath @export var actors_container_path: NodePath @export var beat_time_override := 0.0 @export var auto_run := true var running := true var _upcoming_keys: Dictionary = {} var _triggered_keys: Dictionary = {} func _ready() -> void: running = auto_run _apply_initial_time_phase() func _physics_process(_delta: float) -> void: if not running or chart == null: return var rhythm := _rhythm_manager() if rhythm == null or not rhythm.has_method("song_position"): return update_for_song_time(float(rhythm.call("song_position"))) func set_chart(next_chart: Resource) -> void: chart = next_chart reset() func reset() -> void: _upcoming_keys.clear() _triggered_keys.clear() var chart_id := &"" if chart != null: chart_id = StringName(str(chart.get("chart_id"))) chart_reset.emit(chart_id) var bus := _event_bus_or_null() if bus != null: bus.emit_signal("chart_reset", chart_id) _apply_initial_time_phase() func update_for_song_time(song_time: float) -> void: if chart == null: return var beat_time := _beat_time() for event: Resource in chart.call("all_events"): var event_time := float(event.call("time_seconds", beat_time)) var time_to_event := event_time - song_time var lead_time := maxf(0.0, float(event.get("lead_beats"))) * beat_time var event_key: StringName = event.call("key") if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time + UPCOMING_TIME_EPSILON: if _time_phase_allows(event): _upcoming_keys[event_key] = true _emit_upcoming(event, time_to_event) if not _triggered_keys.has(event_key) and song_time >= event_time: _triggered_keys[event_key] = true if _time_phase_allows(event): _emit_triggered(event) func pause() -> void: running = false func resume() -> void: running = true func _emit_upcoming(event: Resource, time_to_event: float) -> void: chart_event_upcoming.emit(event, time_to_event) var bus := _event_bus_or_null() if bus != null: bus.emit_signal("chart_event_upcoming", event, time_to_event) func _emit_triggered(event: Resource) -> void: _dispatch_to_target_actor(event) chart_event_triggered.emit(event) var bus := _event_bus_or_null() if bus != null: bus.emit_signal("chart_event_triggered", event) func _dispatch_to_target_actor(event: Resource) -> void: if event == null: return var action_id := StringName(str(event.get("action_id"))) if action_id.is_empty(): return var target_id := StringName(str(event.get("target_id"))) if target_id.is_empty(): return var actors := _actors_container() if actors == null: return var target := actors.get_node_or_null(NodePath(str(target_id))) if target == null: return var driver := target.get_node_or_null("EnemyActionDriver") if driver != null and driver.has_method("handle_chart_event"): driver.call("handle_chart_event", event) func _time_phase_allows(event: Resource) -> bool: if event == null: return false if event.has_method("matches_time_phase"): var manager := _time_phase_manager() if manager == null: return true return bool(event.call("matches_time_phase", StringName(str(manager.get("current_time_phase"))))) return true func _apply_initial_time_phase() -> void: if chart == null: return var initial = chart.get("initial_time_phase") if initial == null: return var manager := _time_phase_manager() if manager != null and manager.has_method("reset_to_initial"): manager.call("reset_to_initial", StringName(str(initial))) func _time_phase_manager() -> Node: # Reverse root scan (matching TimeAnchorSystem): a test-added manager wins # over the autoload, since name collisions get auto-renamed on add_child. 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 _beat_time() -> float: if beat_time_override > 0.0: return beat_time_override var rhythm := _rhythm_manager() if rhythm != null: return float(rhythm.get("beat_time")) return 0.5 func _rhythm_manager() -> Node: if not is_inside_tree(): return null if not rhythm_manager_path.is_empty(): return get_node_or_null(rhythm_manager_path) return get_tree().root.get_node_or_null("RhythmManager") func _actors_container() -> Node: if not is_inside_tree() or actors_container_path.is_empty(): return null return get_node_or_null(actors_container_path) func _event_bus_or_null() -> Node: if not is_inside_tree(): return null return _last_root_child_matching(func(child: Node) -> bool: return child != self and child.has_signal("chart_event_triggered") and child.has_signal("chart_reset") and child.has_signal("judgement_made") ) 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