Files
2026-07-06 22:59:47 -07:00

319 lines
11 KiB
GDScript

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