Initial project sync

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