class_name ActionController extends Node signal action_started(action: Resource, intent) signal action_active_started(action: Resource, intent) signal action_active_finished(action: Resource) signal action_finished(action: Resource) signal action_rejected(intent, reason: StringName) enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY } @export var combo_window_path: NodePath @export var action_resolver_path: NodePath @export var action_executor_path: NodePath @export var state_machine_path: NodePath @export var burst_component_path: NodePath @onready var combo_window: Node = get_node_or_null(combo_window_path) @onready var action_resolver: Node = get_node_or_null(action_resolver_path) @onready var action_executor: Node = get_node_or_null(action_executor_path) @onready var state_machine: Node = get_node_or_null(state_machine_path) @onready var burst_component: Node = get_node_or_null(burst_component_path) var phase := Phase.IDLE var current_action: Resource var current_intent var pending_intent var phase_elapsed := 0.0 var phase_duration := 0.0 func submit_intent(intent) -> void: if intent == null: return var judged_intent = _ensure_judged(intent) if judged_intent.is_released(): action_rejected.emit(judged_intent, &"release_not_action") return _emit_judgement_feedback(judged_intent) if _judgement_label(judged_intent) == &"miss": _interrupt_current_action_for_miss() _record_miss(judged_intent) action_rejected.emit(judged_intent, &"miss") return if _window_is_showing_pending_clear(): _store_pending_intent(judged_intent) return if phase == Phase.IDLE: _consume_intent(judged_intent) return if _can_cancel_now(): _reset_to_idle() _consume_intent(judged_intent) return _store_pending_intent(judged_intent) func _physics_process(delta: float) -> void: if phase == Phase.IDLE: if pending_intent != null and not _window_is_showing_pending_clear(): var idle_intent = pending_intent pending_intent = null _consume_intent(idle_intent) return phase_elapsed += delta if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now(): var interrupted_action := current_action var next_intent = pending_intent pending_intent = null _reset_to_idle() action_finished.emit(interrupted_action) _consume_intent(next_intent) return if phase_elapsed < phase_duration: return var carryover := maxf(0.0, phase_elapsed - phase_duration) match phase: Phase.STARTUP: _enter_phase(Phase.ACTIVE) phase_elapsed = carryover if not _activate_current_action(): return Phase.ACTIVE: action_active_finished.emit(current_action) _enter_phase(Phase.RECOVERY) phase_elapsed = carryover Phase.RECOVERY: var finished_action := current_action var next_intent = pending_intent pending_intent = null _reset_to_idle() _clear_window_after_action(finished_action) action_finished.emit(finished_action) if next_intent != null: _consume_intent(next_intent) func _consume_intent(intent) -> void: _start_action(intent) func _start_action(intent) -> void: if combo_window == null or action_resolver == null: action_rejected.emit(intent, &"missing_component") return if _should_restart_blade_chain(intent): combo_window.clear(&"blade_chain_restart") _record_intent_symbol(intent) var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context()) if action == null: action = _resolve_fallback_action(intent) if action == null: action_rejected.emit(intent, &"unresolved") return current_action = action current_intent = intent _enter_phase(Phase.STARTUP) action_started.emit(action, intent) func _activate_current_action() -> bool: if current_action == null or current_intent == null: _reset_to_idle() return false if action_executor == null: action_rejected.emit(current_intent, &"missing_component") _reset_to_idle() return false if not action_executor.execute(current_action, StringName(str(current_intent.judgement.get("label", "perfect"))), burst_component): combo_window.flush_pending_clear() combo_window.clear(&"action_failed") action_rejected.emit(current_intent, &"execution_failed") _reset_to_idle() return false action_active_started.emit(current_action, current_intent) return true func _record_intent_symbol(intent) -> void: if combo_window.has_pending_clear(): combo_window.flush_pending_clear() combo_window.record(intent.symbol) func _record_miss(_intent) -> void: if combo_window != null: if combo_window.has_pending_clear(): combo_window.flush_pending_clear() combo_window.record(&"Ø") func _interrupt_current_action_for_miss() -> void: pending_intent = null if phase == Phase.IDLE: return var interrupted_action := current_action _reset_to_idle() if interrupted_action != null: action_finished.emit(interrupted_action) func _resolve_fallback_action(intent) -> Resource: if action_resolver == null: return null if not action_resolver.has_method("resolve_text_pattern"): return null return action_resolver.call("resolve_text_pattern", str(intent.symbol), state_machine, _resolver_context()) as Resource func _clear_window_after_action(action: Resource) -> void: if combo_window == null or action == null: return if bool(action.get("clear_window")): combo_window.clear(StringName("skill:%s" % action.get("id"))) func _store_pending_intent(intent) -> void: if pending_intent != null: action_rejected.emit(pending_intent, &"replaced") pending_intent = intent func _should_restart_blade_chain(intent) -> bool: if combo_window == null: return false if intent.symbol != &"S": return false var pattern: String = combo_window.get_contiguous_pattern() return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP" func _enter_phase(next_phase: Phase) -> void: phase = next_phase phase_elapsed = 0.0 phase_duration = _phase_duration_seconds(next_phase) func _phase_duration_seconds(next_phase: Phase) -> float: if current_action == null: return 0.0 var beat_time := _beat_time() match next_phase: Phase.STARTUP: return maxf(0.01, float(current_action.get("startup_beats")) * beat_time) Phase.ACTIVE: return maxf(0.01, float(current_action.get("active_beats")) * beat_time) Phase.RECOVERY: return maxf(0.01, float(current_action.get("recovery_beats")) * beat_time) return 0.0 func _can_cancel_now() -> bool: if phase != Phase.RECOVERY or current_action == null: return false var duration := maxf(0.01, phase_duration) var progress := clampf(phase_elapsed / duration, 0.0, 1.0) return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0) func _reset_to_idle() -> void: phase = Phase.IDLE current_action = null current_intent = null phase_elapsed = 0.0 phase_duration = 0.0 func _window_is_showing_pending_clear() -> bool: return combo_window != null and combo_window.has_pending_clear() func _ensure_judged(intent): if not intent.judgement.is_empty(): return intent.with_judgement(_judgement_with_defaults(intent.judgement)) var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null if rhythm != null and rhythm.has_method("judge"): return intent.with_judgement(_judgement_with_defaults(rhythm.call("judge", intent.timestamp_ms))) return intent.with_judgement(_judgement_with_defaults({"label": "perfect", "diff": 0.0, "abs_diff": 0.0})) func _judgement_label(intent) -> StringName: return StringName(str(intent.judgement.get("label", "miss"))) func _emit_judgement_feedback(intent) -> void: var rating := _judgement_with_defaults(intent.judgement) var action_name: StringName = intent.rhythm_action if not intent.rhythm_action.is_empty() else intent.symbol rating["action"] = action_name var label := StringName(str(rating.get("label", "miss"))) var diff_ms := float(rating.get("diff", INF)) * 1000.0 var bus := _event_bus_or_null() if bus == null: return bus.emit_signal("judgement_made", label, diff_ms) bus.emit_signal("action_judged", action_name, rating) func _judgement_with_defaults(judgement: Dictionary) -> Dictionary: var rating := judgement.duplicate() var label := StringName(str(rating.get("label", "miss"))) rating["label"] = str(label) if not rating.has("diff"): rating["diff"] = 0.0 if not rating.has("abs_diff"): rating["abs_diff"] = absf(float(rating.get("diff", 0.0))) if not rating.has("color"): rating["color"] = _judgement_color(label) return rating func _judgement_color(label: StringName) -> Color: match label: &"perfect": return Color("00f2ff") &"good": return Color("ffffff") &"bad": return Color("ffaa00") return Color("ff0055") func _event_bus_or_null() -> Node: if not is_inside_tree(): return null var root := get_tree().root var bus := root.get_node_or_null("EventBus") if bus == null: bus = load("res://autoload/event_bus.gd").new() bus.name = "EventBus" root.add_child(bus) return bus func _resolver_context() -> Dictionary: var state := &"any" if state_machine != null and state_machine.has_method("get_current_state_name"): state = StringName(str(state_machine.call("get_current_state_name"))) return { "state": state, "charge_release_action_id": _charge_release_action_id(), "burst_action_id": _burst_action_id(), "counter_action_id": _counter_action_id(), "counter_ready": _counter_ready(), "blade_chain_action_id": _blade_chain_action_id(), "blade_chain_active": _blade_chain_active(), } func _charge_release_action_id() -> StringName: return &"" func _burst_action_id() -> StringName: if burst_component != null and bool(burst_component.get("burst_ready")): return &"skill_burst_activate" return &"" func _counter_action_id() -> StringName: return &"skill_s_counter_projectile" func _counter_ready() -> bool: return false func _blade_chain_action_id() -> StringName: if _blade_chain_active(): return &"skill_s_projectile_2" return &"" func _blade_chain_active() -> bool: if current_action == null: return false return bool(current_action.get("can_chain")) func _beat_time() -> float: var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null if rhythm != null: return float(rhythm.get("beat_time")) return 0.5