Files
2026-07-06 22:59:47 -07:00

171 lines
6.5 KiB
GDScript

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