103 lines
4.5 KiB
GDScript
103 lines
4.5 KiB
GDScript
class_name TimeAnchorChartTool
|
|
extends RefCounted
|
|
|
|
## Chart-side tooling for time anchors (design doc 3.4):
|
|
## generate explicit periodic anchors, validate anchor/mask configuration,
|
|
## and simulate the runtime periodic scheduler for a constant streak.
|
|
|
|
const ChartEventScript := preload("res://resources/chart_event.gd")
|
|
|
|
const VALID_TIME_PHASES: Array[StringName] = [&"past", &"future"]
|
|
const VALID_TIME_PHASE_MASKS: Array[StringName] = [&"", &"both", &"past", &"future"]
|
|
|
|
|
|
static func validate_chart(chart: Resource, beat_time := 0.5, bad_window := 0.2, judgement_scale := 1.0) -> Array[String]:
|
|
var errors: Array[String] = []
|
|
if chart == null:
|
|
errors.append("chart is null")
|
|
return errors
|
|
if bad_window * judgement_scale > beat_time * 0.5:
|
|
errors.append("bad window %.3f exceeds half a beat %.3f; overlapping anchor windows at the 1-beat tier" % [bad_window * judgement_scale, beat_time * 0.5])
|
|
var initial = chart.get("initial_time_phase")
|
|
if initial != null and not VALID_TIME_PHASES.has(StringName(str(initial))):
|
|
errors.append("initial_time_phase must be past/future, got %s" % str(initial))
|
|
var seen_anchor_beats: Dictionary = {}
|
|
for event: Resource in chart.call("all_events"):
|
|
var event_key := str(event.call("key"))
|
|
var mask = event.get("time_phase_mask")
|
|
if mask != null and not VALID_TIME_PHASE_MASKS.has(StringName(str(mask))):
|
|
errors.append("%s: invalid time_phase_mask %s" % [event_key, str(mask)])
|
|
var event_type := StringName(str(event.get("event_type")))
|
|
if event_type == &"time_anchor":
|
|
if int(event.get("subdivision")) != 0:
|
|
errors.append("%s: time_anchor must sit on an integer beat" % event_key)
|
|
var beat := int(event.get("beat_index"))
|
|
if seen_anchor_beats.has(beat):
|
|
errors.append("%s: duplicate time_anchor on beat %d" % [event_key, beat])
|
|
seen_anchor_beats[beat] = true
|
|
elif event_type == &"force_time_phase":
|
|
var payload = event.get("payload")
|
|
var target := StringName(str(payload.get("target_time_phase", ""))) if payload is Dictionary else &""
|
|
if not VALID_TIME_PHASES.has(target):
|
|
errors.append("%s: force_time_phase needs payload.target_time_phase past/future" % event_key)
|
|
return errors
|
|
|
|
|
|
static func validate_time_phase_profile(profile: Resource) -> Array[String]:
|
|
var errors: Array[String] = []
|
|
if profile == null:
|
|
return errors
|
|
for property_name: String in ["past_effects", "future_effects"]:
|
|
var definitions = profile.get(property_name)
|
|
if not definitions is Array:
|
|
continue
|
|
for definition: Variant in definitions:
|
|
if definition is Resource and StringName(str((definition as Resource).get("duration_type"))) != &"infinite":
|
|
errors.append("%s: effect %s must be duration_type infinite (lifecycle owned by TimePhaseAdapter)" % [property_name, str((definition as Resource).get("id"))])
|
|
return errors
|
|
|
|
|
|
static func generate_periodic_time_anchor_events(interval_beats: int, total_beats: int, lead_beats := 2.0) -> Array[Resource]:
|
|
var events: Array[Resource] = []
|
|
var safe_interval := maxi(1, interval_beats)
|
|
var beat := safe_interval
|
|
while beat < total_beats:
|
|
var event: Resource = ChartEventScript.new()
|
|
event.set("event_id", StringName("time_anchor_%d" % beat))
|
|
event.set("beat_index", beat)
|
|
event.set("event_type", &"time_anchor")
|
|
event.set("lead_beats", lead_beats)
|
|
events.append(event)
|
|
beat += safe_interval
|
|
return events
|
|
|
|
|
|
static func simulate_periodic_schedule(frequency_profile: Resource, streak: int, total_beats: int, explicit_beats: Array[int] = []) -> Array[int]:
|
|
var window_beats := 4
|
|
if frequency_profile != null and frequency_profile.get("window_beats") != null:
|
|
window_beats = maxi(1, int(frequency_profile.get("window_beats")))
|
|
var offsets: Array[int] = [window_beats]
|
|
if frequency_profile != null and frequency_profile.has_method("anchor_offsets_for_streak"):
|
|
var profile_offsets = frequency_profile.call("anchor_offsets_for_streak", streak)
|
|
if profile_offsets is Array and not profile_offsets.is_empty():
|
|
offsets = []
|
|
for offset: Variant in profile_offsets:
|
|
offsets.append(clampi(int(offset), 1, window_beats))
|
|
var scheduled: Array[int] = []
|
|
var origin := 0
|
|
while origin < total_beats:
|
|
var explicit_hit := false
|
|
for explicit_beat: int in explicit_beats:
|
|
if explicit_beat > origin and explicit_beat <= origin + window_beats:
|
|
origin = explicit_beat
|
|
explicit_hit = true
|
|
break
|
|
if explicit_hit:
|
|
continue
|
|
for offset: int in offsets:
|
|
var beat := origin + offset
|
|
if beat < total_beats:
|
|
scheduled.append(beat)
|
|
origin += window_beats
|
|
return scheduled
|