432 lines
14 KiB
GDScript
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")
|