class_name ActionController extends Node const ActionResolverScript := preload("res://scenes/combat/action_resolver.gd") const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd") const DEFAULT_PLAYER_ACTION_BINDINGS_PATH := "res://resources/player_action_bindings.tres" 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_cancelled(action: Resource, reason: StringName) signal action_rejected(intent, reason: StringName) enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY, CHARGING } const MAX_CHARGE_LEVEL := 3 @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 @export var beat_anchor_policy: StringName = &"ANCHOR_ACTIVE" @export var anchor_grid := 0.25 @export var hold_threshold_beats := 0.25 @export var charge_level_beats := 1.0 @export var player_action_bindings: Resource @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) @onready var energy_component: Node = get_node_or_null("../EnergyComponent") @onready var effect_container: Node = get_node_or_null("../EffectContainer") var phase := Phase.IDLE var current_action: Resource var current_intent var pending_intent var phase_elapsed := 0.0 var phase_duration := 0.0 var startup_stretch_seconds := 0.0 var _cost_snapshot := 0.0 var _action_snapshot: Dictionary = {} var _charge_hold_intent var _active_charge_entry: Dictionary = {} var _charge_hold_elapsed := 0.0 var _charge_hold_ready := false var _charging_elapsed := 0.0 func submit_intent(intent) -> void: if intent == null: return var judged_intent = _ensure_judged(intent) # new1: 锁定期间的按键完全无效——不进判定流程、不触发动作、不留任何记录。 if judged_intent.is_pressed() and _live_input_locked(judged_intent): action_rejected.emit(judged_intent, &"input_locked") return if _is_direct_charge_release(judged_intent): _cancel_charge_hold() return if _is_charge_secondary_cast(judged_intent): _finish_secondary_charge_gate(judged_intent) return if _is_secondary_charge_cancel_release(judged_intent): _cancel_secondary_charge_gate() return if _is_charge_hold_broken_by_derive(judged_intent): _break_charge_hold_for_derive() if _is_charge_hold_release(judged_intent): _finish_charge_hold_gate(judged_intent) return if _should_begin_direct_charge_gate(judged_intent): _begin_direct_charge_gate(judged_intent) return if judged_intent.is_released(): action_rejected.emit(judged_intent, &"release_not_action") return match _gate_live_input(judged_intent): &"ignored": action_rejected.emit(judged_intent, &"input_ignored") return &"miss": judged_intent = judged_intent.with_judgement(_miss_judgement_override(judged_intent.judgement)) _emit_judgement_feedback(judged_intent) _dispatch_judgement_effect_events(judged_intent) if _judgement_label(judged_intent) == &"miss": _record_miss(judged_intent) action_rejected.emit(judged_intent, &"miss") return var should_track_charge_hold := _should_begin_charge_hold_gate(judged_intent) if phase == Phase.IDLE: _consume_intent(judged_intent) if should_track_charge_hold: _begin_charge_hold_gate_if_started(judged_intent) return if _can_cancel_now(): cancel_current(&"chain") _consume_intent(judged_intent) if should_track_charge_hold: _begin_charge_hold_gate_if_started(judged_intent) return _store_pending_intent(judged_intent) func submit_ai_intent(intent) -> void: if intent == null: return if phase != Phase.IDLE: action_rejected.emit(intent, &"busy") return var action: Resource = ActionResolverScript.get_action(StringName(str(intent.action_id))) if action == null: action_rejected.emit(intent, &"no_executable_action") return if not ActionRuleResolverScript.can_execute(_resolver_context(), action): action_rejected.emit(intent, &"no_executable_action") return if not _commit_action_cost(action, intent): action_rejected.emit(intent, &"insufficient_energy") return current_action = action current_intent = intent _dispatch_action_effect_event(&"on_action_start", action, intent) _enter_phase(Phase.STARTUP) action_started.emit(action, intent) _emit_action_started_fact(action, intent) func _physics_process(delta: float) -> void: _tick_charge_hold(delta) if phase == Phase.CHARGING: return 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) _arm_charge_hold_for_consumed_pending(idle_intent) return phase_elapsed += delta if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now(): var next_intent = pending_intent pending_intent = null cancel_current(&"chain") _consume_intent(next_intent) _arm_charge_hold_for_consumed_pending(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: _finish_action_execution() 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 var should_enter_charge := _charge_hold_ready and _charge_hold_intent != null and next_intent == null if next_intent != null: _clear_charge_hold() _reset_to_idle(not should_enter_charge) _clear_window_after_action(finished_action) action_finished.emit(finished_action) if should_enter_charge and _enter_charge_phase(): return if next_intent != null: _consume_intent(next_intent) _arm_charge_hold_for_consumed_pending(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 _record_intent_symbol(intent) var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context()) if action == null: if not _window_is_showing_pending_clear(): _rollback_intent_symbol(intent) action_rejected.emit(intent, &"no_executable_action") return if not _commit_action_cost(action, intent): _rollback_intent_symbol(intent) action_rejected.emit(intent, &"insufficient_energy") return current_action = action current_intent = intent _dispatch_action_effect_event(&"on_action_start", action, intent) _enter_phase(Phase.STARTUP) action_started.emit(action, intent) _emit_action_started_fact(action, intent) func _start_action_resource(action: Resource, intent) -> void: if action == null: action_rejected.emit(intent, &"no_executable_action") _reset_to_idle() return if not ActionRuleResolverScript.can_execute(_resolver_context(), action): action_rejected.emit(intent, &"no_executable_action") _reset_to_idle() return if not _commit_action_cost(action, intent): action_rejected.emit(intent, &"insufficient_energy") _reset_to_idle() return current_action = action current_intent = intent _dispatch_action_effect_event(&"on_action_start", action, intent) _enter_phase(Phase.STARTUP) action_started.emit(action, intent) _emit_action_started_fact(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"))), effect_container): var reason := _action_executor_failure_reason() combo_window.flush_pending_clear() combo_window.clear(reason) action_rejected.emit(current_intent, reason) _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 _rollback_intent_symbol(intent) -> void: if combo_window != null and combo_window.has_method("rollback_last"): combo_window.call("rollback_last", 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 _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: _emit_intent_replaced_fact(pending_intent, intent) action_rejected.emit(pending_intent, &"replaced") pending_intent = intent func _enter_phase(next_phase: Phase) -> void: phase = next_phase phase_elapsed = 0.0 phase_duration = _phase_duration_seconds(next_phase) _mirror_action_phase() _mirror_defense_state() 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: var minimum_duration := maxf(0.01, _snapshot_float("startup_beats", float(current_action.get("startup_beats"))) * beat_time) if beat_anchor_policy != &"ANCHOR_ACTIVE": startup_stretch_seconds = 0.0 return minimum_duration var anchored_duration := _anchored_startup_duration(minimum_duration, beat_time) startup_stretch_seconds = maxf(0.0, anchored_duration - minimum_duration) return anchored_duration Phase.ACTIVE: return maxf(0.01, _snapshot_float("active_beats", float(current_action.get("active_beats"))) * beat_time) Phase.RECOVERY: return maxf(0.01, _snapshot_float("recovery_beats", float(current_action.get("recovery_beats"))) * beat_time) Phase.CHARGING: return 0.0 return 0.0 func _can_cancel_now() -> bool: if phase != Phase.RECOVERY or current_action == null: return false if bool(current_action.get("clear_window")) and not bool(current_action.get("can_chain")): 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(clear_charge_hold := true) -> void: phase = Phase.IDLE current_action = null current_intent = null phase_elapsed = 0.0 phase_duration = 0.0 startup_stretch_seconds = 0.0 _cost_snapshot = 0.0 _action_snapshot = {} if clear_charge_hold: _clear_charge_hold() _mirror_action_phase() _mirror_defense_state() func cancel_current(reason: StringName) -> void: var cancelled_action := current_action _finish_action_execution() pending_intent = null if reason != &"chain" else pending_intent _reset_to_idle() if reason == &"interrupt" or reason == &"death": if combo_window != null: combo_window.clear(reason) action_cancelled.emit(cancelled_action, reason) _emit_action_cancelled_fact(cancelled_action, reason) 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"): var rating: Dictionary = rhythm.call("judge", intent.timestamp_ms) # 只有实时判定的输入走 new1 的消耗/锁定门控;预置判定(测试/AI)保持权威。 rating["live"] = true return intent.with_judgement(_judgement_with_defaults(rating)) return intent.with_judgement(_judgement_with_defaults({"label": "perfect", "diff": 0.0, "abs_diff": 0.0})) func _live_input_locked(intent) -> bool: if not bool(intent.judgement.get("live", false)): return false var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null return rhythm != null and rhythm.has_method("is_input_locked") and bool(rhythm.call("is_input_locked")) ## new1 门控:返回 &"ok" / &"ignored" / &"miss"(见 RhythmManager.gate_judged_input)。 func _gate_live_input(intent) -> StringName: if not bool(intent.judgement.get("live", false)): return &"ok" var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null if rhythm == null or not rhythm.has_method("gate_judged_input"): return &"ok" return StringName(str(rhythm.call("gate_judged_input", intent.judgement))) func _miss_judgement_override(judgement: Dictionary) -> Dictionary: var rating := judgement.duplicate() rating["label"] = "miss" rating["color"] = _judgement_color(&"miss") return rating 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 nearest_beat := int(rating.get("nearest_beat", 0)) var bus := _event_bus_or_null() if bus == null: return bus.emit_signal("judgement_made", label, diff_ms, nearest_beat) 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("nearest_beat"): rating["nearest_beat"] = 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 _emit_intent_replaced_fact(previous_intent, next_intent) -> void: var bus := _event_bus_or_null() if bus != null and bus.has_signal("intent_replaced"): bus.emit_signal("intent_replaced", previous_intent, next_intent) func _emit_action_started_fact(action: Resource, intent) -> void: var bus := _event_bus_or_null() if bus != null and bus.has_signal("action_started"): bus.emit_signal("action_started", action, intent) func _emit_action_cancelled_fact(action: Resource, reason: StringName) -> void: var bus := _event_bus_or_null() if bus != null and bus.has_signal("action_cancelled"): bus.emit_signal("action_cancelled", action, reason) func _dispatch_judgement_effect_events(intent) -> void: if effect_container == null or not effect_container.has_method("dispatch_event"): return if _judgement_label(intent) == &"perfect": effect_container.call("dispatch_event", &"on_perfect", {"intent": intent}) func _dispatch_action_effect_event(event_name: StringName, action: Resource, intent) -> void: if effect_container == null: effect_container = get_node_or_null("../EffectContainer") if effect_container != null and effect_container.has_method("dispatch_event"): effect_container.call("dispatch_event", event_name, {"action": action, "intent": intent}) func _resolver_context() -> Dictionary: var context := {} if state_machine != null and state_machine.has_method("build_context"): context = state_machine.call("build_context") else: context = { "ground_state": &"Grounded", "action_phase": &"Neutral", "defense_state": &"Vulnerable", "life_state": &"Alive", "tags": [&"Grounded", &"Neutral", &"Vulnerable", &"Alive"], } context.merge({ "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(), }, true) return context func _burst_action_id() -> StringName: if burst_component != null and bool(burst_component.get("burst_ready")): return _binding_action_id("burst_action_id") return &"" func _counter_action_id() -> StringName: return _binding_action_id("counter_action_id") func _counter_ready() -> bool: return false func _blade_chain_action_id() -> StringName: if _blade_chain_active(): return _binding_action_id("blade_chain_action_id") 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 func _commit_action_cost(action: Resource, intent) -> bool: _action_snapshot = _resolve_action_snapshot(action, intent) _cost_snapshot = float(_action_snapshot.get("cost", 0.0)) if energy_component == null: return true return energy_component.spend(_cost_snapshot) func _resolve_cost(action: Resource, judgement: Dictionary = {}) -> float: var combat := get_tree().root.get_node_or_null("CombatManager") if is_inside_tree() else null if combat != null and combat.has_method("resolve_cost"): return float(combat.call("resolve_cost", action, judgement, effect_container)) return float(action.get("base_cost")) func _resolve_action_snapshot(action: Resource, intent) -> Dictionary: var judgement := {} if intent != null: var intent_judgement = intent.get("judgement") if intent_judgement is Dictionary: judgement = intent_judgement var combat := get_tree().root.get_node_or_null("CombatManager") if is_inside_tree() else null if combat != null and combat.has_method("resolve_action_snapshot"): return combat.call("resolve_action_snapshot", action, judgement, effect_container) return { "cost": _resolve_cost(action, judgement), "startup_beats": float(action.get("startup_beats")), "active_beats": float(action.get("active_beats")), "recovery_beats": float(action.get("recovery_beats")), } func _snapshot_float(key: String, fallback: float) -> float: if _action_snapshot.has(key): return float(_action_snapshot[key]) return fallback func _anchored_startup_duration(minimum_duration: float, beat_time: float) -> float: var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null var now := 0.0 if rhythm != null and rhythm.has_method("song_position"): now = float(rhythm.call("song_position")) var grid_seconds := maxf(0.01, anchor_grid * beat_time) var desired_active := now + minimum_duration var anchored_active := ceili(desired_active / grid_seconds) * grid_seconds return maxf(minimum_duration, anchored_active - now) func _mirror_action_phase() -> void: if state_machine == null or not state_machine.has_method("set_action_phase"): return match phase: Phase.STARTUP: state_machine.call("set_action_phase", &"Startup") Phase.ACTIVE: state_machine.call("set_action_phase", &"Active") Phase.RECOVERY: state_machine.call("set_action_phase", &"Recovery") Phase.CHARGING: state_machine.call("set_action_phase", &"Charging") _: state_machine.call("set_action_phase", &"Neutral") func _mirror_defense_state() -> void: if state_machine == null or not state_machine.has_method("set_defense_state"): return if phase != Phase.ACTIVE or current_action == null: state_machine.call("set_defense_state", &"Vulnerable") return var defense_tags: Array = current_action.get("defense_tags") if defense_tags.has(&"invincible"): state_machine.call("set_defense_state", &"Invincible") elif defense_tags.has(&"parry") or defense_tags.has(&"parrying"): state_machine.call("set_defense_state", &"Parrying") elif defense_tags.has(&"super_armor"): state_machine.call("set_defense_state", &"SuperArmor") else: state_machine.call("set_defense_state", &"Vulnerable") func _should_begin_charge_hold_gate(intent) -> bool: if not intent.is_pressed(): return false var entry := _charge_entry_for_symbol(intent.symbol) return StringName(str(entry.get("entry_mode", &""))) == &"after_tap" func _begin_charge_hold_gate_if_started(intent) -> void: if current_intent != intent or current_action == null: return _active_charge_entry = _charge_entry_for_symbol(intent.symbol) _charge_hold_intent = intent _charge_hold_elapsed = 0.0 _charge_hold_ready = false func _arm_charge_hold_for_consumed_pending(intent) -> void: if intent == null or not _should_begin_charge_hold_gate(intent): return if not _symbol_input_still_pressed(intent.symbol): return _begin_charge_hold_gate_if_started(intent) func _symbol_input_still_pressed(symbol: StringName) -> bool: var action_name: StringName match symbol: &"A": action_name = &"combo_a" &"D": action_name = &"combo_d" &"W": action_name = &"combo_w" &"S": action_name = &"combo_s" &"SP": action_name = &"combo_space" _: return false return InputMap.has_action(action_name) and Input.is_action_pressed(action_name) func _is_charge_hold_release(intent) -> bool: return intent.is_released() and _charge_hold_intent != null and intent.symbol == _charge_hold_intent.symbol and StringName(str(_active_charge_entry.get("cast_trigger", &""))) == &"on_release" func _finish_charge_hold_gate(release_intent) -> void: var held_long_enough := phase == Phase.CHARGING var action_id := _charge_cast_action_id(_active_charge_entry, _charge_level()) _clear_charge_hold() if held_long_enough: match _gate_live_input(release_intent): &"ignored": _reset_to_idle() action_rejected.emit(release_intent, &"input_ignored") return &"miss": release_intent = release_intent.with_judgement(_miss_judgement_override(release_intent.judgement)) _emit_judgement_feedback(release_intent) if _judgement_label(release_intent) == &"miss": _reset_to_idle() _record_miss(release_intent) action_rejected.emit(release_intent, &"miss") return _start_action_resource(ActionResolverScript.get_action(action_id), release_intent) func _tick_charge_hold(delta: float) -> void: if _charge_hold_intent == null: return _charge_hold_elapsed += delta if phase == Phase.CHARGING: _charging_elapsed += delta return if _charge_hold_elapsed < hold_threshold_beats * _beat_time(): return _charge_hold_ready = true if phase == Phase.IDLE: _enter_charge_phase() func _enter_charge_phase() -> bool: if _charge_hold_intent == null: return false if _active_charge_entry.is_empty(): _clear_charge_hold() return false current_intent = _charge_hold_intent current_action = null _charging_elapsed = 0.0 _enter_phase(Phase.CHARGING) return true func _clear_charge_hold() -> void: _charge_hold_intent = null _active_charge_entry = {} _charge_hold_elapsed = 0.0 _charge_hold_ready = false _charging_elapsed = 0.0 func _cancel_charge_hold() -> void: _clear_charge_hold() _reset_to_idle() func _is_charge_hold_broken_by_derive(intent) -> bool: if not intent.is_pressed() or _charge_hold_intent == null: return false if StringName(str(_active_charge_entry.get("cast_trigger", &""))) != &"on_release": return false return intent.symbol == &"SP" func _break_charge_hold_for_derive() -> void: # SP while an A/D hold is armed or charging: the four-slot derivation wins # ([A][sp] dash, [A][A][sp] finisher...), so abort the charge and let the # press fall through to the normal resolution path. var was_charging := phase == Phase.CHARGING _clear_charge_hold() if was_charging: _reset_to_idle() func _should_begin_direct_charge_gate(intent) -> bool: if not intent.is_pressed() or phase != Phase.IDLE: return false var entry := _charge_entry_for_symbol(intent.symbol) return StringName(str(entry.get("entry_mode", &""))) == &"direct" func _begin_direct_charge_gate(intent) -> void: _active_charge_entry = _charge_entry_for_symbol(intent.symbol) _charge_hold_intent = intent _charge_hold_elapsed = 0.0 _charge_hold_ready = true _charging_elapsed = 0.0 current_intent = intent current_action = null _enter_phase(Phase.CHARGING) func _is_direct_charge_release(intent) -> bool: return intent.is_released() and _charge_hold_intent != null and intent.symbol == _charge_hold_intent.symbol and StringName(str(_active_charge_entry.get("entry_mode", &""))) == &"direct" func _is_charge_secondary_cast(intent) -> bool: if not intent.is_pressed() or phase != Phase.CHARGING or _active_charge_entry.is_empty(): return false if StringName(str(_active_charge_entry.get("cast_trigger", &""))) != &"on_secondary_key": return false return intent.symbol == StringName(str(_active_charge_entry.get("cast_key", &""))) func _is_secondary_charge_cancel_release(intent) -> bool: if not intent.is_released() or _charge_hold_intent == null: return false if intent.symbol != _charge_hold_intent.symbol: return false return StringName(str(_active_charge_entry.get("cast_trigger", &""))) == &"on_secondary_key" func _cancel_secondary_charge_gate() -> void: _clear_charge_hold() if phase == Phase.CHARGING: _reset_to_idle() func _finish_secondary_charge_gate(cast_intent) -> void: var entry := _active_charge_entry.duplicate(true) var action_id := _charge_cast_action_id(entry, _charge_level()) _clear_charge_hold() match _gate_live_input(cast_intent): &"ignored": _reset_to_idle() action_rejected.emit(cast_intent, &"input_ignored") return &"miss": cast_intent = cast_intent.with_judgement(_miss_judgement_override(cast_intent.judgement)) _emit_judgement_feedback(cast_intent) if _judgement_label(cast_intent) == &"miss": _reset_to_idle() action_rejected.emit(cast_intent, &"miss") return _start_action_resource(ActionResolverScript.get_action(action_id), cast_intent) func _charge_entry_for_symbol(symbol: StringName) -> Dictionary: var bindings := _player_action_bindings() if bindings != null and bindings.has_method("charge_entry_for_symbol"): return bindings.call("charge_entry_for_symbol", symbol) return {} func _charge_cast_action_id(entry: Dictionary, level: int) -> StringName: if entry.is_empty(): return &"" var bindings := _player_action_bindings() if bindings != null and bindings.has_method("cast_action_id"): return bindings.call("cast_action_id", entry, level) var cast_ids: Dictionary = entry.get("cast_action_ids", {}) if cast_ids.has(level): return StringName(str(cast_ids[level])) if cast_ids.has(str(level)): return StringName(str(cast_ids[str(level)])) return &"" func _charge_level() -> int: var level_seconds := maxf(0.001, charge_level_beats * _beat_time()) return clampi(1 + int(floor(_charging_elapsed / level_seconds)), 1, MAX_CHARGE_LEVEL) func charge_state() -> Dictionary: # Single source of truth for the charge gauge: the cast level is decided # here, so presentation reads the same clock instead of keeping its own. var charging := phase == Phase.CHARGING var level_seconds := maxf(0.001, charge_level_beats * _beat_time()) return { "charging": charging, "level": _charge_level() if charging else 0, "max_level": MAX_CHARGE_LEVEL, "progress_units": clampf(_charging_elapsed / level_seconds, 0.0, float(MAX_CHARGE_LEVEL - 1)) if charging else 0.0, } func _binding_action_id(property_name: StringName) -> StringName: var bindings := _player_action_bindings() if bindings == null: return &"" return StringName(str(bindings.get(property_name))) func _player_action_bindings() -> Resource: if player_action_bindings != null: return player_action_bindings if ResourceLoader.exists(DEFAULT_PLAYER_ACTION_BINDINGS_PATH): player_action_bindings = load(DEFAULT_PLAYER_ACTION_BINDINGS_PATH) return player_action_bindings func _action_executor_failure_reason() -> StringName: if action_executor != null and "last_failure_reason" in action_executor: var reason := StringName(str(action_executor.get("last_failure_reason"))) if not reason.is_empty(): return reason return &"execution_failed" func _finish_action_execution() -> void: if action_executor != null and action_executor.has_method("finish_action"): action_executor.call("finish_action")