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