class_name MinionBehavior extends Node ## 小怪行为状态机(关卡设计指南 §7.4 / AnchorV1.0 §17.5)。 ## 近战怪:入场 → 巡逻 → (警戒后在下一个节拍点)追击 → 攻击。 ## 远程怪:入场 → 移动到站位固定 → 距离判断攻击;被近身或连续受击时后撤。 ## 决策与攻击都对齐节拍;移动逐帧执行。 const ROLE_MELEE := &"melee" const ROLE_RANGED := &"ranged" const STATE_ENTRY := &"Entry" const STATE_PATROL := &"Patrol" const STATE_CHASE := &"Chase" const STATE_HOLD := &"Hold" const STATE_RETREAT := &"Retreat" @export var enabled := true ## 近战怪会巡逻并追击;远程怪固定站位、会后撤。 @export var role: StringName = ROLE_MELEE @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") ## Attacks only start inside this horizontal distance; further away the minion ## repositions instead (near for melee, the whole firing lane for ranged). ## new3 §4.2:近战攻击距离 120。 @export var attack_range := 120.0 ## Stop approaching a little inside the attack range so the minion does not ## rub against the player collider. new3 §4.2:追踪停止距离 120。 @export var approach_stop_range := 120.0 ## 警戒范围:玩家进入后近战怪转入追击(下一个节拍点生效)。new3 §4.2:360。 @export var alert_range := 360.0 ## 近战怪巡逻半径与速度(占 approach_speed 的比例)。 @export var patrol_radius := 110.0 @export var patrol_speed_scale := 0.45 ## 远程怪后撤参数(new3 §5.4):玩家进入危险距离时向后退开。 @export var retreat_distance := 220.0 @export var retreat_speed_scale := 1.7 @export var too_close_range := 220.0 @export var too_close_seconds := 0.6 ## 受击脱离(2026-07-05 定案):连续受击 4 次触发短暂闪烁无敌 + 跳跃形式脱离。 @export var retreat_after_hits := 4 @export var hit_chain_window_seconds := 2.0 @export var retreat_cooldown_seconds := 3.0 @export var escape_invuln_seconds := 0.4 @export var escape_speed_scale := 3.2 ## 后撤/脱离结束后的停顿(按节拍对齐,new3 §5.4 推荐 0.4s ≈ 1 拍)。 @export var post_retreat_pause_beats := 1 ## 可活动区间:巡逻 / 后撤永远不出这段地面。 @export var arena_min_x := 1120.0 @export var arena_max_x := 2890.0 ## 增援入场目标(NAN = 原地即战斗区域,直接进入巡逻/站位)。 @export var entry_target_x := NAN @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 state: StringName = STATE_ENTRY var _next_decision_beat := 0 var _action_index := 0 var _patrol_anchor_x := 0.0 var _patrol_direction := 1.0 var _chase_pending := false var _retreat_target_x := 0.0 var _too_close_time := 0.0 var _recent_hits := 0 var _hit_window_left := 0.0 var _retreat_cooldown_left := 0.0 var _escaping := false var _invuln_left := 0.0 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) var receiver := get_node_or_null("../DamageReceiver") if receiver != null and receiver.has_signal("damage_received") and not receiver.is_connected("damage_received", _on_damage_received): receiver.connect("damage_received", _on_damage_received) _patrol_anchor_x = _owner_x() _refresh_target() if _has_entry_move(): state = STATE_ENTRY else: state = STATE_PATROL if role == ROLE_MELEE else STATE_HOLD _face_target() 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 _physics_process(delta: float) -> void: _tick_escape_invulnerability(delta) if not enabled or _is_dead(): _set_approach_direction(0.0) return if action_controller != null and int(action_controller.get("phase")) != 0: # 攻击动作进行中:位移交给动作本身。 _set_approach_direction(0.0) return _refresh_target() _tick_retreat_triggers(delta) _update_state() _update_movement() ## ------------------------------------------------------- state transitions func _update_state() -> void: var distance := _distance_to_target() match state: STATE_ENTRY: # 近战怪的入场可以被警戒打断(追击等下一拍);远程怪坚持走到站位。 # 每帧重算:玩家在下一拍前离开警戒范围就不再追。 if role == ROLE_MELEE: _chase_pending = distance <= alert_range if _entry_reached(): _arrive_at_battle_area() STATE_PATROL: _chase_pending = distance <= alert_range STATE_CHASE: if distance > alert_range * 1.25: # 玩家甩开警戒范围:回到当前位置附近小范围巡逻。 state = STATE_PATROL _patrol_anchor_x = _owner_x() _chase_pending = false STATE_HOLD: if _should_escape(): _begin_escape() elif _should_retreat(distance): _begin_retreat() STATE_RETREAT: if absf(_owner_x() - _retreat_target_x) <= 10.0: _finish_retreat() func _arrive_at_battle_area() -> void: entry_target_x = NAN if role == ROLE_MELEE: state = STATE_PATROL _patrol_anchor_x = _owner_x() else: # 到达站位后固定下来,开始距离判断。 state = STATE_HOLD func _begin_retreat(escape := false) -> void: var own_x := _owner_x() var away := 1.0 if target != null and is_instance_valid(target): away = 1.0 if own_x >= target.global_position.x else -1.0 var candidate := clampf(own_x + away * retreat_distance, arena_min_x, arena_max_x) if absf(candidate - own_x) < 60.0: # 贴墙没有退路:跳向另一侧(穿过玩家身位)。 candidate = clampf(own_x - away * retreat_distance, arena_min_x, arena_max_x) _retreat_target_x = candidate _too_close_time = 0.0 _recent_hits = 0 _retreat_cooldown_left = retreat_cooldown_seconds _escaping = escape if escape: _start_escape_invulnerability() state = STATE_RETREAT ## 受击脱离(2026-07-05 定案):短暂闪烁无敌 + 跳跃形式快速脱离。 func _begin_escape() -> void: _begin_retreat(true) func _finish_retreat() -> void: state = STATE_HOLD _escaping = false # 后撤/脱离后的短暂停顿:按节拍推迟下一次攻击决策(new3 §5.4)。 _next_decision_beat = maxi(_next_decision_beat, _current_beat_index() + maxi(0, post_retreat_pause_beats) + 1) func _should_retreat(distance: float) -> bool: if role != ROLE_RANGED or _retreat_cooldown_left > 0.0: return false return _too_close_time >= too_close_seconds func _should_escape() -> bool: if role != ROLE_RANGED or _retreat_cooldown_left > 0.0: return false return _recent_hits >= maxi(1, retreat_after_hits) func _start_escape_invulnerability() -> void: _invuln_left = maxf(0.0, escape_invuln_seconds) _set_damage_receiver_enabled(false) func _tick_escape_invulnerability(delta: float) -> void: if _invuln_left <= 0.0: return _invuln_left = maxf(0.0, _invuln_left - delta) var visual := _owner_visual() if _invuln_left <= 0.0: if visual != null: visual.modulate.a = 1.0 if not _is_dead(): _set_damage_receiver_enabled(true) return if visual != null: # 闪烁表现:无敌期间快速明暗交替。 visual.modulate.a = 0.35 if fmod(_invuln_left, 0.16) > 0.08 else 1.0 ## FrameCollisionDriver 每物理帧都会重申受击矩阵,所以无敌必须通过它的 ## damage_receiver_enabled 开关实现,而不是直接改 Area2D 的 monitoring。 func _set_damage_receiver_enabled(enabled: bool) -> void: var collision_driver := get_node_or_null("../FrameCollisionDriver") if collision_driver != null and "damage_receiver_enabled" in collision_driver: collision_driver.set("damage_receiver_enabled", enabled) var receiver := get_node_or_null("../DamageReceiver") as Area2D if receiver != null: receiver.set_deferred("monitoring", enabled) receiver.set_deferred("monitorable", enabled) func _owner_visual() -> Node2D: var owner := get_parent() if owner == null: return null return owner.get_node_or_null("Visual") as Node2D func _current_beat_index() -> int: var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null if rhythm != null: return int(rhythm.get("beat_index")) return 0 func _tick_retreat_triggers(delta: float) -> void: _retreat_cooldown_left = maxf(0.0, _retreat_cooldown_left - delta) if _hit_window_left > 0.0: _hit_window_left = maxf(0.0, _hit_window_left - delta) if _hit_window_left <= 0.0: _recent_hits = 0 if role == ROLE_RANGED and state == STATE_HOLD and _distance_to_target() <= too_close_range: _too_close_time += delta else: _too_close_time = maxf(0.0, _too_close_time - delta * 2.0) func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void: if role != ROLE_RANGED: return if _hit_window_left <= 0.0: _recent_hits = 0 _recent_hits += 1 _hit_window_left = hit_chain_window_seconds ## --------------------------------------------------------------- movement func _update_movement() -> void: match state: STATE_ENTRY: _move_toward_x(entry_target_x, 1.0) STATE_PATROL: _patrol_step() STATE_CHASE: _chase_step() STATE_HOLD: _set_approach_direction(0.0) _face_target() STATE_RETREAT: _move_toward_x(_retreat_target_x, escape_speed_scale if _escaping else retreat_speed_scale) _face_target() func _patrol_step() -> void: var own_x := _owner_x() var half := maxf(20.0, patrol_radius) var low := clampf(_patrol_anchor_x - half, arena_min_x, arena_max_x) var high := clampf(_patrol_anchor_x + half, arena_min_x, arena_max_x) if own_x <= low: _patrol_direction = 1.0 elif own_x >= high: _patrol_direction = -1.0 _set_approach_direction(_patrol_direction * patrol_speed_scale) _face_walk_direction(_patrol_direction) func _chase_step() -> void: var owner := get_parent() as Node2D if owner == null or target == null or not is_instance_valid(target): _set_approach_direction(0.0) return var delta_x := target.global_position.x - owner.global_position.x if absf(delta_x) <= maxf(8.0, approach_stop_range): _set_approach_direction(0.0) else: _set_approach_direction(signf(delta_x)) _face_target() func _move_toward_x(target_x: float, speed_scale: float) -> void: if is_nan(target_x): _set_approach_direction(0.0) return var delta_x := target_x - _owner_x() if absf(delta_x) <= 8.0: _set_approach_direction(0.0) return _set_approach_direction(signf(delta_x) * speed_scale) if state == STATE_ENTRY: _face_walk_direction(signf(delta_x)) func _entry_reached() -> bool: return not _has_entry_move() or absf(entry_target_x - _owner_x()) <= 10.0 func _has_entry_move() -> bool: return not is_nan(entry_target_x) func _set_approach_direction(direction: float) -> void: var owner := get_parent() if owner != null and "approach_direction" in owner: owner.set("approach_direction", direction) ## ------------------------------------------------------------ beat driven func _on_beat_ticked(beat_index: int) -> void: if not enabled or _is_dead(): return if _chase_pending and role == ROLE_MELEE and state != STATE_CHASE: # 指南 §7.4:警戒后在下一个节拍点进入追击状态。 _chase_pending = false state = STATE_CHASE if action_controller != null and int(action_controller.get("phase")) != 0: return if beat_index < _next_decision_beat: return if state == STATE_ENTRY or state == STATE_RETREAT: return var owner := get_parent() if owner == null or not owner.has_method("current_action_ids"): return _refresh_target() if _distance_to_target() > attack_range: # Out of reach: keep repositioning (handled per-frame); re-check next beat. _next_decision_beat = beat_index + 1 return _face_target() var action_ids: Array = owner.call("current_action_ids") if action_ids.is_empty(): return var action_id := StringName(str(action_ids[_action_index % action_ids.size()])) _action_index += 1 var interval := 1 if owner.has_method("current_attack_interval_beats"): interval = maxi(1, int(owner.call("current_attack_interval_beats"))) _next_decision_beat = beat_index + interval if driver != null and driver.has_method("start_action"): driver.call("start_action", action_id) ## ----------------------------------------------------------------- lookup func _distance_to_target() -> float: var owner := get_parent() as Node2D if owner == null or target == null or not is_instance_valid(target): return INF return absf(target.global_position.x - owner.global_position.x) func _owner_x() -> float: var owner := get_parent() as Node2D return owner.global_position.x if owner != null else 0.0 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 _face_walk_direction(direction: float) -> void: var owner := get_parent() if owner == null or is_zero_approx(direction): return if "heading" in owner: owner.set("heading", Vector2.LEFT if direction < 0.0 else Vector2.RIGHT) 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 _event_bus_or_null() -> Node: if not is_inside_tree(): return null return get_tree().root.get_node_or_null("EventBus")