319 lines
11 KiB
GDScript
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
|