866 lines
29 KiB
GDScript
866 lines
29 KiB
GDScript
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")
|