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

409 lines
14 KiB
GDScript

class_name BossBehaviorTree
extends Node
@export var enabled := true
@export var target_path: NodePath
@export var enemy_action_driver_path: NodePath = NodePath("../EnemyActionDriver")
@export var action_controller_path: NodePath = NodePath("../ActionController")
@export var health_component_path: NodePath = NodePath("../HealthComponent")
## new3 §6.2:近战相位攻击距离 180。
@export var melee_distance := 180.0
@export var ranged_distance := 360.0
@export var dash_distance := 460.0
## new3 §6.7:远程相位下玩家进入该距离触发后撤。
@export var retreat_trigger_distance := 240.0
@export var decision_interval_beats := 2.0
## new3 §6.4:相位切换后短暂停顿(跳过的决策拍数,1 拍 ≈ 0.46s)。
@export var phase_switch_pause_beats := 1
## §18.1 Boss 相位差异:Past 出招慢(重击),Future 出招快(轻击、频率高)。
## 只缩放决策节拍,不改 HP/防御等核心数值。
@export var past_decision_interval_scale := 1.0
@export var future_decision_interval_scale := 0.5
## §18.2: preparing a special attack raises a conditional TimeAnchorEvent.
@export var special_attack_anchor_lead_beats := 2
@export var conditional_anchor_actions: Array[StringName] = [&"boss_lunging_stab", &"boss_2way_shoot"]
@export var melee_combo_cooldown_beats := 4.0
@export var retreat_cooldown_beats := 8.0
@export var stab_cooldown_beats := 16.0
@export var two_way_cooldown_beats := 8.0
@export var dash_cooldown_beats := 4.0
@export var low_health_ratio := 0.35
@export var restrict_actions_by_time_phase := true
@onready var driver: Node = get_node_or_null(enemy_action_driver_path)
@onready var action_controller: Node = get_node_or_null(action_controller_path)
@onready var health_component: Node = get_node_or_null(health_component_path)
@onready var target: Node2D = get_node_or_null(target_path) as Node2D
var _beat_cursor := 0.0
var _next_decision_beat := 0
var _last_seen_beat := 0
var _combo_index := 0
var _ranged_index := 0
var _force_ranged_after_retreat := false
var _last_melee_beat := -999.0
var _last_retreat_beat := -999.0
var _last_stab_beat := -999.0
var _last_two_way_beat := -999.0
var _last_dash_beat := -999.0
func _ready() -> void:
_connect_beat_bus()
_connect_chart_bus()
_connect_time_phase_bus()
_refresh_target()
_face_target()
func _connect_time_phase_bus() -> void:
var bus := _event_bus()
if bus != null and 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)
## new3 §6.4:相位切换时不立刻攻击——先停顿、调整距离,再进入新相位循环。
## 以总线上最后看到的拍号为基准(而非全局时钟),测试的合成拍号同样适用。
func _on_time_phase_changed(_previous: StringName, _current: StringName, _reason: StringName) -> void:
_next_decision_beat = maxi(_next_decision_beat, _last_seen_beat + 1 + maxi(0, phase_switch_pause_beats))
func _exit_tree() -> void:
var bus := _event_bus_or_null()
if bus == null:
return
if bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked):
bus.disconnect("beat_ticked", _on_beat_ticked)
if bus.has_signal("chart_event_upcoming") and bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming):
bus.disconnect("chart_event_upcoming", _on_chart_event_upcoming)
func _physics_process(_delta: float) -> void:
if not enabled or _is_dead():
return
if action_controller != null and int(action_controller.get("phase")) != 0:
return
_refresh_target()
_face_target()
func _on_beat_ticked(beat_index: int) -> void:
_last_seen_beat = maxi(_last_seen_beat, beat_index)
if not enabled or _is_dead() or not _owner_combat_enabled():
return
if action_controller != null and int(action_controller.get("phase")) != 0:
return
if beat_index < _next_decision_beat:
return
_beat_cursor = float(beat_index)
var action_id := _choose_action()
if action_id.is_empty():
return
if _owner_stationary() and (action_id == &"boss_dash" or action_id == &"boss_retreat_dash"):
action_id = _phase_fallback_action()
if not phase_allows_action(action_id):
action_id = _phase_fallback_action()
if action_id.is_empty() or not phase_allows_action(action_id):
return
_next_decision_beat = beat_index + _decision_beat_step()
if conditional_anchor_actions.has(action_id):
_schedule_conditional_anchor(beat_index)
if driver != null and driver.has_method("start_action"):
driver.call("start_action", action_id)
func reset_for_combat_entry() -> void:
enabled = true
_next_decision_beat = 0
_beat_cursor = 0.0
_force_ranged_after_retreat = false
_refresh_target()
_face_target()
## Conditional TimeAnchorEvent (AnchorV1.0 §18.2): the boss preparing a special
## attack marks an upcoming beat as a time anchor — miss it and the phase flips.
func _schedule_conditional_anchor(beat_index: int) -> void:
var anchor_system := _time_anchor_system_or_null()
if anchor_system == null or not anchor_system.has_method("schedule_time_anchor"):
return
anchor_system.call("schedule_time_anchor", beat_index + maxi(1, special_attack_anchor_lead_beats), &"boss_condition")
func _time_anchor_system_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("TimeAnchorSystem")
func _choose_action() -> StringName:
if _is_dead():
return &""
_refresh_target()
_face_target()
if target == null:
return &""
var distance := _distance_to_target()
var low_health := _health_ratio() <= low_health_ratio
if restrict_actions_by_time_phase:
var phase := _current_time_phase()
if phase == &"future":
return _choose_future_phase_action(distance)
if phase == &"past":
return _choose_past_phase_action(distance)
if distance <= melee_distance:
return _choose_melee_range_action(low_health)
if distance > dash_distance:
if _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
_last_dash_beat = _beat_cursor
return &"boss_dash"
return _next_ranged_action()
return _choose_mid_range_action(low_health, distance)
func _choose_past_phase_action(distance: float) -> StringName:
_force_ranged_after_retreat = false
if distance > dash_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
_last_dash_beat = _beat_cursor
return &"boss_dash"
if distance > melee_distance and _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
_last_stab_beat = _beat_cursor
return &"boss_lunging_stab"
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
_last_melee_beat = _beat_cursor
return _next_combo_action()
return &""
func _choose_future_phase_action(distance: float) -> StringName:
_force_ranged_after_retreat = false
# new3 §6.7:远程相位被近身时优先后撤拉开距离(后撤本身不造成伤害)。
if distance <= retreat_trigger_distance and not _owner_stationary() and _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats):
_last_retreat_beat = _beat_cursor
_force_ranged_after_retreat = true
return &"boss_retreat_dash"
if distance <= melee_distance and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
_last_two_way_beat = _beat_cursor
return &"boss_2way_shoot"
return _next_ranged_action()
func _choose_melee_range_action(low_health: bool) -> StringName:
_force_ranged_after_retreat = false
if not low_health and _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
_last_melee_beat = _beat_cursor
return _next_combo_action()
if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
_last_two_way_beat = _beat_cursor
return &"boss_2way_shoot"
if _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats):
_last_retreat_beat = _beat_cursor
_force_ranged_after_retreat = true
return &"boss_retreat_dash"
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
_last_melee_beat = _beat_cursor
return _next_combo_action()
return &""
func _choose_mid_range_action(low_health: bool, distance: float) -> StringName:
if _force_ranged_after_retreat:
_force_ranged_after_retreat = false
return _next_ranged_action()
if not low_health and _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
_last_stab_beat = _beat_cursor
return &"boss_lunging_stab"
if low_health and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
_last_two_way_beat = _beat_cursor
return &"boss_2way_shoot"
if distance > ranged_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
_last_dash_beat = _beat_cursor
return &"boss_dash"
return _next_ranged_action()
func _next_combo_action() -> StringName:
var actions: Array[StringName] = [&"boss_combo_1", &"boss_combo_2", &"boss_combo_3"]
var action_id := actions[_combo_index % actions.size()]
_combo_index += 1
return action_id
func _next_ranged_action() -> StringName:
var actions: Array[StringName] = [&"boss_shoot_1", &"boss_shoot_2"]
var action_id := actions[_ranged_index % actions.size()]
_ranged_index += 1
return action_id
func phase_allows_action(action_id: StringName) -> bool:
if not restrict_actions_by_time_phase or action_id.is_empty():
return true
var action := ActionResolver.get_action(action_id)
if action == null:
return true
var tags: Array = action.get("action_tags")
var hit_type := StringName(str(action.get("hit_type")))
var is_melee := hit_type == &"melee" or tags.has(&"melee")
var is_ranged := hit_type == &"projectile" or hit_type == &"ranged" or tags.has(&"ranged")
var phase := _current_time_phase()
if phase == &"past":
return not is_ranged
if phase == &"future":
return not is_melee
return true
func _phase_fallback_action() -> StringName:
if not restrict_actions_by_time_phase:
return _next_ranged_action()
if _current_time_phase() == &"future":
if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
_last_two_way_beat = _beat_cursor
return &"boss_2way_shoot"
return _next_ranged_action()
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
_last_melee_beat = _beat_cursor
return _next_combo_action()
if _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
_last_stab_beat = _beat_cursor
return &"boss_lunging_stab"
return &""
func _refresh_target() -> void:
if target != null and is_instance_valid(target):
return
target = get_node_or_null(target_path) as Node2D
if target != null:
return
var owner := get_parent()
if owner == null or owner.get_parent() == null:
return
target = owner.get_parent().get_node_or_null("Player") as Node2D
func _face_target() -> void:
var owner := get_parent()
if target != null and owner != null and owner.has_method("look_at_target"):
owner.call("look_at_target", target)
func _distance_to_target() -> float:
var owner := get_parent() as Node2D
if owner == null or target == null:
return melee_distance
return absf(target.global_position.x - owner.global_position.x)
func _health_ratio() -> float:
if health_component == null:
return 1.0
var maximum := maxf(1.0, float(health_component.get("maximum")))
return clampf(float(health_component.get("current")) / maximum, 0.0, 1.0)
func _owner_combat_enabled() -> bool:
# Boss-room lock: while the door is open the boss neither acts nor raises
# conditional time anchors.
var owner := get_parent()
if owner == null:
return true
var value = owner.get("combat_enabled")
if value is bool:
return value
return true
func _owner_stationary() -> bool:
var owner := get_parent()
if owner == null:
return false
var value = owner.get("stationary")
return value is bool and value
func _is_dead() -> bool:
if health_component != null and int(health_component.get("current")) <= 0:
return true
var owner := get_parent()
if owner == null:
return false
var state_machine := owner.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"
return false
func _cooldown_ready(last_beat: float, cooldown_beats: float) -> bool:
return _beat_cursor - last_beat >= cooldown_beats
func _decision_beat_step() -> int:
# Boss 相位补充说明: the boss has no strong/weak phase — HP, defense and
# its core numbers stay stable across Past/Future. Only move SELECTION
# (melee vs ranged) and decision CADENCE (§18.1 slow/heavy vs fast/light)
# shift with the phase.
var interval_scale := 1.0
if restrict_actions_by_time_phase:
interval_scale = future_decision_interval_scale if _current_time_phase() == &"future" else past_decision_interval_scale
return maxi(1, int(round(decision_interval_beats * interval_scale)))
func _connect_beat_bus() -> void:
var bus := _event_bus()
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)
func _connect_chart_bus() -> void:
var bus := _event_bus()
if bus != null and bus.has_signal("chart_event_upcoming") and not bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming):
bus.connect("chart_event_upcoming", _on_chart_event_upcoming)
func _on_chart_event_upcoming(event: Resource, _time_to_event: float) -> void:
if event == null:
return
var owner := get_parent()
if owner == null or StringName(str(event.get("target_id"))) != StringName(owner.name):
return
var event_beat := int(floor(float(event.call("beat_position"))))
_next_decision_beat = maxi(_next_decision_beat, event_beat + 2)
func _event_bus() -> 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 _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")
func _current_time_phase() -> StringName:
if not is_inside_tree():
return &"past"
var children := get_tree().root.get_children()
for index: int in range(children.size() - 1, -1, -1):
var child: Node = children[index]
if child.has_method("set_time_phase") and child.get("current_time_phase") != null:
return StringName(str(child.get("current_time_phase")))
return &"past"
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