Initial project sync

This commit is contained in:
wxm
2026-07-06 22:55:14 -07:00
commit 8cca00d0da
1066 changed files with 58585 additions and 0 deletions
+865
View File
@@ -0,0 +1,865 @@
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")