Files
rthythm_archor_game/scenes/enemies/minion_behavior.gd
T
2026-07-06 22:59:47 -07:00

432 lines
14 KiB
GDScript

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")