344 lines
10 KiB
GDScript
344 lines
10 KiB
GDScript
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
|