Initial project sync
This commit is contained in:
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://0dw8poe3o53a
|
||||
@@ -0,0 +1,149 @@
|
||||
class_name ActionExecutor
|
||||
extends Node
|
||||
|
||||
const StatResolverScript := preload("res://scripts/resolvers/stat_resolver.gd")
|
||||
|
||||
signal action_executed(action: Resource, judgement: StringName)
|
||||
signal action_failed(action: Resource, reason: StringName)
|
||||
|
||||
@export var energy_component_path: NodePath
|
||||
@export var damage_emitter_path: NodePath
|
||||
|
||||
@onready var _energy_component: Node = get_node_or_null(energy_component_path)
|
||||
@onready var _damage_emitter: Node = get_node_or_null(damage_emitter_path)
|
||||
|
||||
var last_failure_reason: StringName = &""
|
||||
|
||||
func execute(action: Resource, judgement: StringName, _stat_provider: Variant = null) -> bool:
|
||||
last_failure_reason = &""
|
||||
if action == null:
|
||||
_fail(action, &"missing_action")
|
||||
return false
|
||||
if _action_requires_damage_emitter(action) and _damage_emitter == null:
|
||||
_fail(action, &"missing_damage_emitter")
|
||||
return false
|
||||
if _action_requires_damage_emitter(action) and _damage_emitter != null and _damage_emitter.has_method("configure_hit"):
|
||||
_damage_emitter.configure_hit(action, {"label": str(judgement)})
|
||||
elif _damage_emitter != null and _damage_emitter.has_method("clear_hit"):
|
||||
_damage_emitter.clear_hit()
|
||||
if _action_spawns_projectile(action):
|
||||
_request_projectile(action, judgement)
|
||||
var reward := int(round(_resolve_reward(action, {"label": str(judgement)}, _stat_provider)))
|
||||
if reward != 0 and _energy_component != null:
|
||||
_energy_component.change(reward)
|
||||
action_executed.emit(action, judgement)
|
||||
return true
|
||||
|
||||
|
||||
func finish_action() -> void:
|
||||
if _damage_emitter != null and _damage_emitter.has_method("clear_hit"):
|
||||
_damage_emitter.clear_hit()
|
||||
elif _damage_emitter != null:
|
||||
_damage_emitter.monitoring = false
|
||||
|
||||
|
||||
func _fail(action: Resource, reason: StringName) -> void:
|
||||
last_failure_reason = reason
|
||||
action_failed.emit(action, reason)
|
||||
|
||||
|
||||
func _action_requires_damage_emitter(action: Resource) -> bool:
|
||||
if action == null:
|
||||
return false
|
||||
return StringName(str(action.get("hit_type"))) == &"melee"
|
||||
|
||||
|
||||
func _action_spawns_projectile(action: Resource) -> bool:
|
||||
return action != null and StringName(str(action.get("hit_type"))) == &"projectile"
|
||||
|
||||
|
||||
func _resolve_cost(action: Resource, stat_provider: Variant) -> float:
|
||||
var combat := _combat_manager_or_null()
|
||||
if combat != null and combat.has_method("resolve_cost"):
|
||||
return float(combat.call("resolve_cost", action, {}, stat_provider))
|
||||
return float(action.get("base_cost"))
|
||||
|
||||
|
||||
func _resolve_reward(action: Resource, judgement: Dictionary, stat_provider: Variant) -> float:
|
||||
var combat := _combat_manager_or_null()
|
||||
if combat != null and combat.has_method("resolve_reward"):
|
||||
return float(combat.call("resolve_reward", action, judgement, stat_provider))
|
||||
return StatResolverScript.resolve_reward(action, judgement, stat_provider)
|
||||
|
||||
|
||||
func _request_projectile(action: Resource, judgement: StringName = &"perfect") -> void:
|
||||
var owner := get_parent()
|
||||
var context := {
|
||||
"team": _projectile_team(action),
|
||||
"action": action,
|
||||
"judgement": {"label": str(judgement)},
|
||||
"range": float(action.get("range")) if action != null else 0.0,
|
||||
"attacker_interrupts": _owner_interrupt_authority(owner),
|
||||
"source_actor": owner,
|
||||
}
|
||||
# Projectile base damage belongs to the shooter (player/minion/boss), not
|
||||
# the projectile scene default.
|
||||
if _damage_emitter != null:
|
||||
context["base_damage"] = int(_damage_emitter.get("damage"))
|
||||
if owner != null and owner.has_method("projectile_requests_for_action"):
|
||||
var requests = owner.call("projectile_requests_for_action", action)
|
||||
if requests is Array and not requests.is_empty():
|
||||
for request: Variant in requests:
|
||||
if request is Dictionary:
|
||||
_emit_projectile_request(
|
||||
request.get("projectile_scene", null) as PackedScene,
|
||||
request.get("spawn_position", Vector2.ZERO) as Vector2,
|
||||
request.get("direction", Vector2.RIGHT) as Vector2,
|
||||
context
|
||||
)
|
||||
return
|
||||
var spawn_position := Vector2.ZERO
|
||||
var direction := Vector2.RIGHT
|
||||
if owner is Node2D:
|
||||
spawn_position = (owner as Node2D).global_position
|
||||
var heading = owner.get("heading")
|
||||
if heading is Vector2 and heading != Vector2.ZERO:
|
||||
direction = heading
|
||||
if owner != null and owner.has_method("projectile_spawn_position"):
|
||||
spawn_position = owner.call("projectile_spawn_position", action)
|
||||
if owner != null and owner.has_method("projectile_direction"):
|
||||
direction = owner.call("projectile_direction", action)
|
||||
_emit_projectile_request(null, spawn_position, direction, context)
|
||||
|
||||
|
||||
func _owner_interrupt_authority(owner: Node) -> bool:
|
||||
if owner != null and owner.has_method("is_strong_in_current_time_phase"):
|
||||
return bool(owner.call("is_strong_in_current_time_phase"))
|
||||
return true
|
||||
|
||||
|
||||
func _projectile_team(action: Resource) -> StringName:
|
||||
if action != null and action.get("action_tags") is Array:
|
||||
for tag: Variant in action.get("action_tags"):
|
||||
if StringName(str(tag)) == &"enemy":
|
||||
return &"enemy"
|
||||
return &"player"
|
||||
|
||||
|
||||
func _emit_projectile_request(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary = {}) -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("projectile_requested"):
|
||||
bus.emit_signal("projectile_requested", projectile_scene, spawn_position, direction, context)
|
||||
|
||||
|
||||
func _combat_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("CombatManager")
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://deahmqoulk8me
|
||||
@@ -0,0 +1,18 @@
|
||||
class_name AIIntent
|
||||
extends RefCounted
|
||||
|
||||
var action_id: StringName
|
||||
var timestamp_ms := 0.0
|
||||
var judgement: Dictionary = {
|
||||
"label": "perfect",
|
||||
"diff": 0.0,
|
||||
"abs_diff": 0.0,
|
||||
}
|
||||
|
||||
|
||||
static func create(next_action_id: StringName, next_timestamp_ms: float) -> RefCounted:
|
||||
var script: Script = load("res://scenes/components/ai_intent.gd")
|
||||
var intent: RefCounted = script.new()
|
||||
intent.action_id = next_action_id
|
||||
intent.timestamp_ms = next_timestamp_ms
|
||||
return intent
|
||||
@@ -0,0 +1 @@
|
||||
uid://coyppy6e3f8ax
|
||||
@@ -0,0 +1,170 @@
|
||||
class_name AttackBuffComponent
|
||||
extends Node
|
||||
|
||||
## Sole writer of the time-anchor attack buff stacks. The stack storage itself
|
||||
## is the Effect instance inside EffectContainer; damage injection rides the
|
||||
## existing buffs multiplier slot in resolve_damage (no resolver changes).
|
||||
|
||||
const BUFF_EFFECT_PATH := "res://resources/effects/time_phase/effect_time_anchor_attack_buff.tres"
|
||||
const BUFF_EFFECT_ID := &"time_anchor_attack_buff"
|
||||
const MAX_STACKS := 7
|
||||
const HEAL_WINDOW_BEATS := 4
|
||||
const HEAL_BY_JUDGEMENT := {
|
||||
&"perfect": 30,
|
||||
&"good": 20,
|
||||
&"bad": 10,
|
||||
}
|
||||
|
||||
## 受伤规则:进入受伤状态掉一半 Buff 层数(向下取整),
|
||||
## 每掉 1 层换 0.5 秒霸体(SuperArmor:伤害照吃、不打断、无击退)。
|
||||
const HURT_ARMOR_EFFECT_PATH := "res://resources/effects/effect_hurt_super_armor.tres"
|
||||
const HURT_ARMOR_EFFECT_ID := &"hurt_super_armor"
|
||||
const HURT_ARMOR_SECONDS_PER_STACK := 0.5
|
||||
|
||||
@export var effect_container_path := NodePath("../EffectContainer")
|
||||
@export var health_component_path := NodePath("../HealthComponent")
|
||||
@export var state_machine_path := NodePath("../StateMachine")
|
||||
|
||||
@onready var effect_container: Node = get_node_or_null(effect_container_path)
|
||||
@onready var health_component: Node = get_node_or_null(health_component_path)
|
||||
@onready var state_machine: Node = get_node_or_null(state_machine_path)
|
||||
|
||||
var buff_definition: Resource
|
||||
var hurt_armor_definition: Resource
|
||||
var _last_healed_window := -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if buff_definition == null and ResourceLoader.exists(BUFF_EFFECT_PATH):
|
||||
buff_definition = load(BUFF_EFFECT_PATH)
|
||||
if hurt_armor_definition == null and ResourceLoader.exists(HURT_ARMOR_EFFECT_PATH):
|
||||
hurt_armor_definition = load(HURT_ARMOR_EFFECT_PATH)
|
||||
if state_machine != null and state_machine.has_signal("axis_changed") and not state_machine.is_connected("axis_changed", _on_state_axis_changed):
|
||||
state_machine.connect("axis_changed", _on_state_axis_changed)
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("time_anchor_resolved") and not bus.is_connected("time_anchor_resolved", _on_time_anchor_resolved):
|
||||
bus.connect("time_anchor_resolved", _on_time_anchor_resolved)
|
||||
if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
if bus.has_signal("chart_reset") and not bus.is_connected("chart_reset", _on_chart_reset):
|
||||
bus.connect("chart_reset", _on_chart_reset)
|
||||
|
||||
|
||||
func attack_buff_stacks() -> int:
|
||||
if effect_container == null or not effect_container.has_method("effect_stacks"):
|
||||
return 0
|
||||
return int(effect_container.call("effect_stacks", BUFF_EFFECT_ID))
|
||||
|
||||
|
||||
func _on_time_anchor_resolved(anchor: Dictionary, held: bool, judgement: Dictionary) -> void:
|
||||
if not held:
|
||||
return
|
||||
_apply_time_anchor_heal(anchor, judgement)
|
||||
var label := _judgement_label(judgement)
|
||||
if label == &"perfect" or label == &"good":
|
||||
if effect_container != null and buff_definition != null:
|
||||
effect_container.call("add_effect", buff_definition, &"time_anchor")
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, _current: StringName, _reason: StringName) -> void:
|
||||
if effect_container == null or not effect_container.has_method("set_effect_stacks"):
|
||||
return
|
||||
var reduced := maxi(0, attack_buff_stacks() - 2)
|
||||
effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, reduced)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_state_axis_changed(axis: StringName, _previous: StringName, current: StringName) -> void:
|
||||
if axis == &"life_state" and current == &"Hitstun":
|
||||
_drop_stacks_for_hurt()
|
||||
|
||||
|
||||
## 受伤惩罚与补偿一体:掉 floor(层数 / 2) 层,掉几层就换几段霸体时间。
|
||||
## 霸体期间后续攻击不再触发受伤状态,因此不会连锁掉层。
|
||||
func _drop_stacks_for_hurt() -> void:
|
||||
if effect_container == null or not effect_container.has_method("set_effect_stacks"):
|
||||
return
|
||||
var stacks := attack_buff_stacks()
|
||||
var dropped := floori(stacks * 0.5)
|
||||
if dropped <= 0:
|
||||
return
|
||||
effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, stacks - dropped)
|
||||
_apply_hurt_super_armor(dropped)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _apply_hurt_super_armor(dropped_stacks: int) -> void:
|
||||
if hurt_armor_definition == null or not effect_container.has_method("add_effect"):
|
||||
return
|
||||
var armor := hurt_armor_definition.duplicate() as Resource
|
||||
armor.set("duration", HURT_ARMOR_SECONDS_PER_STACK * float(dropped_stacks))
|
||||
effect_container.call("add_effect", armor, &"hurt_buff_drop")
|
||||
|
||||
|
||||
func cap_stacks(max_kept: int) -> void:
|
||||
if effect_container == null or not effect_container.has_method("set_effect_stacks"):
|
||||
return
|
||||
var capped := clampi(attack_buff_stacks(), 0, maxi(0, max_kept))
|
||||
effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, capped)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_chart_reset(_chart_id: StringName) -> void:
|
||||
_last_healed_window = -1
|
||||
if effect_container != null and effect_container.has_method("remove_effect"):
|
||||
effect_container.call("remove_effect", BUFF_EFFECT_ID)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _apply_time_anchor_heal(anchor: Dictionary, judgement: Dictionary) -> void:
|
||||
var beat_value: Variant = anchor.get("beat", null)
|
||||
if beat_value == null:
|
||||
return
|
||||
var label: StringName = _judgement_label(judgement)
|
||||
if not HEAL_BY_JUDGEMENT.has(label):
|
||||
return
|
||||
var window_index: int = int(floor(float(int(beat_value)) / float(HEAL_WINDOW_BEATS)))
|
||||
if window_index == _last_healed_window:
|
||||
return
|
||||
var health: Node = _health_component_or_null()
|
||||
if health == null or not health.has_method("heal"):
|
||||
return
|
||||
var amount: int = int(HEAL_BY_JUDGEMENT[label])
|
||||
var maximum: int = maxi(1, int(health.get("maximum")))
|
||||
var current: int = int(health.get("current"))
|
||||
if current < int(ceil(float(maximum) * 0.5)):
|
||||
amount *= 2
|
||||
health.call("heal", amount)
|
||||
_last_healed_window = window_index
|
||||
|
||||
|
||||
func _judgement_label(judgement: Dictionary) -> StringName:
|
||||
if judgement.has("label"):
|
||||
return StringName(str(judgement["label"]).to_lower())
|
||||
if judgement.has(&"label"):
|
||||
return StringName(str(judgement[&"label"]).to_lower())
|
||||
return &""
|
||||
|
||||
|
||||
func _health_component_or_null() -> Node:
|
||||
if health_component != null and is_instance_valid(health_component):
|
||||
return health_component
|
||||
health_component = get_node_or_null(health_component_path)
|
||||
return health_component
|
||||
|
||||
|
||||
func _broadcast() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("attack_buff_changed"):
|
||||
bus.emit_signal("attack_buff_changed", attack_buff_stacks(), MAX_STACKS)
|
||||
|
||||
|
||||
func _event_buses() -> Array[Node]:
|
||||
var buses: Array[Node] = []
|
||||
if not is_inside_tree():
|
||||
return buses
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("time_anchor_resolved") and child.has_signal("attack_buff_changed"):
|
||||
buses.append(child)
|
||||
return buses
|
||||
@@ -0,0 +1 @@
|
||||
uid://l2m6txa53ylx
|
||||
@@ -0,0 +1,132 @@
|
||||
class_name AttackBuffVisual
|
||||
extends Node2D
|
||||
|
||||
const DEFAULT_FRAME_DIRECTORY := "res://assets/ui/buff_effect"
|
||||
const DEFAULT_MAX_STACKS := 7
|
||||
|
||||
@export var frame_directory := DEFAULT_FRAME_DIRECTORY
|
||||
@export var frames_per_second := 18.0
|
||||
@export var center_offset := Vector2(0.0, -86.0)
|
||||
@export var minimum_scale := 0.85
|
||||
@export var maximum_scale := 1.85
|
||||
@export var minimum_alpha := 0.28
|
||||
@export var maximum_alpha := 0.95
|
||||
|
||||
var _stacks := 0
|
||||
var _max_stacks := DEFAULT_MAX_STACKS
|
||||
var _frame_index := 0
|
||||
var _frame_elapsed := 0.0
|
||||
var _pulse_elapsed := 0.0
|
||||
var _frames: Array[Texture2D] = []
|
||||
var _sprite: Sprite2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = 1
|
||||
_sprite = Sprite2D.new()
|
||||
_sprite.name = "BuffEffect"
|
||||
_sprite.centered = true
|
||||
_sprite.position = center_offset
|
||||
_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
_sprite.visible = false
|
||||
add_child(_sprite)
|
||||
_load_frames()
|
||||
_connect_event_buses()
|
||||
call_deferred("_connect_event_buses")
|
||||
call_deferred("_sync_from_attack_buff_component")
|
||||
set_attack_buff(0, DEFAULT_MAX_STACKS)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _stacks <= 0 or _frames.is_empty():
|
||||
return
|
||||
var intensity := _intensity()
|
||||
_pulse_elapsed = fmod(_pulse_elapsed + delta * lerpf(1.3, 2.8, intensity), TAU)
|
||||
_frame_elapsed += delta
|
||||
var frame_duration := 1.0 / maxf(1.0, frames_per_second * lerpf(0.85, 1.35, intensity))
|
||||
while _frame_elapsed >= frame_duration:
|
||||
_frame_elapsed -= frame_duration
|
||||
_frame_index = (_frame_index + 1) % _frames.size()
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
_apply_effect_state()
|
||||
|
||||
|
||||
func set_attack_buff(stacks: int, max_stacks: int) -> void:
|
||||
_max_stacks = maxi(1, max_stacks)
|
||||
_stacks = clampi(stacks, 0, _max_stacks)
|
||||
set_process(_stacks > 0 and not _frames.is_empty())
|
||||
visible = _stacks > 0 and not _frames.is_empty()
|
||||
if _sprite != null:
|
||||
_sprite.visible = visible
|
||||
if visible and _sprite.texture == null and not _frames.is_empty():
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
_apply_effect_state()
|
||||
|
||||
|
||||
func frame_count() -> int:
|
||||
return _frames.size()
|
||||
|
||||
|
||||
func _on_attack_buff_changed(stacks: int, max_stacks: int) -> void:
|
||||
set_attack_buff(stacks, max_stacks)
|
||||
|
||||
|
||||
func _load_frames() -> void:
|
||||
_frames.clear()
|
||||
var dir := DirAccess.open(frame_directory)
|
||||
if dir == null:
|
||||
return
|
||||
# 导出包内贴图在目录列表里显示为 xxx.png.import / xxx.png.remap;
|
||||
# 还原原名后 load() 才能命中。编辑器里原图与 .import 同时在列,需去重。
|
||||
var files := PackedStringArray()
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not dir.current_is_dir():
|
||||
var resource_name := file_name.trim_suffix(".import").trim_suffix(".remap")
|
||||
if resource_name.begins_with("frame") and resource_name.ends_with(".png") and not files.has(resource_name):
|
||||
files.append(resource_name)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
files.sort()
|
||||
for path_name: String in files:
|
||||
var texture := load("%s/%s" % [frame_directory, path_name]) as Texture2D
|
||||
if texture != null:
|
||||
_frames.append(texture)
|
||||
if not _frames.is_empty() and _sprite != null:
|
||||
_sprite.texture = _frames[0]
|
||||
|
||||
|
||||
func _apply_effect_state() -> void:
|
||||
if _sprite == null:
|
||||
return
|
||||
if _stacks <= 0 or _frames.is_empty():
|
||||
_sprite.visible = false
|
||||
return
|
||||
var intensity := _intensity()
|
||||
var pulse := 0.5 + 0.5 * sin(_pulse_elapsed)
|
||||
_sprite.position = center_offset
|
||||
_sprite.scale = Vector2.ONE * (lerpf(minimum_scale, maximum_scale, intensity) + pulse * lerpf(0.03, 0.18, intensity))
|
||||
_sprite.modulate = Color(1.0, 1.0, 1.0, lerpf(minimum_alpha, maximum_alpha, intensity) * lerpf(0.78, 1.0, pulse))
|
||||
_sprite.visible = true
|
||||
|
||||
|
||||
func _sync_from_attack_buff_component() -> void:
|
||||
var actor := get_parent()
|
||||
if actor != null:
|
||||
actor = actor.get_parent()
|
||||
var component := actor.get_node_or_null("AttackBuffComponent") if actor != null else null
|
||||
if component != null and component.has_method("attack_buff_stacks"):
|
||||
set_attack_buff(int(component.call("attack_buff_stacks")), DEFAULT_MAX_STACKS)
|
||||
|
||||
|
||||
func _connect_event_buses() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("attack_buff_changed") and not child.is_connected("attack_buff_changed", _on_attack_buff_changed):
|
||||
child.connect("attack_buff_changed", _on_attack_buff_changed)
|
||||
|
||||
|
||||
func _intensity() -> float:
|
||||
return clampf(float(_stacks) / float(maxi(1, _max_stacks)), 0.0, 1.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2ejqfwu2b6t5
|
||||
@@ -0,0 +1,83 @@
|
||||
class_name BurstComponent
|
||||
extends Node
|
||||
|
||||
signal burst_changed(burst_ready: bool, active: bool, cooldown: int)
|
||||
|
||||
const BURST_EFFECT_PATH := "res://resources/effects/effect_burst_power.tres"
|
||||
|
||||
@export var active_beats := 16
|
||||
@export var cooldown_beats := 4
|
||||
|
||||
var burst_ready := false
|
||||
var active := false
|
||||
var cooldown := 0
|
||||
var _beats_left := 0
|
||||
@onready var effect_container: Node = get_node_or_null("../EffectContainer")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager")
|
||||
if rhythm != null and not rhythm.is_connected("beat_ticked", _on_beat_ticked):
|
||||
rhythm.connect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func set_ready(value: bool) -> void:
|
||||
if active or cooldown > 0:
|
||||
burst_ready = false
|
||||
else:
|
||||
burst_ready = value
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
|
||||
|
||||
func activate() -> bool:
|
||||
if not burst_ready or active or cooldown > 0:
|
||||
return false
|
||||
burst_ready = false
|
||||
active = true
|
||||
_beats_left = active_beats
|
||||
_apply_burst_effect()
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
return true
|
||||
|
||||
|
||||
func damage_mult(_action: Resource = null) -> float:
|
||||
return _effect_stat_multiplier(&"damage_mult")
|
||||
|
||||
|
||||
func cost_mult(_action: Resource = null) -> float:
|
||||
return _effect_stat_multiplier(&"cost_mult")
|
||||
|
||||
|
||||
func move_mult(_action: Resource = null) -> float:
|
||||
return 1.0
|
||||
|
||||
|
||||
func _on_beat_ticked(_beat_index: int) -> void:
|
||||
if active:
|
||||
_beats_left -= 1
|
||||
if _beats_left <= 0:
|
||||
active = false
|
||||
cooldown = cooldown_beats
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
elif cooldown > 0:
|
||||
cooldown -= 1
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
|
||||
|
||||
func _apply_burst_effect() -> void:
|
||||
if effect_container == null:
|
||||
effect_container = get_node_or_null("../EffectContainer")
|
||||
if effect_container != null and effect_container.has_method("add_effect"):
|
||||
var burst_effect: Resource = load(BURST_EFFECT_PATH) if ResourceLoader.exists(BURST_EFFECT_PATH) else null
|
||||
if burst_effect != null:
|
||||
effect_container.call("add_effect", burst_effect, &"burst")
|
||||
|
||||
|
||||
func _effect_stat_multiplier(stat: StringName) -> float:
|
||||
if not active:
|
||||
return 1.0
|
||||
if effect_container == null:
|
||||
effect_container = get_node_or_null("../EffectContainer")
|
||||
if effect_container != null and effect_container.has_method("stat_multiplier"):
|
||||
return float(effect_container.call("stat_multiplier", stat, null))
|
||||
return 1.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnofufbs2yvx5
|
||||
@@ -0,0 +1,198 @@
|
||||
class_name ChargeComponent
|
||||
extends Node
|
||||
|
||||
signal charge_changed(current: float, maximum: float, charge_ready: bool, active: bool)
|
||||
|
||||
# wave: CHARGING UP OVERLAY loops while holding S (author spec 50fps).
|
||||
# blade_rain: SWORD RAIN PREP OVERLAY plays frames 1-16 (ring forms) and holds;
|
||||
# frames 17-19 (ring launches) belong to the blade_rain_cast release animation.
|
||||
const OVERLAY_PROFILES := {
|
||||
&"wave": {
|
||||
"path": "res://assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png",
|
||||
"hframes": 5, "vframes": 5, "first": 0, "last": 24, "fps": 50.0,
|
||||
"offset": Vector2(-92.0, -140.0), "loop": true,
|
||||
},
|
||||
&"blade_rain": {
|
||||
"path": "res://assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png",
|
||||
"hframes": 10, "vframes": 2, "first": 1, "last": 16, "fps": 25.0,
|
||||
"offset": Vector2(-64.0, -305.0), "loop": false,
|
||||
},
|
||||
}
|
||||
|
||||
# Fallback ramp when no ActionController drives the gauge (standalone use).
|
||||
@export var charge_duration := 1.1
|
||||
@export var animation_player_path: NodePath
|
||||
@export var effect_sprite_path: NodePath
|
||||
|
||||
var value := 0.0
|
||||
var charge_ready := false
|
||||
var active := false
|
||||
var overlay_profile: StringName = &"wave"
|
||||
|
||||
var _effect_time := 0.0
|
||||
var _animation_time := 0.0
|
||||
var _maximum := 1.1
|
||||
var _last_level := 0
|
||||
|
||||
@onready var _animation_player: AnimationPlayer = get_node_or_null(animation_player_path) as AnimationPlayer
|
||||
@onready var _effect_sprite: Sprite2D = get_node_or_null(effect_sprite_path) as Sprite2D
|
||||
@onready var _action_controller: Node = get_node_or_null("../ActionController")
|
||||
|
||||
|
||||
func tick(delta: float, is_charging: bool) -> void:
|
||||
if not is_charging:
|
||||
if active or value > 0.0 or charge_ready:
|
||||
cancel()
|
||||
return
|
||||
if not active:
|
||||
_start()
|
||||
if not active:
|
||||
return
|
||||
_update_charge_animation(delta)
|
||||
var charge_state := _controller_charge_state()
|
||||
if charge_state.is_empty():
|
||||
_maximum = charge_duration
|
||||
value = minf(charge_duration, value + delta)
|
||||
charge_ready = value >= charge_duration
|
||||
else:
|
||||
# The gauge is level-space: 0 units = level 1 fresh, max units = top
|
||||
# level. The cast level itself is owned by ActionController.
|
||||
var max_level := maxi(2, int(charge_state.get("max_level", 3)))
|
||||
var level := int(charge_state.get("level", 1))
|
||||
_maximum = float(max_level - 1)
|
||||
value = clampf(float(charge_state.get("progress_units", 0.0)), 0.0, _maximum)
|
||||
charge_ready = level >= max_level
|
||||
if level > _last_level and _last_level > 0:
|
||||
_pulse_level_up()
|
||||
_last_level = level
|
||||
_update_charge_effect(delta)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func cancel() -> void:
|
||||
active = false
|
||||
value = 0.0
|
||||
charge_ready = false
|
||||
_animation_time = 0.0
|
||||
_last_level = 0
|
||||
_set_effect_visible(false)
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.modulate = Color.WHITE
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return active
|
||||
|
||||
|
||||
func is_ready() -> bool:
|
||||
return charge_ready
|
||||
|
||||
|
||||
func maximum() -> float:
|
||||
return _maximum
|
||||
|
||||
|
||||
func _start() -> void:
|
||||
active = true
|
||||
value = 0.0
|
||||
charge_ready = false
|
||||
_effect_time = 0.0
|
||||
_animation_time = 0.0
|
||||
_last_level = 1
|
||||
_setup_charge_overlay()
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.modulate = Color.WHITE
|
||||
_update_charge_effect(0.0)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func _controller_charge_state() -> Dictionary:
|
||||
if _action_controller == null:
|
||||
_action_controller = get_node_or_null("../ActionController")
|
||||
if _action_controller == null or not _action_controller.has_method("charge_state"):
|
||||
return {}
|
||||
var charge_state = _action_controller.call("charge_state")
|
||||
if charge_state is Dictionary and bool((charge_state as Dictionary).get("charging", false)):
|
||||
return charge_state
|
||||
return {}
|
||||
|
||||
|
||||
func _pulse_level_up() -> void:
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.modulate = Color(1.7, 1.7, 1.7)
|
||||
|
||||
|
||||
func set_overlay_profile(profile: StringName) -> void:
|
||||
if profile == overlay_profile or not OVERLAY_PROFILES.has(profile):
|
||||
return
|
||||
overlay_profile = profile
|
||||
if active:
|
||||
_effect_time = 0.0
|
||||
_setup_charge_overlay()
|
||||
|
||||
|
||||
func _current_overlay() -> Dictionary:
|
||||
return OVERLAY_PROFILES.get(overlay_profile, OVERLAY_PROFILES[&"wave"])
|
||||
|
||||
|
||||
func _setup_charge_overlay() -> void:
|
||||
if _effect_sprite == null:
|
||||
return
|
||||
var overlay := _current_overlay()
|
||||
var texture_path := str(overlay.get("path", ""))
|
||||
var texture: Texture2D = load(texture_path) if ResourceLoader.exists(texture_path) else null
|
||||
if texture == null:
|
||||
return
|
||||
_effect_sprite.texture = texture
|
||||
_effect_sprite.hframes = maxi(1, int(overlay.get("hframes", 1)))
|
||||
_effect_sprite.vframes = maxi(1, int(overlay.get("vframes", 1)))
|
||||
_effect_sprite.offset = overlay.get("offset", Vector2.ZERO)
|
||||
_effect_sprite.frame = clampi(int(overlay.get("first", 0)), 0, _effect_sprite.hframes * _effect_sprite.vframes - 1)
|
||||
|
||||
|
||||
func _update_charge_effect(delta: float) -> void:
|
||||
if _effect_sprite == null:
|
||||
return
|
||||
_effect_sprite.visible = active
|
||||
if not active:
|
||||
return
|
||||
_effect_time += delta
|
||||
if _effect_sprite.modulate != Color.WHITE:
|
||||
_effect_sprite.modulate = _effect_sprite.modulate.lerp(Color.WHITE, minf(1.0, delta * 6.0))
|
||||
var overlay := _current_overlay()
|
||||
var first := int(overlay.get("first", 0))
|
||||
var last := int(overlay.get("last", first))
|
||||
var span := maxi(1, last - first + 1)
|
||||
var step := int(_effect_time * float(overlay.get("fps", 25.0)))
|
||||
var frame_index := first + (step % span if bool(overlay.get("loop", true)) else mini(step, span - 1))
|
||||
_effect_sprite.frame = clampi(frame_index, 0, _effect_sprite.hframes * _effect_sprite.vframes - 1)
|
||||
|
||||
|
||||
func _update_charge_animation(delta: float) -> void:
|
||||
_animation_time += delta
|
||||
var intro_length := _animation_length(&"warrior_charge_intro")
|
||||
if _animation_time < intro_length:
|
||||
_play_charge_animation(&"warrior_charge_intro")
|
||||
else:
|
||||
_play_charge_animation(&"warrior_charge_loop")
|
||||
|
||||
|
||||
func _play_charge_animation(animation_name: StringName) -> void:
|
||||
if _animation_player != null and _animation_player.has_animation(animation_name) and _animation_player.current_animation != animation_name:
|
||||
_animation_player.play(animation_name)
|
||||
|
||||
|
||||
func _animation_length(animation_name: StringName) -> float:
|
||||
if _animation_player != null and _animation_player.has_animation(animation_name):
|
||||
return maxf(0.1, _animation_player.get_animation(animation_name).length)
|
||||
return 0.1
|
||||
|
||||
|
||||
func _set_effect_visible(is_visible: bool) -> void:
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.visible = is_visible
|
||||
|
||||
|
||||
func _emit_changed() -> void:
|
||||
charge_changed.emit(value, _maximum, charge_ready, active)
|
||||
@@ -0,0 +1 @@
|
||||
uid://doo6xoscxjpt2
|
||||
@@ -0,0 +1,122 @@
|
||||
class_name ComboWindow
|
||||
extends Node
|
||||
|
||||
signal combo_updated(inputs: Array[StringName])
|
||||
signal combo_cleared(reason: StringName)
|
||||
|
||||
@export var size := 4
|
||||
@export var clear_display_time := 0.35
|
||||
@export var broadcast_to_bus := true
|
||||
|
||||
var slots: Array[StringName] = []
|
||||
var pending_clear_reason: StringName = &""
|
||||
var _timer: Timer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_timer = Timer.new()
|
||||
_timer.one_shot = true
|
||||
_timer.timeout.connect(flush_pending_clear)
|
||||
add_child(_timer)
|
||||
|
||||
|
||||
func record(input: StringName) -> void:
|
||||
if input.is_empty():
|
||||
return
|
||||
slots.append(input)
|
||||
combo_updated.emit(get_slots())
|
||||
_emit_bus_signal("combo_updated", [get_slots()])
|
||||
if slots.size() >= size:
|
||||
queue_clear(&"full")
|
||||
|
||||
|
||||
func rollback_last(expected_input: StringName = &"") -> bool:
|
||||
if slots.is_empty():
|
||||
return false
|
||||
var last := slots[slots.size() - 1]
|
||||
if not expected_input.is_empty() and last != expected_input:
|
||||
return false
|
||||
slots.pop_back()
|
||||
pending_clear_reason = &""
|
||||
combo_updated.emit(get_slots())
|
||||
_emit_bus_signal("combo_updated", [get_slots()])
|
||||
return true
|
||||
|
||||
|
||||
func get_slots() -> Array[StringName]:
|
||||
return slots.duplicate()
|
||||
|
||||
|
||||
func has_pending_clear() -> bool:
|
||||
return not pending_clear_reason.is_empty()
|
||||
|
||||
|
||||
func consume_pending_clear_reason() -> StringName:
|
||||
var reason := pending_clear_reason
|
||||
pending_clear_reason = &""
|
||||
return reason
|
||||
|
||||
|
||||
func get_pattern() -> String:
|
||||
var pattern := ""
|
||||
for slot: StringName in slots:
|
||||
if slot != &"Ø":
|
||||
pattern += str(slot)
|
||||
return pattern
|
||||
|
||||
|
||||
func get_contiguous_pattern() -> String:
|
||||
var pattern := ""
|
||||
for index: int in range(slots.size() - 1, -1, -1):
|
||||
var slot := slots[index]
|
||||
if slot == &"Ø":
|
||||
break
|
||||
pattern = str(slot) + pattern
|
||||
return pattern
|
||||
|
||||
|
||||
func queue_clear(reason: StringName, delay := -1.0) -> void:
|
||||
pending_clear_reason = reason
|
||||
if _timer == null:
|
||||
return
|
||||
_timer.stop()
|
||||
_timer.wait_time = clear_display_time if delay < 0.0 else delay
|
||||
_timer.start()
|
||||
|
||||
|
||||
func cancel_pending_clear() -> void:
|
||||
pending_clear_reason = &""
|
||||
if _timer != null:
|
||||
_timer.stop()
|
||||
|
||||
|
||||
func flush_pending_clear() -> void:
|
||||
var reason := consume_pending_clear_reason()
|
||||
if reason.is_empty():
|
||||
return
|
||||
if _timer != null:
|
||||
_timer.stop()
|
||||
clear(reason)
|
||||
|
||||
|
||||
func clear(reason: StringName = &"") -> void:
|
||||
slots.clear()
|
||||
pending_clear_reason = &""
|
||||
combo_cleared.emit(reason)
|
||||
_emit_bus_signal("combo_cleared", [reason])
|
||||
combo_updated.emit(get_slots())
|
||||
_emit_bus_signal("combo_updated", [get_slots()])
|
||||
|
||||
|
||||
func _emit_bus_signal(signal_name: StringName, args: Array) -> void:
|
||||
if not broadcast_to_bus or not is_inside_tree():
|
||||
return
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null:
|
||||
bus.emit_signal(signal_name, args[0])
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd66pkuusgqck
|
||||
@@ -0,0 +1,105 @@
|
||||
class_name DamageEmitter
|
||||
extends Area2D
|
||||
|
||||
@export var damage := 10
|
||||
@export var hit_type: StringName = &"normal"
|
||||
@export var base_knockback := Vector2(120.0, 304.056)
|
||||
|
||||
var action_context: Resource
|
||||
var judgement_context: Dictionary = {}
|
||||
## Weak-state minion attacks only deal damage; interrupt authority snapshots at
|
||||
## action start so phase changes mid-swing do not rewrite the hit.
|
||||
var attacker_interrupts := true
|
||||
var _default_position := Vector2.ZERO
|
||||
var _default_shape_size := Vector2.ZERO
|
||||
# With per-frame hit windows (FrameCollisionDriver) the same receiver can
|
||||
# enter the area more than once within one swing; each swing hits once.
|
||||
var _already_hit_ids: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_default_position = position
|
||||
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape is RectangleShape2D:
|
||||
_default_shape_size = (shape_node.shape as RectangleShape2D).size
|
||||
area_entered.connect(_on_area_entered)
|
||||
|
||||
|
||||
func configure_hit(action: Resource, judgement: Dictionary) -> void:
|
||||
action_context = action
|
||||
judgement_context = judgement.duplicate()
|
||||
attacker_interrupts = _owner_interrupt_authority()
|
||||
_already_hit_ids.clear()
|
||||
if action != null:
|
||||
hit_type = StringName(str(action.get("hit_type")))
|
||||
_update_hitbox_geometry(action)
|
||||
monitoring = true
|
||||
|
||||
|
||||
func clear_hit() -> void:
|
||||
monitoring = false
|
||||
action_context = null
|
||||
judgement_context = {}
|
||||
attacker_interrupts = true
|
||||
_already_hit_ids.clear()
|
||||
|
||||
|
||||
func _owner_interrupt_authority() -> bool:
|
||||
var owner := get_parent()
|
||||
if owner != null and owner.has_method("is_strong_in_current_time_phase"):
|
||||
return bool(owner.call("is_strong_in_current_time_phase"))
|
||||
return true
|
||||
|
||||
|
||||
func has_already_hit(receiver: Node) -> bool:
|
||||
return receiver != null and _already_hit_ids.has(receiver.get_instance_id())
|
||||
|
||||
|
||||
func _update_hitbox_geometry(action: Resource) -> void:
|
||||
if action == null:
|
||||
position = _default_position
|
||||
return
|
||||
var range := float(action.get("range"))
|
||||
if range <= 0.0:
|
||||
position = _default_position
|
||||
return
|
||||
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape is RectangleShape2D:
|
||||
var rectangle := shape_node.shape as RectangleShape2D
|
||||
var height := _default_shape_size.y if _default_shape_size != Vector2.ZERO else rectangle.size.y
|
||||
rectangle.size = Vector2(maxf(8.0, range), maxf(8.0, height))
|
||||
var owner := get_parent()
|
||||
var direction := 1.0
|
||||
if owner != null:
|
||||
var heading = owner.get("heading")
|
||||
if heading is Vector2 and absf((heading as Vector2).x) > 0.0:
|
||||
direction = -1.0 if (heading as Vector2).x < 0.0 else 1.0
|
||||
var base_y := _default_position.y
|
||||
position = Vector2(direction * (range * 0.5 + 10.0), base_y)
|
||||
|
||||
|
||||
func _on_area_entered(receiver: Area2D) -> void:
|
||||
if receiver.is_in_group("damage_receivers"):
|
||||
if _already_hit_ids.has(receiver.get_instance_id()):
|
||||
return
|
||||
_already_hit_ids[receiver.get_instance_id()] = true
|
||||
var combat := _combat_manager_or_null()
|
||||
if combat != null and combat.has_method("resolve_hit"):
|
||||
var result: Dictionary = combat.call("resolve_hit", self, receiver)
|
||||
_event_bus().emit_signal("damage_dealt", receiver, int(result.get("damage", 0)), hit_type)
|
||||
|
||||
|
||||
func _event_bus() -> Node:
|
||||
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 _combat_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("CombatManager")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddays88jc3oh3
|
||||
@@ -0,0 +1,30 @@
|
||||
class_name DamageReceiver
|
||||
extends Area2D
|
||||
|
||||
signal damage_received(amount: int, hit_type: StringName, from: Vector2)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("damage_receivers")
|
||||
|
||||
|
||||
func take_damage(amount: int, hit_type: StringName, from: Vector2) -> void:
|
||||
damage_received.emit(amount, hit_type, from)
|
||||
_event_bus().emit_signal("damage_dealt", self, amount, hit_type)
|
||||
|
||||
|
||||
func receive_hit(result: Dictionary) -> void:
|
||||
var amount := int(result.get("damage", 0))
|
||||
var resolved_hit_type := StringName(str(result.get("hit_type", &"normal")))
|
||||
var from := result.get("from", Vector2.ZERO) as Vector2
|
||||
damage_received.emit(amount, resolved_hit_type, from)
|
||||
|
||||
|
||||
func _event_bus() -> Node:
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://be6nwtel2w0kp
|
||||
@@ -0,0 +1,213 @@
|
||||
class_name EffectContainer
|
||||
extends Node
|
||||
|
||||
signal effect_added(effect_id: StringName)
|
||||
signal effect_removed(effect_id: StringName)
|
||||
|
||||
const EffectInstanceScript := preload("res://resources/effects/effect_instance.gd")
|
||||
|
||||
@export var beats_per_measure := 4
|
||||
@export var initial_effects: Array[Resource] = []
|
||||
|
||||
var effects: Array[RefCounted] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.connect("beat_ticked", _on_beat_ticked)
|
||||
for definition: Resource in initial_effects:
|
||||
add_effect(definition, &"loadout")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.disconnect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
tick_time(delta)
|
||||
|
||||
|
||||
func add_effect(definition: Resource, source: Variant = &"-") -> void:
|
||||
if definition == null:
|
||||
return
|
||||
var effect_id := StringName(str(definition.get("id")))
|
||||
var max_stacks := int(definition.get("max_stacks"))
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
effect.stacks = mini(effect.stacks + 1, max(1, max_stacks))
|
||||
effect.remaining = float(definition.get("duration"))
|
||||
effect.source = source
|
||||
effect_added.emit(effect_id)
|
||||
return
|
||||
effects.append(EffectInstanceScript.create(definition, source))
|
||||
effect_added.emit(effect_id)
|
||||
|
||||
|
||||
func set_effect_stacks(effect_id: StringName, stacks: int) -> void:
|
||||
if stacks <= 0:
|
||||
remove_effect(effect_id)
|
||||
return
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
var max_stacks := maxi(1, int(effect.definition.get("max_stacks")))
|
||||
effect.stacks = mini(stacks, max_stacks)
|
||||
return
|
||||
|
||||
|
||||
func effect_stacks(effect_id: StringName) -> int:
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
return int(effect.stacks)
|
||||
return 0
|
||||
|
||||
|
||||
func remove_effect(effect_id: StringName) -> void:
|
||||
for index: int in range(effects.size() - 1, -1, -1):
|
||||
var effect: RefCounted = effects[index]
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
effects.remove_at(index)
|
||||
effect_removed.emit(effect_id)
|
||||
|
||||
|
||||
func tick_time(delta: float) -> void:
|
||||
for effect: RefCounted in effects:
|
||||
effect.tick_time(delta)
|
||||
_prune_expired()
|
||||
|
||||
|
||||
func tick_beats(beats: float) -> void:
|
||||
for effect: RefCounted in effects:
|
||||
effect.tick_beats(beats)
|
||||
_prune_expired()
|
||||
|
||||
|
||||
func active_count() -> int:
|
||||
return effects.size()
|
||||
|
||||
|
||||
func active_effect_ids() -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for effect: RefCounted in effects:
|
||||
ids.append(StringName(str(effect.definition.get("id"))))
|
||||
return ids
|
||||
|
||||
|
||||
func active_effect_summaries() -> Array[Dictionary]:
|
||||
var summaries: Array[Dictionary] = []
|
||||
for effect: RefCounted in effects:
|
||||
summaries.append({
|
||||
"id": StringName(str(effect.definition.get("id"))),
|
||||
"duration_type": StringName(str(effect.definition.get("duration_type"))),
|
||||
"remaining": float(effect.remaining),
|
||||
"stacks": int(effect.stacks),
|
||||
"source": _source_label(effect.source),
|
||||
})
|
||||
return summaries
|
||||
|
||||
|
||||
func dispatch_event(event_name: StringName, context: Dictionary = {}) -> void:
|
||||
var triggered: Array[Resource] = []
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("trigger_event"))) != event_name:
|
||||
continue
|
||||
if effect.has_method("matches_event_context") and not bool(effect.call("matches_event_context", context)):
|
||||
continue
|
||||
var next_effects = effect.definition.get("trigger_effects")
|
||||
if not next_effects is Array:
|
||||
continue
|
||||
for definition: Resource in next_effects:
|
||||
if definition != null:
|
||||
triggered.append(definition)
|
||||
for effect: RefCounted in effects:
|
||||
if effect.has_method("tick_event"):
|
||||
effect.call("tick_event", event_name, context)
|
||||
_prune_expired()
|
||||
for definition: Resource in triggered:
|
||||
add_effect(definition)
|
||||
|
||||
|
||||
func damage_mult(action: Resource = null) -> float:
|
||||
return stat_multiplier(&"damage_mult", action)
|
||||
|
||||
|
||||
func cost_mult(action: Resource = null) -> float:
|
||||
return stat_multiplier(&"cost_mult", action)
|
||||
|
||||
|
||||
func move_mult(action: Resource = null) -> float:
|
||||
return stat_multiplier(&"move_mult", action)
|
||||
|
||||
|
||||
func defense_modifiers() -> Array[Resource]:
|
||||
return _active_modifiers("defense_modifiers")
|
||||
|
||||
|
||||
func action_rule_modifiers() -> Array[Resource]:
|
||||
return _active_modifiers("action_rule_modifiers")
|
||||
|
||||
|
||||
func stat_multiplier(stat: StringName, _action: Resource = null) -> float:
|
||||
var multiplier := 1.0
|
||||
for effect: RefCounted in effects:
|
||||
for modifier: Resource in _stat_modifiers(effect):
|
||||
if StringName(str(modifier.get("stat"))) != stat:
|
||||
continue
|
||||
if str(modifier.get("operation")) == "add":
|
||||
multiplier += float(modifier.get("value")) * maxi(1, int(effect.stacks))
|
||||
else:
|
||||
multiplier *= float(modifier.get("value"))
|
||||
return multiplier
|
||||
|
||||
|
||||
func _stat_modifiers(effect: RefCounted) -> Array:
|
||||
var modifiers = effect.definition.get("stat_modifiers")
|
||||
return modifiers if modifiers is Array else []
|
||||
|
||||
|
||||
func _active_modifiers(property_name: String) -> Array[Resource]:
|
||||
var result: Array[Resource] = []
|
||||
for effect: RefCounted in effects:
|
||||
var modifiers = effect.definition.get(property_name)
|
||||
if not modifiers is Array:
|
||||
continue
|
||||
for modifier: Resource in modifiers:
|
||||
if modifier != null:
|
||||
result.append(modifier)
|
||||
return result
|
||||
|
||||
|
||||
func _source_label(source: Variant) -> StringName:
|
||||
if source == null:
|
||||
return &"-"
|
||||
if source is StringName:
|
||||
return source
|
||||
if source is String:
|
||||
return StringName(source)
|
||||
if source is Node:
|
||||
return StringName((source as Node).name)
|
||||
return StringName(str(source))
|
||||
|
||||
|
||||
func _prune_expired() -> void:
|
||||
for index: int in range(effects.size() - 1, -1, -1):
|
||||
var effect: RefCounted = effects[index]
|
||||
if effect.is_expired():
|
||||
var effect_id := StringName(str(effect.definition.get("id")))
|
||||
effects.remove_at(index)
|
||||
effect_removed.emit(effect_id)
|
||||
|
||||
|
||||
func _on_beat_ticked(beat_index: int) -> void:
|
||||
tick_beats(1.0)
|
||||
if beats_per_measure > 0 and beat_index % beats_per_measure == 0:
|
||||
dispatch_event(&"on_measure_start", {"beat_index": beat_index})
|
||||
dispatch_event(&"on_beat", {"beat_index": beat_index})
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://27i1pv5fu1ae
|
||||
@@ -0,0 +1,54 @@
|
||||
class_name EnergyComponent
|
||||
extends Node
|
||||
|
||||
signal energy_changed(current: int, maximum: int)
|
||||
|
||||
@export var maximum := 100
|
||||
@export var current := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func set_values(next_current: int, next_maximum: int) -> void:
|
||||
maximum = max(1, next_maximum)
|
||||
current = clampi(next_current, 0, maximum)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func set_current(next_current: int) -> void:
|
||||
var clamped := clampi(next_current, 0, maximum)
|
||||
if clamped == current:
|
||||
return
|
||||
current = clamped
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func change(delta: int) -> void:
|
||||
set_current(current + delta)
|
||||
|
||||
|
||||
func spend(cost: float) -> bool:
|
||||
var int_cost := int(ceil(cost))
|
||||
if int_cost <= 0:
|
||||
return true
|
||||
if current < int_cost:
|
||||
return false
|
||||
set_current(current - int_cost)
|
||||
return true
|
||||
|
||||
|
||||
func _emit_changed() -> void:
|
||||
energy_changed.emit(current, maximum)
|
||||
_event_bus().emit_signal("player_energy_changed", float(current), float(maximum))
|
||||
|
||||
|
||||
func _event_bus() -> Node:
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce44s5ldp64p1
|
||||
@@ -0,0 +1,317 @@
|
||||
class_name FrameCollisionDriver
|
||||
extends Node
|
||||
|
||||
## Drives the three collision matrices from the CURRENT animation frame, every
|
||||
## physics tick, for both the player and the boss:
|
||||
## body - CharacterBody2D collision_layer/mask (composed with the
|
||||
## MotionExecutor dash-through ghost bits so nothing goes stale),
|
||||
## hurtbox - DamageReceiver shape follows the opaque pixel bounds of the
|
||||
## frame being displayed (pose-accurate damage-receive matrix),
|
||||
## hitbox - DamageEmitter only monitors on ACTIVE-phase frames whose art
|
||||
## reaches forward past HIT_REACH_GATE of the sheet's own maximum
|
||||
## (best-effort per sheet; action range stays the damage floor).
|
||||
## If no frame of a swing ever passes the gate, the back half of
|
||||
## the ACTIVE phase falls back to the plain range window so a
|
||||
## beat-anchored swing can never whiff purely from art timing.
|
||||
## Facing is derived per tick from heading x visual mirroring, so native-left
|
||||
## sheets (player) and native-right sheets (boss) both measure true forward
|
||||
## reach. Frame bounds are computed once per sheet in a single byte-level pass
|
||||
## and cached statically; per-tick work is table lookups plus rect transforms.
|
||||
|
||||
const ActionControllerScript := preload("res://scenes/components/action_controller.gd")
|
||||
|
||||
const MIN_HURT_SIZE := Vector2(12.0, 16.0)
|
||||
const HIT_REACH_GATE := 0.72
|
||||
const HIT_MIN_FORWARD_PX := 18.0
|
||||
const HIT_HEIGHT_FACTOR := 0.8
|
||||
const HIT_START_OFFSET_PX := 6.0
|
||||
|
||||
@export var sprite_path: NodePath = ^"../Visual/CharacterSprite"
|
||||
@export var damage_emitter_path: NodePath = ^"../DamageEmitter"
|
||||
@export var damage_receiver_path: NodePath = ^"../DamageReceiver"
|
||||
@export var action_controller_path: NodePath = ^"../ActionController"
|
||||
@export var motion_executor_path: NodePath = ^"../MotionExecutor"
|
||||
@export var body_collision_enabled := true
|
||||
@export var damage_receiver_enabled := true
|
||||
@export var damage_emitter_enabled := true
|
||||
@export var hurtbox_enabled := true
|
||||
@export var hitbox_enabled := true
|
||||
## Actor-space pixels trimmed from the opaque bounds (total per axis) so
|
||||
## clothing fringes and glow pixels do not count as body.
|
||||
@export var hurt_margin := Vector2(12.0, 8.0)
|
||||
|
||||
var actor: CharacterBody2D
|
||||
var sprite: Sprite2D
|
||||
var emitter: Area2D
|
||||
var receiver: Area2D
|
||||
var controller: Node
|
||||
var motion_executor: Node
|
||||
|
||||
var _base_body_layer := 0
|
||||
var _base_body_mask := 0
|
||||
var _base_emitter_layer := 0
|
||||
var _base_emitter_mask := 0
|
||||
var _base_receiver_layer := 0
|
||||
var _base_receiver_mask := 0
|
||||
var _swing_action: Resource
|
||||
var _swing_had_hit_window := false
|
||||
|
||||
static var _sheet_bounds_cache: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Run after ActionController/ActionExecutor state changes within the tick,
|
||||
# so this component owns the final matrix values for the physics step.
|
||||
process_physics_priority = 20
|
||||
actor = get_parent() as CharacterBody2D
|
||||
sprite = get_node_or_null(sprite_path) as Sprite2D
|
||||
emitter = get_node_or_null(damage_emitter_path) as Area2D
|
||||
receiver = get_node_or_null(damage_receiver_path) as Area2D
|
||||
controller = get_node_or_null(action_controller_path)
|
||||
motion_executor = get_node_or_null(motion_executor_path)
|
||||
if actor != null:
|
||||
_base_body_layer = actor.collision_layer
|
||||
_base_body_mask = actor.collision_mask
|
||||
if emitter != null:
|
||||
_base_emitter_layer = emitter.collision_layer
|
||||
_base_emitter_mask = emitter.collision_mask
|
||||
_make_shape_unique(emitter)
|
||||
if receiver != null:
|
||||
_base_receiver_layer = receiver.collision_layer
|
||||
_base_receiver_mask = receiver.collision_mask
|
||||
_make_shape_unique(receiver)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
refresh_now()
|
||||
|
||||
|
||||
func refresh_now() -> void:
|
||||
_apply_body_matrix()
|
||||
var frame_rect := _current_frame_actor_rect()
|
||||
if hurtbox_enabled:
|
||||
_apply_hurtbox(frame_rect)
|
||||
if hitbox_enabled:
|
||||
_apply_hitbox(frame_rect)
|
||||
|
||||
|
||||
func _apply_body_matrix() -> void:
|
||||
if actor == null:
|
||||
return
|
||||
var layer := _base_body_layer
|
||||
var mask := _base_body_mask
|
||||
if not body_collision_enabled:
|
||||
layer &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
mask &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
if motion_executor != null and motion_executor.has_method("is_ghosting") and motion_executor.is_ghosting():
|
||||
layer &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
mask &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
actor.collision_layer = layer
|
||||
actor.collision_mask = mask
|
||||
|
||||
|
||||
func _apply_hurtbox(frame_rect: Rect2) -> void:
|
||||
if receiver == null:
|
||||
return
|
||||
receiver.collision_layer = _base_receiver_layer
|
||||
receiver.collision_mask = _base_receiver_mask
|
||||
if not damage_receiver_enabled:
|
||||
receiver.collision_layer = 0
|
||||
receiver.collision_mask = 0
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
return
|
||||
if _actor_is_dead():
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
return
|
||||
receiver.monitoring = true
|
||||
receiver.monitorable = true
|
||||
if frame_rect.size == Vector2.ZERO:
|
||||
return
|
||||
var shape_node := receiver.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node == null or not (shape_node.shape is RectangleShape2D):
|
||||
return
|
||||
receiver.position = Vector2.ZERO
|
||||
var size := frame_rect.size - hurt_margin
|
||||
size = Vector2(maxf(MIN_HURT_SIZE.x, size.x), maxf(MIN_HURT_SIZE.y, size.y))
|
||||
(shape_node.shape as RectangleShape2D).size = size
|
||||
shape_node.position = frame_rect.get_center()
|
||||
|
||||
|
||||
func _apply_hitbox(frame_rect: Rect2) -> void:
|
||||
if emitter == null:
|
||||
return
|
||||
emitter.collision_layer = _base_emitter_layer
|
||||
emitter.collision_mask = _base_emitter_mask
|
||||
if not damage_emitter_enabled or _actor_is_dead():
|
||||
emitter.collision_layer = 0 if not damage_emitter_enabled else _base_emitter_layer
|
||||
emitter.collision_mask = 0 if not damage_emitter_enabled else _base_emitter_mask
|
||||
emitter.monitoring = false
|
||||
return
|
||||
if controller == null or actor == null or sprite == null or sprite.texture == null:
|
||||
# Without a phase source the emitter keeps its own configure/clear
|
||||
# window behaviour (projectiles, tests, detached components).
|
||||
return
|
||||
emitter.position = Vector2.ZERO
|
||||
var action: Resource = emitter.get("action_context") as Resource
|
||||
var in_active: bool = int(controller.get("phase")) == ActionControllerScript.Phase.ACTIVE
|
||||
var is_melee := action != null and StringName(str(action.get("hit_type"))) == &"melee"
|
||||
if not in_active or not is_melee or frame_rect.size == Vector2.ZERO:
|
||||
emitter.monitoring = false
|
||||
_swing_action = null
|
||||
_swing_had_hit_window = false
|
||||
return
|
||||
if action != _swing_action:
|
||||
_swing_action = action
|
||||
_swing_had_hit_window = false
|
||||
var heading_x := 1.0
|
||||
var heading: Variant = actor.get("heading")
|
||||
if heading is Vector2 and absf((heading as Vector2).x) > 0.0:
|
||||
heading_x = signf((heading as Vector2).x)
|
||||
var hframes := maxi(1, sprite.hframes)
|
||||
var vframes := maxi(1, sprite.vframes)
|
||||
var frame := clampi(sprite.frame, 0, hframes * vframes - 1)
|
||||
var anchor_px := _actor_anchor_in_sheet_pixels()
|
||||
var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame)
|
||||
if bounds.size == Vector2.ZERO:
|
||||
emitter.monitoring = false
|
||||
return
|
||||
# Which native side of the sheet is "forward" depends on both the heading
|
||||
# and the Visual mirror: sprite-native +x maps to actor sign(basis_x).
|
||||
var basis_x := (actor.global_transform.affine_inverse() * sprite.global_transform).x.x
|
||||
var forward_is_native_left := (basis_x * heading_x) < 0.0
|
||||
var native_forward := (anchor_px.x - bounds.position.x) if forward_is_native_left else (bounds.end.x - anchor_px.x)
|
||||
var sheet_max := _sheet_max_native_extent(sprite.texture, hframes, vframes, anchor_px.x, forward_is_native_left)
|
||||
var pixel_scale := frame_rect.size.x / maxf(1.0, bounds.size.x)
|
||||
var forward_px := native_forward * pixel_scale
|
||||
var reaches := sheet_max > 0.0 and native_forward >= HIT_REACH_GATE * sheet_max and forward_px >= HIT_MIN_FORWARD_PX
|
||||
if not reaches:
|
||||
if _swing_had_hit_window or not _in_late_active_fallback():
|
||||
emitter.monitoring = false
|
||||
return
|
||||
# Fallback: no frame of this swing has reached yet and ACTIVE is half
|
||||
# over — guarantee the plain range window (old behaviour floor) so a
|
||||
# beat-anchored swing cannot whiff purely from art timing.
|
||||
forward_px = 0.0
|
||||
else:
|
||||
_swing_had_hit_window = true
|
||||
var reach := maxf(forward_px, float(action.get("range")))
|
||||
if reach <= HIT_START_OFFSET_PX:
|
||||
emitter.monitoring = false
|
||||
return
|
||||
var height := clampf(frame_rect.size.y * HIT_HEIGHT_FACTOR, 24.0, 140.0)
|
||||
var shape_node := emitter.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape is RectangleShape2D:
|
||||
var width := maxf(8.0, reach - HIT_START_OFFSET_PX)
|
||||
(shape_node.shape as RectangleShape2D).size = Vector2(width, height)
|
||||
shape_node.position = Vector2(heading_x * (HIT_START_OFFSET_PX + width * 0.5), frame_rect.get_center().y)
|
||||
emitter.monitoring = true
|
||||
|
||||
|
||||
func _actor_is_dead() -> bool:
|
||||
if actor == null:
|
||||
return false
|
||||
var state_machine := actor.get_node_or_null("StateMachine")
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
|
||||
var health := actor.get_node_or_null("HealthComponent")
|
||||
return health != null and int(health.get("current")) <= 0
|
||||
|
||||
|
||||
func _in_late_active_fallback() -> bool:
|
||||
var duration_value: Variant = controller.get("phase_duration")
|
||||
var elapsed_value: Variant = controller.get("phase_elapsed")
|
||||
if not (duration_value is float) or not (elapsed_value is float):
|
||||
return false
|
||||
var duration := duration_value as float
|
||||
return duration > 0.0 and (elapsed_value as float) >= duration * 0.5
|
||||
|
||||
|
||||
func _current_frame_actor_rect() -> Rect2:
|
||||
if sprite == null or sprite.texture == null or actor == null or not sprite.is_inside_tree():
|
||||
return Rect2()
|
||||
var hframes := maxi(1, sprite.hframes)
|
||||
var vframes := maxi(1, sprite.vframes)
|
||||
var frame := clampi(sprite.frame, 0, hframes * vframes - 1)
|
||||
var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame)
|
||||
if bounds.size == Vector2.ZERO:
|
||||
return Rect2()
|
||||
var local_origin := _sprite_pixel_origin() + bounds.position
|
||||
var to_actor := actor.global_transform.affine_inverse() * sprite.global_transform
|
||||
var corner_a := to_actor * local_origin
|
||||
var corner_b := to_actor * (local_origin + bounds.size)
|
||||
return Rect2(
|
||||
Vector2(minf(corner_a.x, corner_b.x), minf(corner_a.y, corner_b.y)),
|
||||
(corner_b - corner_a).abs()
|
||||
)
|
||||
|
||||
|
||||
func _sprite_pixel_origin() -> Vector2:
|
||||
# Sprite-local coordinate of the sheet frame's top-left drawn pixel.
|
||||
if sprite.centered:
|
||||
return sprite.offset - _frame_size() * 0.5
|
||||
return sprite.offset
|
||||
|
||||
|
||||
func _actor_anchor_in_sheet_pixels() -> Vector2:
|
||||
# The actor origin expressed in drawn-pixel coordinates of the frame.
|
||||
var to_sprite := sprite.global_transform.affine_inverse() * actor.global_transform
|
||||
return (to_sprite * Vector2.ZERO) - _sprite_pixel_origin()
|
||||
|
||||
|
||||
func _frame_size() -> Vector2:
|
||||
return Vector2(
|
||||
float(sprite.texture.get_width()) / float(maxi(1, sprite.hframes)),
|
||||
float(sprite.texture.get_height()) / float(maxi(1, sprite.vframes))
|
||||
)
|
||||
|
||||
|
||||
func _make_shape_unique(area: Area2D) -> void:
|
||||
var shape_node := area.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape != null:
|
||||
shape_node.shape = shape_node.shape.duplicate()
|
||||
|
||||
|
||||
static func _frame_opaque_bounds(texture: Texture2D, hframes: int, vframes: int, frame: int) -> Rect2:
|
||||
var all_bounds := _sheet_frame_bounds(texture, hframes, vframes)
|
||||
if frame < 0 or frame >= all_bounds.size():
|
||||
return Rect2()
|
||||
return all_bounds[frame]
|
||||
|
||||
|
||||
static func _sheet_max_native_extent(texture: Texture2D, hframes: int, vframes: int, anchor_x: float, left_side: bool) -> float:
|
||||
var best := 0.0
|
||||
for bounds: Rect2 in _sheet_frame_bounds(texture, hframes, vframes):
|
||||
if bounds.size == Vector2.ZERO:
|
||||
continue
|
||||
var extent := (anchor_x - bounds.position.x) if left_side else (bounds.end.x - anchor_x)
|
||||
best = maxf(best, extent)
|
||||
return best
|
||||
|
||||
|
||||
static func _sheet_frame_bounds(texture: Texture2D, hframes: int, vframes: int) -> Array:
|
||||
# All frames of a sheet are measured once and cached. The per-frame crop +
|
||||
# used-rect run on the C++ side, so even large sheets cost well under a
|
||||
# millisecond and there is no first-swing hitch.
|
||||
var key := "%d#%d#%d" % [texture.get_rid().get_id(), hframes, vframes]
|
||||
if _sheet_bounds_cache.has(key):
|
||||
return _sheet_bounds_cache[key]
|
||||
var result: Array = []
|
||||
var image := texture.get_image()
|
||||
if image != null and not image.is_empty():
|
||||
if image.is_compressed():
|
||||
image.decompress()
|
||||
var frame_width := maxi(1, image.get_width() / hframes)
|
||||
var frame_height := maxi(1, image.get_height() / vframes)
|
||||
for index: int in range(hframes * vframes):
|
||||
var column := index % hframes
|
||||
var row := int(float(index) / float(hframes))
|
||||
var region := image.get_region(Rect2i(column * frame_width, row * frame_height, frame_width, frame_height))
|
||||
var used := region.get_used_rect()
|
||||
if used.size.x <= 0 or used.size.y <= 0:
|
||||
result.append(Rect2())
|
||||
else:
|
||||
result.append(Rect2(used))
|
||||
_sheet_bounds_cache[key] = result
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdl8602o30i87
|
||||
@@ -0,0 +1,87 @@
|
||||
class_name HealthComponent
|
||||
extends Node
|
||||
|
||||
signal health_changed(current: int, maximum: int)
|
||||
signal depleted
|
||||
|
||||
@export var maximum := 100
|
||||
@export var current := 100
|
||||
@export var hitstun_seconds := 0.4
|
||||
|
||||
var _hitstun_time_left := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _hitstun_time_left <= 0.0:
|
||||
return
|
||||
_hitstun_time_left = maxf(0.0, _hitstun_time_left - delta)
|
||||
if _hitstun_time_left <= 0.0 and current > 0:
|
||||
var state_machine := _state_machine_or_null()
|
||||
if state_machine != null and state_machine.has_method("set_life_state"):
|
||||
state_machine.call("set_life_state", &"Alive")
|
||||
|
||||
|
||||
func set_values(next_current: int, next_maximum: int) -> void:
|
||||
maximum = max(1, next_maximum)
|
||||
current = clampi(next_current, 0, maximum)
|
||||
if current > 0:
|
||||
_hitstun_time_left = 0.0
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func apply_damage(amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
current = clampi(current - amount, 0, maximum)
|
||||
_emit_changed()
|
||||
if current == 0:
|
||||
depleted.emit()
|
||||
|
||||
|
||||
func receive_hit(result: Dictionary) -> void:
|
||||
var amount := int(result.get("damage", result.get("amount", 0)))
|
||||
apply_damage(amount)
|
||||
var state_machine := _state_machine_or_null()
|
||||
if state_machine == null or not state_machine.has_method("set_life_state"):
|
||||
return
|
||||
if current <= 0:
|
||||
_hitstun_time_left = 0.0
|
||||
state_machine.call("set_life_state", &"Dead")
|
||||
elif amount > 0 and bool(result.get("interrupts", true)):
|
||||
_hitstun_time_left = maxf(0.0, hitstun_seconds)
|
||||
state_machine.call("set_life_state", &"Hitstun")
|
||||
|
||||
|
||||
func heal(amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
current = clampi(current + amount, 0, maximum)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func _emit_changed() -> void:
|
||||
health_changed.emit(current, maximum)
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and _is_player_health_component():
|
||||
bus.emit_signal("player_health_changed", current, maximum)
|
||||
|
||||
|
||||
func _is_player_health_component() -> bool:
|
||||
var actor := get_parent()
|
||||
return actor != null and actor.name == "Player"
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
|
||||
|
||||
func _state_machine_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_node_or_null("../StateMachine")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk0nbsdn77rb4
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name InputComponent
|
||||
extends Node
|
||||
|
||||
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
|
||||
|
||||
signal intent_created(intent)
|
||||
signal combo_pressed(symbol: StringName, rhythm_action: StringName)
|
||||
signal combo_released(symbol: StringName)
|
||||
|
||||
const COMBO_ACTIONS: Dictionary = {
|
||||
&"combo_w": [&"W", &"w"],
|
||||
&"combo_a": [&"A", &"a"],
|
||||
&"combo_d": [&"D", &"d"],
|
||||
&"combo_s": [&"S", &"s"],
|
||||
&"combo_space": [&"SP", &"space"],
|
||||
}
|
||||
|
||||
const COMBO_ACTION_ORDER: Array[StringName] = [
|
||||
&"combo_w",
|
||||
&"combo_a",
|
||||
&"combo_d",
|
||||
&"combo_s",
|
||||
&"combo_space",
|
||||
]
|
||||
|
||||
|
||||
func handle_input_event(event: InputEvent) -> bool:
|
||||
var key_event := event as InputEventKey
|
||||
if key_event != null and key_event.echo:
|
||||
return false
|
||||
for action_name: StringName in COMBO_ACTION_ORDER:
|
||||
if event.is_action_pressed(action_name, false, true):
|
||||
var data: Array = COMBO_ACTIONS[action_name]
|
||||
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec()))
|
||||
intent_created.emit(intent)
|
||||
combo_pressed.emit(data[0], data[1])
|
||||
return true
|
||||
if event.is_action_released(action_name, true):
|
||||
var data: Array = COMBO_ACTIONS[action_name]
|
||||
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"released", float(Time.get_ticks_msec()))
|
||||
intent_created.emit(intent)
|
||||
combo_released.emit(data[0])
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxwomhlyicdep
|
||||
@@ -0,0 +1,32 @@
|
||||
class_name InputIntent
|
||||
extends RefCounted
|
||||
|
||||
var symbol: StringName
|
||||
var rhythm_action: StringName
|
||||
var event_type: StringName
|
||||
var timestamp_ms := 0.0
|
||||
var judgement: Dictionary = {}
|
||||
|
||||
|
||||
static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> RefCounted:
|
||||
var script: Script = load("res://scenes/components/input_intent.gd")
|
||||
var intent: RefCounted = script.new()
|
||||
intent.symbol = next_symbol
|
||||
intent.rhythm_action = next_rhythm_action
|
||||
intent.event_type = next_event_type
|
||||
intent.timestamp_ms = next_timestamp_ms
|
||||
return intent
|
||||
|
||||
|
||||
func is_pressed() -> bool:
|
||||
return event_type == &"pressed"
|
||||
|
||||
|
||||
func is_released() -> bool:
|
||||
return event_type == &"released"
|
||||
|
||||
|
||||
func with_judgement(next_judgement: Dictionary) -> RefCounted:
|
||||
var copy: RefCounted = load("res://scenes/components/input_intent.gd").create(symbol, rhythm_action, event_type, timestamp_ms)
|
||||
copy.judgement = next_judgement.duplicate()
|
||||
return copy
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgrjwtje4wfni
|
||||
@@ -0,0 +1,156 @@
|
||||
class_name MotionExecutor
|
||||
extends Node
|
||||
|
||||
signal motion_started(action: Resource)
|
||||
signal motion_finished(action: Resource)
|
||||
|
||||
# player_body (layer 6) | enemy_body (layer 7): the pair that body-blocks.
|
||||
const BODY_GHOST_BITS := (1 << 5) | (1 << 6)
|
||||
# 幽灵态收尾嵌入时的每帧排斥步长(240px/s,快于小怪追击 120px/s,保证能拉开)。
|
||||
const OVERLAP_NUDGE_PER_FRAME := 4.0
|
||||
|
||||
@onready var actor: CharacterBody2D = get_parent() as CharacterBody2D
|
||||
|
||||
var current_action: Resource
|
||||
var velocity := Vector2.ZERO
|
||||
var duration := 0.0
|
||||
var elapsed := 0.0
|
||||
var active := false
|
||||
var _saved_collision_mask := -1
|
||||
var _saved_collision_layer := -1
|
||||
var _restore_pending := false
|
||||
var _ghost_exit_sign := 1.0
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if _restore_pending:
|
||||
_try_restore_collision()
|
||||
|
||||
|
||||
func execute(action: Resource, direction: Vector2, beat_time: float, speed := 220.0) -> void:
|
||||
if active:
|
||||
cancel()
|
||||
current_action = action
|
||||
duration = maxf(0.01, float(action.get("action_beats")) * maxf(0.01, beat_time))
|
||||
elapsed = 0.0
|
||||
active = true
|
||||
# Vertical motion is owned by the fake-height system (height/height_speed);
|
||||
# feeding move_mult_y into the body velocity leaves the CharacterBody drifting off the ground plane.
|
||||
var move_x := float(action.get("move_mult_x"))
|
||||
var horizontal := direction.x if direction.x != 0.0 else signf(move_x)
|
||||
velocity = Vector2(signf(horizontal) * speed, 0.0) if horizontal != 0.0 else Vector2.ZERO
|
||||
if _has_action_tag(action, &"dash_through"):
|
||||
_begin_dash_through()
|
||||
motion_started.emit(action)
|
||||
|
||||
|
||||
func tick(delta: float) -> Vector2:
|
||||
if not active:
|
||||
return Vector2.ZERO
|
||||
elapsed += delta
|
||||
if elapsed >= duration:
|
||||
active = false
|
||||
velocity = Vector2.ZERO
|
||||
_request_restore_collision()
|
||||
motion_finished.emit(current_action)
|
||||
return velocity
|
||||
|
||||
|
||||
func cancel() -> void:
|
||||
active = false
|
||||
velocity = Vector2.ZERO
|
||||
_request_restore_collision()
|
||||
current_action = null
|
||||
|
||||
|
||||
func is_ghosting() -> bool:
|
||||
return _saved_collision_mask != -1
|
||||
|
||||
|
||||
func _has_action_tag(action: Resource, tag: StringName) -> bool:
|
||||
if action == null:
|
||||
return false
|
||||
var tags: Array = action.get("action_tags")
|
||||
for item: Variant in tags:
|
||||
if StringName(str(item)) == tag:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _begin_dash_through() -> void:
|
||||
if actor == null:
|
||||
actor = get_parent() as CharacterBody2D
|
||||
if actor == null:
|
||||
return
|
||||
# A new dash always cancels any deferred restore from the previous one, so
|
||||
# the ghost state cannot snap back mid-dash.
|
||||
_restore_pending = false
|
||||
# 记录冲刺方向:收尾嵌入时沿该方向推出(保留"从远侧穿出"的手感)。
|
||||
if velocity.x != 0.0:
|
||||
_ghost_exit_sign = signf(velocity.x)
|
||||
if _saved_collision_mask != -1:
|
||||
return
|
||||
# Ghost both directions: stop colliding with enemy/player bodies AND stop
|
||||
# being collidable by them, so neither side's move_and_slide recovery can
|
||||
# shove anyone while the dash overlaps a body.
|
||||
_saved_collision_mask = actor.collision_mask
|
||||
_saved_collision_layer = actor.collision_layer
|
||||
actor.collision_mask = actor.collision_mask & ~BODY_GHOST_BITS
|
||||
actor.collision_layer = actor.collision_layer & ~BODY_GHOST_BITS
|
||||
|
||||
|
||||
func _request_restore_collision() -> void:
|
||||
if _saved_collision_mask == -1:
|
||||
return
|
||||
# Never snap collision back while still inside another body: restoring
|
||||
# mid-overlap lets depenetration shove the actor back out the entry side,
|
||||
# which reads as "the dash did not pierce". Retry every physics frame.
|
||||
_restore_pending = true
|
||||
_try_restore_collision()
|
||||
|
||||
|
||||
func _try_restore_collision() -> void:
|
||||
if actor == null or _saved_collision_mask == -1:
|
||||
_restore_pending = false
|
||||
return
|
||||
if Engine.is_in_physics_frame() and _overlaps_ghosted_bodies():
|
||||
# 2026-07-06 策划:除冲刺穿人过程外不得与敌人重叠。嵌入时不再无限期
|
||||
# 干等分离,而是沿冲刺方向温和推出,推清后下一帧恢复碰撞。
|
||||
_nudge_out_of_overlap()
|
||||
return
|
||||
actor.collision_mask = _saved_collision_mask
|
||||
actor.collision_layer = _saved_collision_layer
|
||||
_saved_collision_mask = -1
|
||||
_saved_collision_layer = -1
|
||||
_restore_pending = false
|
||||
|
||||
|
||||
## 幽灵态结束但仍嵌在敌人体内:每物理帧沿冲刺方向推 4px(幽灵掩码只剩
|
||||
## world 位,绝不会被推进地形);顶到边界墙推不动时反向从入口侧退出。
|
||||
func _nudge_out_of_overlap() -> void:
|
||||
var motion := Vector2(_ghost_exit_sign * OVERLAP_NUDGE_PER_FRAME, 0.0)
|
||||
var collision := actor.move_and_collide(motion)
|
||||
if collision != null and collision.get_travel().length() < OVERLAP_NUDGE_PER_FRAME * 0.5:
|
||||
_ghost_exit_sign = -_ghost_exit_sign
|
||||
|
||||
|
||||
func _overlaps_ghosted_bodies() -> bool:
|
||||
if actor == null or not actor.is_inside_tree():
|
||||
return false
|
||||
var query_mask := _saved_collision_mask & BODY_GHOST_BITS
|
||||
if query_mask == 0:
|
||||
return false
|
||||
var shape_node := actor.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node == null or shape_node.shape == null:
|
||||
return false
|
||||
var params := PhysicsShapeQueryParameters2D.new()
|
||||
params.shape = shape_node.shape
|
||||
params.transform = shape_node.global_transform
|
||||
params.collision_mask = query_mask
|
||||
params.collide_with_bodies = true
|
||||
params.collide_with_areas = false
|
||||
params.exclude = [actor.get_rid()]
|
||||
var space := actor.get_world_2d().direct_space_state
|
||||
if space == null:
|
||||
return false
|
||||
return not space.intersect_shape(params, 1).is_empty()
|
||||
@@ -0,0 +1 @@
|
||||
uid://21xrm1ubabdn
|
||||
@@ -0,0 +1,237 @@
|
||||
class_name MovementMotor
|
||||
extends Node
|
||||
|
||||
const GRAVITY := 1200.0
|
||||
const KNOCKBACK_FRICTION := 480.0
|
||||
const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd")
|
||||
|
||||
@onready var actor: CharacterBody2D = get_parent() as CharacterBody2D
|
||||
@onready var state_machine: Node = get_node_or_null("../StateMachine")
|
||||
@onready var effect_container: Node = get_node_or_null("../EffectContainer")
|
||||
|
||||
# 击退的水平残速还没衰减完;期间 set_heading 不得改写朝向(受击不转身)。
|
||||
var _knockback_stray_active := false
|
||||
|
||||
|
||||
func handle_input() -> void:
|
||||
if actor == null or not can_move_freely():
|
||||
return
|
||||
# 自主移动接管 velocity.x,击退残速语义随之结束。
|
||||
_knockback_stray_active = false
|
||||
var direction := get_horizontal_axis()
|
||||
actor.velocity.x = direction * float(actor.get("speed")) * _move_speed_multiplier()
|
||||
if direction < 0.0:
|
||||
actor.set("heading", Vector2.LEFT)
|
||||
elif direction > 0.0:
|
||||
actor.set("heading", Vector2.RIGHT)
|
||||
|
||||
|
||||
func handle_air_time(delta: float) -> void:
|
||||
if actor == null:
|
||||
return
|
||||
_decay_stray_velocity(delta)
|
||||
if _presentation_state() != Character.PRESENTATION_JUMP and _ground_state() != &"Airborne":
|
||||
return
|
||||
var height := float(actor.get("height"))
|
||||
var height_speed := float(actor.get("height_speed"))
|
||||
height += height_speed * delta
|
||||
if height <= 0.0 and height_speed < 0.0:
|
||||
actor.set("height", 0.0)
|
||||
actor.set("height_speed", 0.0)
|
||||
actor.set("state", Character.PRESENTATION_LAND)
|
||||
actor.velocity.y = 0.0
|
||||
_set_ground_state(&"Grounded")
|
||||
_reset_air_actions()
|
||||
_dispatch_effect_event(&"on_landed")
|
||||
else:
|
||||
actor.set("height", height)
|
||||
actor.set("height_speed", height_speed - GRAVITY * delta)
|
||||
_set_ground_state(&"Airborne")
|
||||
|
||||
|
||||
func _decay_stray_velocity(delta: float) -> void:
|
||||
var state := _presentation_state()
|
||||
if state == Character.PRESENTATION_ATTACK or state == Character.PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
actor.velocity.x = move_toward(actor.velocity.x, 0.0, KNOCKBACK_FRICTION * delta)
|
||||
actor.velocity.y = 0.0
|
||||
if actor.velocity.x == 0.0:
|
||||
_knockback_stray_active = false
|
||||
|
||||
|
||||
func handle_movement() -> void:
|
||||
if actor == null:
|
||||
return
|
||||
var state := _presentation_state()
|
||||
if state == Character.PRESENTATION_JUMP or state == Character.PRESENTATION_ATTACK or state == Character.PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
if _ground_state() == &"Airborne" or float(actor.get("height")) > 0.0 or not is_zero_approx(float(actor.get("height_speed"))):
|
||||
_set_ground_state(&"Airborne")
|
||||
return
|
||||
if state == Character.PRESENTATION_LAND:
|
||||
actor.set("state", Character.PRESENTATION_IDLE)
|
||||
elif absf(actor.velocity.x) > 0.0:
|
||||
actor.set("state", Character.PRESENTATION_WALK)
|
||||
else:
|
||||
actor.set("state", Character.PRESENTATION_IDLE)
|
||||
_set_ground_state(&"Grounded")
|
||||
|
||||
|
||||
func set_heading() -> void:
|
||||
if actor == null:
|
||||
return
|
||||
# 受击不转身(2026-07-05 定案):Hitstun/击退残速期间保持受击前朝向。
|
||||
if _knockback_stray_active or _life_state() != &"Alive":
|
||||
return
|
||||
if actor.velocity.x > 0.0:
|
||||
actor.set("heading", Vector2.RIGHT)
|
||||
elif actor.velocity.x < 0.0:
|
||||
actor.set("heading", Vector2.LEFT)
|
||||
|
||||
|
||||
func start_jump() -> bool:
|
||||
if actor == null or not can_jump():
|
||||
return false
|
||||
actor.set("state", Character.PRESENTATION_JUMP)
|
||||
actor.set("height_speed", float(actor.get("jump_intensity")))
|
||||
_set_ground_state(&"Airborne")
|
||||
return true
|
||||
|
||||
|
||||
func can_jump() -> bool:
|
||||
if actor == null:
|
||||
return false
|
||||
var state := _presentation_state()
|
||||
return state == Character.PRESENTATION_IDLE or state == Character.PRESENTATION_WALK
|
||||
|
||||
|
||||
func can_move_freely() -> bool:
|
||||
if not _free_movement_inputs_bound():
|
||||
return false
|
||||
if _movement_blocked_by_effect():
|
||||
return false
|
||||
var context := _movement_context()
|
||||
if bool(ActionRuleResolverScript.can_move_freely(context)):
|
||||
return true
|
||||
return _charging_movement_allowed_by_effect()
|
||||
|
||||
|
||||
func get_horizontal_axis() -> float:
|
||||
var axis := 0.0
|
||||
if Input.is_action_pressed(&"move_left"):
|
||||
axis -= 1.0
|
||||
if Input.is_action_pressed(&"move_right"):
|
||||
axis += 1.0
|
||||
return axis
|
||||
|
||||
|
||||
func apply_knockback(knockback: Vector2) -> void:
|
||||
if actor == null:
|
||||
return
|
||||
actor.velocity.x = knockback.x
|
||||
_knockback_stray_active = knockback.x != 0.0
|
||||
if knockback.y > 0.0:
|
||||
actor.set("height", maxf(float(actor.get("height")), 0.1))
|
||||
actor.set("height_speed", knockback.y)
|
||||
_set_ground_state(&"Airborne")
|
||||
elif knockback.y < 0.0:
|
||||
actor.set("height_speed", knockback.y)
|
||||
else:
|
||||
actor.velocity.y = 0.0
|
||||
|
||||
|
||||
## 供覆写了 handle_movement 的宿主(Boss/小怪 AI)查询:击退残速未衰减完时
|
||||
## 不得清零 velocity.x,否则击退位移活不过一帧。
|
||||
func has_knockback_stray() -> bool:
|
||||
return _knockback_stray_active
|
||||
|
||||
|
||||
## 强制结束击退滑行语义(Boss 受击链强制撤退等"压倒击退"的路径专用)。
|
||||
func clear_knockback_stray() -> void:
|
||||
_knockback_stray_active = false
|
||||
|
||||
|
||||
func _ground_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("ground_state", &"Grounded")))
|
||||
return &"Grounded"
|
||||
|
||||
|
||||
func _movement_context() -> Dictionary:
|
||||
var context := {
|
||||
"life_state": &"Alive",
|
||||
"action_phase": &"Neutral",
|
||||
"ground_state": &"Grounded",
|
||||
}
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
context = state_machine.call("build_context")
|
||||
context["effect_container"] = effect_container
|
||||
return context
|
||||
|
||||
|
||||
func _presentation_state() -> StringName:
|
||||
if actor == null:
|
||||
return Character.PRESENTATION_IDLE
|
||||
return StringName(str(actor.get("state")))
|
||||
|
||||
|
||||
func _action_phase() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("action_phase", &"Neutral")))
|
||||
return &"Neutral"
|
||||
|
||||
|
||||
func _life_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
|
||||
return &"Alive"
|
||||
|
||||
|
||||
func _charging_movement_allowed_by_effect() -> bool:
|
||||
if _action_phase() != &"Charging":
|
||||
return false
|
||||
if effect_container == null or not effect_container.has_method("action_rule_modifiers"):
|
||||
return false
|
||||
for modifier: Resource in effect_container.call("action_rule_modifiers"):
|
||||
if bool(modifier.get("allow_movement_while_charging")):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _movement_blocked_by_effect() -> bool:
|
||||
if effect_container == null or not effect_container.has_method("action_rule_modifiers"):
|
||||
return false
|
||||
for modifier: Resource in effect_container.call("action_rule_modifiers"):
|
||||
if bool(modifier.get("block_movement")):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _move_speed_multiplier() -> float:
|
||||
if effect_container == null or not effect_container.has_method("stat_multiplier"):
|
||||
return 1.0
|
||||
return float(effect_container.call("stat_multiplier", &"move_speed", null))
|
||||
|
||||
|
||||
func _free_movement_inputs_bound() -> bool:
|
||||
for action_name: StringName in [&"move_left", &"move_right"]:
|
||||
if InputMap.has_action(action_name) and not InputMap.action_get_events(action_name).is_empty():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _set_ground_state(next_state: StringName) -> void:
|
||||
if state_machine != null and state_machine.has_method("set_ground_state"):
|
||||
state_machine.call("set_ground_state", next_state)
|
||||
|
||||
|
||||
func _reset_air_actions() -> void:
|
||||
if state_machine != null and "air_action_count" in state_machine:
|
||||
state_machine.set("air_action_count", 0)
|
||||
|
||||
|
||||
func _dispatch_effect_event(event_name: StringName) -> 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, {"actor": actor})
|
||||
@@ -0,0 +1 @@
|
||||
uid://cc8bjx2eu05cf
|
||||
@@ -0,0 +1,96 @@
|
||||
class_name OverheadChargeSegments
|
||||
extends Node2D
|
||||
|
||||
@export var charge_component_path: NodePath = ^"../ChargeComponent"
|
||||
@export var segment_count := 3
|
||||
@export var segment_size := Vector2(42.0, 12.0)
|
||||
@export var segment_gap := 5.0
|
||||
@export var border_width := 2.0
|
||||
@export var background_color := Color(0.04, 0.055, 0.07, 0.86)
|
||||
@export var fill_color := Color(0.25, 0.88, 1.0, 0.96)
|
||||
@export var ready_fill_color := Color(1.0, 0.72, 0.18, 1.0)
|
||||
@export var border_color := Color(0.88, 0.96, 1.0, 0.9)
|
||||
@export var inactive_linger_seconds := 0.22
|
||||
|
||||
var _current := 0.0
|
||||
var _maximum := 1.0
|
||||
var _is_charge_ready := false
|
||||
var _active := false
|
||||
var _visual_linger_remaining := 0.0
|
||||
|
||||
@onready var _charge_component: Node = get_node_or_null(charge_component_path)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
segment_count = maxi(1, segment_count)
|
||||
z_index = max(z_index, 96)
|
||||
visible = false
|
||||
if _charge_component != null and _charge_component.has_signal("charge_changed"):
|
||||
if not _charge_component.is_connected("charge_changed", _on_charge_changed):
|
||||
_charge_component.connect("charge_changed", _on_charge_changed)
|
||||
|
||||
|
||||
func active() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
func current_progress_segments() -> float:
|
||||
if not _active and _visual_linger_remaining <= 0.0:
|
||||
return 0.0
|
||||
if _is_charge_ready:
|
||||
return float(segment_count)
|
||||
if _maximum <= float(segment_count - 1) + 0.1:
|
||||
return clampf(_current + 1.0, 0.0, float(segment_count))
|
||||
return clampf((_current / maxf(0.001, _maximum)) * float(segment_count), 0.0, float(segment_count))
|
||||
|
||||
|
||||
func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void:
|
||||
var was_showing := _active or _visual_linger_remaining > 0.0
|
||||
if active:
|
||||
_current = maxf(0.0, current)
|
||||
_maximum = maxf(0.001, maximum)
|
||||
_is_charge_ready = ready
|
||||
_visual_linger_remaining = 0.0
|
||||
modulate.a = 1.0
|
||||
visible = true
|
||||
else:
|
||||
if was_showing and inactive_linger_seconds > 0.0:
|
||||
_visual_linger_remaining = inactive_linger_seconds
|
||||
visible = true
|
||||
else:
|
||||
_visual_linger_remaining = 0.0
|
||||
modulate.a = 1.0
|
||||
visible = false
|
||||
_active = active
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _visual_linger_remaining <= 0.0:
|
||||
return
|
||||
_visual_linger_remaining = maxf(0.0, _visual_linger_remaining - delta)
|
||||
modulate.a = clampf(_visual_linger_remaining / maxf(0.001, inactive_linger_seconds), 0.0, 1.0)
|
||||
if _visual_linger_remaining <= 0.0:
|
||||
visible = false
|
||||
modulate.a = 1.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _active and _visual_linger_remaining <= 0.0:
|
||||
return
|
||||
var safe_count := maxi(1, segment_count)
|
||||
var total_width := segment_size.x * float(safe_count) + segment_gap * float(maxi(0, safe_count - 1))
|
||||
var origin := Vector2(-total_width * 0.5, 0.0)
|
||||
var progress := current_progress_segments()
|
||||
var plate_rect := Rect2(origin - Vector2(5.0, 4.0), Vector2(total_width + 10.0, segment_size.y + 8.0))
|
||||
draw_rect(plate_rect, Color(0.0, 0.0, 0.0, 0.68), true)
|
||||
draw_rect(plate_rect, Color(0.9, 0.96, 1.0, 0.42), false, 1.0)
|
||||
for index: int in range(safe_count):
|
||||
var rect := Rect2(origin + Vector2(float(index) * (segment_size.x + segment_gap), 0.0), segment_size)
|
||||
draw_rect(rect, background_color, true)
|
||||
var fill_ratio := clampf(progress - float(index), 0.0, 1.0)
|
||||
if fill_ratio > 0.0:
|
||||
var fill_rect := Rect2(rect.position + Vector2(border_width, border_width), Vector2(maxf(0.0, (segment_size.x - border_width * 2.0) * fill_ratio), maxf(0.0, segment_size.y - border_width * 2.0)))
|
||||
draw_rect(fill_rect, ready_fill_color if _is_charge_ready else fill_color, true)
|
||||
draw_rect(rect, border_color, false, border_width)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdkmtj1tgh5sf
|
||||
@@ -0,0 +1,48 @@
|
||||
class_name OverheadHealthBar
|
||||
extends Node2D
|
||||
|
||||
@export var health_component_path: NodePath = ^"../HealthComponent"
|
||||
@export var bar_size := Vector2(58.0, 7.0)
|
||||
@export var border_width := 1.0
|
||||
@export var background_color := Color(0.05, 0.04, 0.045, 0.82)
|
||||
@export var fill_color := Color(0.95, 0.16, 0.13, 0.95)
|
||||
@export var low_health_color := Color(1.0, 0.62, 0.15, 0.98)
|
||||
@export var border_color := Color(0.96, 0.9, 0.78, 0.88)
|
||||
@export var hide_when_depleted := true
|
||||
|
||||
var _current := 1
|
||||
var _maximum := 1
|
||||
|
||||
@onready var _health_component: Node = get_node_or_null(health_component_path)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = max(z_index, 30)
|
||||
if _health_component != null:
|
||||
if _health_component.has_signal("health_changed") and not _health_component.is_connected("health_changed", _on_health_changed):
|
||||
_health_component.connect("health_changed", _on_health_changed)
|
||||
_on_health_changed(int(_health_component.get("current")), int(_health_component.get("maximum")))
|
||||
else:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func health_ratio() -> float:
|
||||
return clampf(float(_current) / float(maxi(1, _maximum)), 0.0, 1.0)
|
||||
|
||||
|
||||
func _on_health_changed(current: int, maximum: int) -> void:
|
||||
_current = clampi(current, 0, maxi(1, maximum))
|
||||
_maximum = maxi(1, maximum)
|
||||
visible = _current > 0 or not hide_when_depleted
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if hide_when_depleted and _current <= 0:
|
||||
return
|
||||
var rect := Rect2(-bar_size * 0.5, bar_size)
|
||||
draw_rect(rect, background_color, true)
|
||||
var ratio := health_ratio()
|
||||
var fill_rect := Rect2(rect.position + Vector2(border_width, border_width), Vector2(maxf(0.0, (bar_size.x - border_width * 2.0) * ratio), maxf(0.0, bar_size.y - border_width * 2.0)))
|
||||
draw_rect(fill_rect, low_health_color if ratio <= 0.32 else fill_color, true)
|
||||
draw_rect(rect, border_color, false, border_width)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c277u75ut0vwy
|
||||
@@ -0,0 +1,88 @@
|
||||
class_name StateMachine
|
||||
extends Node
|
||||
|
||||
signal axis_changed(axis: StringName, previous: StringName, current: StringName)
|
||||
|
||||
enum GroundState { GROUNDED, AIRBORNE }
|
||||
enum ActionPhaseState { NEUTRAL, STARTUP, ACTIVE, RECOVERY, CHARGING }
|
||||
enum LifeState { ALIVE, HITSTUN, DEAD }
|
||||
enum DefenseState { VULNERABLE, PARRYING, SUPER_ARMOR, INVINCIBLE }
|
||||
|
||||
const GROUND_STATE_NAMES: Array[StringName] = [&"Grounded", &"Airborne"]
|
||||
const ACTION_PHASE_NAMES: Array[StringName] = [&"Neutral", &"Startup", &"Active", &"Recovery", &"Charging"]
|
||||
const LIFE_STATE_NAMES: Array[StringName] = [&"Alive", &"Hitstun", &"Dead"]
|
||||
const DEFENSE_STATE_NAMES: Array[StringName] = [&"Vulnerable", &"Parrying", &"SuperArmor", &"Invincible"]
|
||||
|
||||
var ground_state := GroundState.GROUNDED
|
||||
var action_phase := ActionPhaseState.NEUTRAL
|
||||
var life_state := LifeState.ALIVE
|
||||
var defense_state := DefenseState.VULNERABLE
|
||||
var air_action_count := 0
|
||||
var max_air_action_count := 1
|
||||
|
||||
|
||||
func build_context() -> Dictionary:
|
||||
var tags: Array[StringName] = [
|
||||
get_ground_state(),
|
||||
get_action_phase(),
|
||||
get_defense_state(),
|
||||
get_life_state(),
|
||||
]
|
||||
return {
|
||||
"ground_state": get_ground_state(),
|
||||
"action_phase": get_action_phase(),
|
||||
"defense_state": get_defense_state(),
|
||||
"life_state": get_life_state(),
|
||||
"air_action_count": air_action_count,
|
||||
"max_air_action_count": max_air_action_count,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
func get_context() -> Dictionary:
|
||||
return build_context()
|
||||
|
||||
|
||||
func has_tag(tag: StringName) -> bool:
|
||||
return build_context()["tags"].has(tag)
|
||||
|
||||
|
||||
func get_ground_state() -> StringName:
|
||||
return GROUND_STATE_NAMES[ground_state]
|
||||
|
||||
|
||||
func get_action_phase() -> StringName:
|
||||
return ACTION_PHASE_NAMES[action_phase]
|
||||
|
||||
|
||||
func get_life_state() -> StringName:
|
||||
return LIFE_STATE_NAMES[life_state]
|
||||
|
||||
|
||||
func get_defense_state() -> StringName:
|
||||
return DEFENSE_STATE_NAMES[defense_state]
|
||||
|
||||
|
||||
func set_ground_state(next_state: StringName) -> void:
|
||||
ground_state = _set_axis(&"ground_state", GROUND_STATE_NAMES, ground_state, next_state)
|
||||
|
||||
|
||||
func set_action_phase(next_phase: StringName) -> void:
|
||||
action_phase = _set_axis(&"action_phase", ACTION_PHASE_NAMES, action_phase, next_phase)
|
||||
|
||||
|
||||
func set_life_state(next_state: StringName) -> void:
|
||||
life_state = _set_axis(&"life_state", LIFE_STATE_NAMES, life_state, next_state)
|
||||
|
||||
|
||||
func set_defense_state(next_state: StringName) -> void:
|
||||
defense_state = _set_axis(&"defense_state", DEFENSE_STATE_NAMES, defense_state, next_state)
|
||||
|
||||
|
||||
func _set_axis(axis: StringName, names: Array[StringName], current: int, next_name: StringName) -> int:
|
||||
var next_index := names.find(next_name)
|
||||
if next_index == -1 or next_index == current:
|
||||
return current
|
||||
var previous := names[current]
|
||||
axis_changed.emit(axis, previous, names[next_index])
|
||||
return next_index
|
||||
@@ -0,0 +1 @@
|
||||
uid://1cwt1fphdnkr
|
||||
@@ -0,0 +1,63 @@
|
||||
class_name StreakCounter
|
||||
extends Node
|
||||
|
||||
## Sole writer of the streak count (AnchorV1.0 "ComboCounter").
|
||||
## Pure fact subscriber: +1 on skill_executed, reset on miss / chart_reset.
|
||||
|
||||
var streak := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("skill_executed") and not bus.is_connected("skill_executed", _on_skill_executed):
|
||||
bus.connect("skill_executed", _on_skill_executed)
|
||||
if bus.has_signal("judgement_made") and not bus.is_connected("judgement_made", _on_judgement_made):
|
||||
bus.connect("judgement_made", _on_judgement_made)
|
||||
if bus.has_signal("time_anchor_resolved") and not bus.is_connected("time_anchor_resolved", _on_time_anchor_resolved):
|
||||
bus.connect("time_anchor_resolved", _on_time_anchor_resolved)
|
||||
if bus.has_signal("chart_reset") and not bus.is_connected("chart_reset", _on_chart_reset):
|
||||
bus.connect("chart_reset", _on_chart_reset)
|
||||
|
||||
|
||||
func _on_skill_executed(_skill: Resource, _judgement: StringName) -> void:
|
||||
streak += 1
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void:
|
||||
if quality != &"miss":
|
||||
return
|
||||
reset()
|
||||
|
||||
|
||||
func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void:
|
||||
if held:
|
||||
return
|
||||
reset()
|
||||
|
||||
|
||||
func _on_chart_reset(_chart_id: StringName) -> void:
|
||||
reset()
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
if streak == 0:
|
||||
return
|
||||
streak = 0
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _broadcast() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("streak_changed"):
|
||||
bus.emit_signal("streak_changed", streak)
|
||||
|
||||
|
||||
func _event_buses() -> Array[Node]:
|
||||
var buses: Array[Node] = []
|
||||
if not is_inside_tree():
|
||||
return buses
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("skill_executed") and child.has_signal("streak_changed"):
|
||||
buses.append(child)
|
||||
return buses
|
||||
@@ -0,0 +1 @@
|
||||
uid://djvi3n0qxs2wl
|
||||
@@ -0,0 +1,89 @@
|
||||
class_name StrongBuffVisual
|
||||
extends Node2D
|
||||
|
||||
const DEFAULT_FRAME_DIRECTORY := "res://assets/ui/strong_buff"
|
||||
|
||||
@export var frame_directory := DEFAULT_FRAME_DIRECTORY
|
||||
@export var frames_per_second := 20.0
|
||||
@export var center_offset := Vector2(0.0, -38.0)
|
||||
@export var effect_scale := 0.18
|
||||
|
||||
var _active := false
|
||||
var _frame_index := 0
|
||||
var _frame_elapsed := 0.0
|
||||
var _frames: Array[Texture2D] = []
|
||||
var _sprite: Sprite2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = 1
|
||||
_sprite = Sprite2D.new()
|
||||
_sprite.name = "StrongBuffEffect"
|
||||
_sprite.centered = true
|
||||
_sprite.position = center_offset
|
||||
_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
_sprite.visible = false
|
||||
add_child(_sprite)
|
||||
_load_frames()
|
||||
set_active(false)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _active or _frames.is_empty():
|
||||
return
|
||||
_frame_elapsed += delta
|
||||
var frame_duration := 1.0 / maxf(1.0, frames_per_second)
|
||||
while _frame_elapsed >= frame_duration:
|
||||
_frame_elapsed -= frame_duration
|
||||
_frame_index = (_frame_index + 1) % _frames.size()
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
|
||||
|
||||
func set_active(active: bool) -> void:
|
||||
_active = active and not _frames.is_empty()
|
||||
visible = _active
|
||||
set_process(_active)
|
||||
if _sprite != null:
|
||||
_sprite.visible = _active
|
||||
_sprite.position = center_offset
|
||||
_sprite.scale = Vector2.ONE * effect_scale
|
||||
if _active and _sprite.texture == null and not _frames.is_empty():
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
func frame_count() -> int:
|
||||
return _frames.size()
|
||||
|
||||
|
||||
func current_effect_scale() -> float:
|
||||
return effect_scale
|
||||
|
||||
|
||||
func _load_frames() -> void:
|
||||
_frames.clear()
|
||||
var dir := DirAccess.open(frame_directory)
|
||||
if dir == null:
|
||||
return
|
||||
# 导出包内贴图在目录列表里显示为 xxx.png.import / xxx.png.remap;
|
||||
# 还原原名后 load() 才能命中。编辑器里原图与 .import 同时在列,需去重。
|
||||
var files := PackedStringArray()
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not dir.current_is_dir():
|
||||
var resource_name := file_name.trim_suffix(".import").trim_suffix(".remap")
|
||||
if resource_name.ends_with(".png") and not files.has(resource_name):
|
||||
files.append(resource_name)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
files.sort()
|
||||
for path_name: String in files:
|
||||
var texture := load("%s/%s" % [frame_directory, path_name]) as Texture2D
|
||||
if texture != null:
|
||||
_frames.append(texture)
|
||||
if not _frames.is_empty() and _sprite != null:
|
||||
_sprite.texture = _frames[0]
|
||||
@@ -0,0 +1 @@
|
||||
uid://hr56iy0nnfao
|
||||
@@ -0,0 +1,86 @@
|
||||
class_name TimePhaseAdapter
|
||||
extends Node
|
||||
|
||||
## Swaps this actor's time-phase Effects when the world phase changes.
|
||||
## Only ever talks to EffectContainer.add_effect / remove_effect; never
|
||||
## touches stats, state axes or animations directly. Actors without a
|
||||
## profile are simply unaffected by phase switches.
|
||||
|
||||
@export var profile: Resource
|
||||
@export var effect_container_path := NodePath("../EffectContainer")
|
||||
|
||||
@onready var effect_container: Node = get_node_or_null(effect_container_path)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
apply_time_phase(_current_time_phase())
|
||||
|
||||
|
||||
func apply_time_phase(time_phase: StringName) -> void:
|
||||
if profile == null or effect_container == null:
|
||||
return
|
||||
for definition: Resource in _profile_effects(&"past") + _profile_effects(&"future"):
|
||||
if definition != null and effect_container.has_method("remove_effect"):
|
||||
effect_container.call("remove_effect", StringName(str(definition.get("id"))))
|
||||
for definition: Resource in _profile_effects(time_phase):
|
||||
if definition != null and effect_container.has_method("add_effect"):
|
||||
effect_container.call("add_effect", definition, &"time_phase")
|
||||
|
||||
|
||||
func visual_key() -> StringName:
|
||||
if profile != null and profile.has_method("visual_key_for_time_phase"):
|
||||
return profile.call("visual_key_for_time_phase", _current_time_phase())
|
||||
return _current_time_phase()
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
|
||||
apply_time_phase(current)
|
||||
|
||||
|
||||
func _profile_effects(time_phase: StringName) -> Array[Resource]:
|
||||
var result: Array[Resource] = []
|
||||
if profile == null:
|
||||
return result
|
||||
var effects = profile.call("effects_for_time_phase", time_phase) if profile.has_method("effects_for_time_phase") else null
|
||||
if effects is Array:
|
||||
for definition: Variant in effects:
|
||||
if definition is Resource:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
var manager := _time_phase_manager_or_null()
|
||||
if manager != null:
|
||||
return StringName(str(manager.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _time_phase_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return _last_root_child_matching(func(child: Node) -> bool:
|
||||
return child.has_method("set_time_phase") and child.get("current_time_phase") != null
|
||||
)
|
||||
|
||||
|
||||
func _event_buses() -> Array[Node]:
|
||||
var buses: Array[Node] = []
|
||||
if not is_inside_tree():
|
||||
return buses
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("time_phase_changed"):
|
||||
buses.append(child)
|
||||
return buses
|
||||
|
||||
|
||||
func _last_root_child_matching(predicate: Callable) -> Node:
|
||||
var children := get_tree().root.get_children()
|
||||
for index: int in range(children.size() - 1, -1, -1):
|
||||
var child: Node = children[index]
|
||||
if bool(predicate.call(child)):
|
||||
return child
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://byxap76rqsb4j
|
||||
Reference in New Issue
Block a user