第二十七部分:敌人 AI 进阶:巡逻、追击、攻击、逃跑、技能释放、仇恨范围、状态机拆分
这一部分开始升级敌人 AI。
前面我们的 Enemy 可能已经能做到:
玩家进入检测范围
怪物追玩家
靠近后攻击
受伤
死亡
掉落
给经验
但目前敌人还比较简单:
看到玩家就追
靠近就打
没看到可能原地站着
现在要做得更像正式 RPG:
平时巡逻
发现玩家后追击
玩家跑远后返回原位
血量低时逃跑
远程怪会拉开距离
法师怪会释放技能
敌人有仇恨范围
敌人状态更清晰
这一部分会让你的战斗体验提升很多。
1. 敌人 AI 的核心问题
敌人 AI 本质上是在回答:
当前我应该做什么?
比如:
玩家不在附近:
巡逻
玩家进入视野:
追击
玩家进入攻击范围:
攻击
玩家跑太远:
放弃追击,返回出生点
血量很低:
逃跑
技能冷却好了:
释放技能
被眩晕:
什么都不做
所以敌人 AI 最适合用:
状态机
前面 Player 已经用了状态机。
Enemy 也应该有自己的状态机。
2. 第一版进阶 Enemy AI 要做什么
这一部分建议做:
1. EnemyState 扩展
2. IDLE 待机
3. PATROL 巡逻
4. CHASE 追击
5. ATTACK 攻击
6. RETURN 返回出生点
7. FLEE 逃跑
8. CAST_SKILL 释放技能
9. HURT 受伤
10. DEAD 死亡
11. 仇恨范围
12. 放弃追击范围
13. 巡逻点系统
14. 技能释放条件
15. 状态切换拆分
先不要做:
复杂行为树
黑板系统
群体协作
包围玩家
复杂视野遮挡
听觉系统
队长指挥
阵营关系
高级战术 AI
第一版目标:
敌人能巡逻、追击、攻击、逃跑、释放技能
3. EnemyState 设计
之前 Enemy 可能有:
enum EnemyState {
IDLE,
CHASE,
ATTACK,
HURT,
DEAD
}
现在扩展成:
enum EnemyState {
IDLE,
PATROL,
CHASE,
ATTACK,
RETURN,
FLEE,
CAST_SKILL,
HURT,
DEAD
}
含义:
IDLE:
站着不动,观察周围
PATROL:
在几个巡逻点之间移动
CHASE:
追击玩家
ATTACK:
普通攻击
RETURN:
玩家跑远后回到出生点或巡逻路线
FLEE:
低血量逃跑
CAST_SKILL:
释放技能
HURT:
受击硬直
DEAD:
死亡
4. Enemy 基础字段
Enemy.gd:
extends CharacterBody2D
signal health_changed(current_hp: int, max_hp: int)
signal died
enum EnemyState {
IDLE,
PATROL,
CHASE,
ATTACK,
RETURN,
FLEE,
CAST_SKILL,
HURT,
DEAD
}
@export var max_hp: int = 50
@export var base_damage: int = 8
@export var base_defense: int = 0
@export var move_speed: float = 45.0
@export var chase_speed: float = 60.0
@export var flee_speed: float = 70.0
@export var detection_range: float = 120.0
@export var attack_range: float = 28.0
@export var lose_interest_range: float = 220.0
@export var return_threshold: float = 8.0
@export var attack_cooldown: float = 1.2
@export var hurt_duration: float = 0.25
@export var exp_reward: int = 20
var current_hp: int
var final_defense: int = 0
var current_state: EnemyState = EnemyState.IDLE
var previous_state: EnemyState = EnemyState.IDLE
var target: Node2D = null
var spawn_position: Vector2
var last_attacker: Node = null
var attack_timer: float = 0.0
var hurt_timer: float = 0.0
var reward_given: bool = false
5. 节点结构推荐
Enemy CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── NavigationAgent2D
├── DetectionArea Area2D
│ └── CollisionShape2D
├── AttackArea Area2D
│ └── CollisionShape2D
├── Hurtbox Area2D
│ └── CollisionShape2D
└── BuffComponent Node
其中:
DetectionArea:
发现玩家
AttackArea:
普通攻击判定
Hurtbox:
被玩家攻击命中的区域
NavigationAgent2D:
寻路
BuffComponent:
中毒、燃烧、减速、眩晕等状态
6. _ready 初始化
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var navigation_agent: NavigationAgent2D = $NavigationAgent2D
@onready var detection_area: Area2D = $DetectionArea
@onready var attack_area: Area2D = $AttackArea
@onready var hurtbox: Area2D = $Hurtbox
@onready var buff_component: BuffComponent = $BuffComponent
func _ready():
current_hp = max_hp
final_defense = base_defense
spawn_position = global_position
detection_area.body_entered.connect(_on_detection_body_entered)
detection_area.body_exited.connect(_on_detection_body_exited)
attack_area.area_entered.connect(_on_attack_area_entered)
attack_area.monitoring = false
change_state(EnemyState.PATROL)
如果你暂时没有巡逻点,也可以:
change_state(EnemyState.IDLE)
7. DetectionArea 发现玩家
func _on_detection_body_entered(body: Node):
if current_state == EnemyState.DEAD:
return
if body.is_in_group("player"):
target = body
change_state(EnemyState.CHASE)
玩家离开 DetectionArea 不一定立刻放弃。
因为玩家可能刚跑出视野,敌人还可以追一小段。
所以 body_exited 可以先不处理:
func _on_detection_body_exited(body: Node):
if body == target:
pass
真正是否放弃追击,用距离判断:
distance_to_target > lose_interest_range
这样更自然。
8. _physics_process 状态分发
func _physics_process(delta):
if current_state == EnemyState.DEAD:
return
update_timers(delta)
if is_stunned():
physics_stunned(delta)
return
match current_state:
EnemyState.IDLE:
physics_idle(delta)
EnemyState.PATROL:
physics_patrol(delta)
EnemyState.CHASE:
physics_chase(delta)
EnemyState.ATTACK:
physics_attack(delta)
EnemyState.RETURN:
physics_return(delta)
EnemyState.FLEE:
physics_flee(delta)
EnemyState.CAST_SKILL:
physics_cast_skill(delta)
EnemyState.HURT:
physics_hurt(delta)
9. update_timers()
func update_timers(delta):
if attack_timer > 0:
attack_timer -= delta
if hurt_timer > 0:
hurt_timer -= delta
攻击冷却、受伤硬直都在这里更新。
10. 眩晕状态
BuffComponent 里有:
is_stunned()
Enemy 里:
func is_stunned() -> bool:
if buff_component == null:
return false
return buff_component.is_stunned()
眩晕时:
func physics_stunned(delta):
velocity = Vector2.ZERO
move_and_slide()
play_animation("idle")
这很重要。
眩晕不是一个单独状态也可以,因为它可以覆盖当前状态。
比如敌人正在追击,突然被眩晕:
状态仍然是 CHASE
但眩晕期间不执行 chase
眩晕结束后继续 CHASE
这种实现很简单。
11. change_state()
func change_state(new_state: EnemyState):
if current_state == new_state:
return
exit_state(current_state)
previous_state = current_state
current_state = new_state
enter_state(new_state)
12. enter_state()
func enter_state(state: EnemyState):
match state:
EnemyState.IDLE:
velocity = Vector2.ZERO
play_animation("idle")
EnemyState.PATROL:
play_animation("walk")
EnemyState.CHASE:
play_animation("walk")
EnemyState.ATTACK:
velocity = Vector2.ZERO
start_attack()
EnemyState.RETURN:
navigation_agent.target_position = spawn_position
play_animation("walk")
EnemyState.FLEE:
play_animation("walk")
EnemyState.CAST_SKILL:
velocity = Vector2.ZERO
start_cast_skill()
EnemyState.HURT:
velocity = Vector2.ZERO
hurt_timer = hurt_duration
play_animation("hurt")
EnemyState.DEAD:
enter_dead()
13. exit_state()
func exit_state(state: EnemyState):
match state:
EnemyState.ATTACK:
attack_area.monitoring = false
EnemyState.CAST_SKILL:
pass
暂时不用做太多。
但保留这个函数很有价值。
以后你会在这里停止动画、关闭攻击判定、清除临时变量。
14. IDLE 待机
func physics_idle(delta):
velocity = Vector2.ZERO
move_and_slide()
if has_valid_target():
change_state(EnemyState.CHASE)
return
如果没有巡逻系统,敌人就站着等玩家进入范围。
15. has_valid_target()
func has_valid_target() -> bool:
if target == null:
return false
if not is_instance_valid(target):
target = null
return false
if target.has_method("is_dead") and target.is_dead():
target = null
return false
return true
Player 可以加:
func is_dead() -> bool:
return current_state == PlayerState.DEAD
这样敌人不会追一个已经死亡或不存在的对象。
16. 巡逻点系统
巡逻点可以放在地图里。
Enemy 节点下加:
PatrolPoints Node2D
├── Point1 Marker2D
├── Point2 Marker2D
└── Point3 Marker2D
或者 Enemy 导出 NodePath。
第一版推荐 Enemy 子节点方式。
Enemy.gd:
@export var patrol_wait_time: float = 1.0
var patrol_points: Array[Node2D] = []
var patrol_index: int = 0
var patrol_wait_timer: float = 0.0
初始化:
func setup_patrol_points():
patrol_points.clear()
var points_root = get_node_or_null("PatrolPoints")
if points_root == null:
return
for child in points_root.get_children():
if child is Node2D:
patrol_points.append(child)
在 _ready() 里调用:
setup_patrol_points()
if patrol_points.is_empty():
change_state(EnemyState.IDLE)
else:
change_state(EnemyState.PATROL)
17. PATROL 巡逻逻辑
func physics_patrol(delta):
if has_valid_target():
change_state(EnemyState.CHASE)
return
if patrol_points.is_empty():
change_state(EnemyState.IDLE)
return
if patrol_wait_timer > 0:
patrol_wait_timer -= delta
velocity = Vector2.ZERO
move_and_slide()
play_animation("idle")
return
var point = patrol_points[patrol_index]
navigation_agent.target_position = point.global_position
move_by_navigation(move_speed)
if global_position.distance_to(point.global_position) <= 6.0:
patrol_index = (patrol_index + 1) % patrol_points.size()
patrol_wait_timer = patrol_wait_time
18. move_by_navigation()
很多状态都会用导航移动,所以抽出来。
func move_by_navigation(speed: float):
if navigation_agent.is_navigation_finished():
velocity = Vector2.ZERO
move_and_slide()
return
var next_path_position = navigation_agent.get_next_path_position()
var direction = global_position.direction_to(next_path_position)
velocity = direction * speed * get_move_speed_multiplier()
move_and_slide()
update_facing(direction)
play_animation("walk")
注意:
speed 是状态给的速度
get_move_speed_multiplier() 是 Buff 给的减速倍率
19. get_move_speed_multiplier()
func get_move_speed_multiplier() -> float:
if buff_component == null:
return 1.0
return buff_component.get_move_speed_multiplier()
这样减速 Buff 会影响巡逻、追击、逃跑。
20. 追击逻辑 CHASE
func physics_chase(delta):
if not has_valid_target():
change_state(EnemyState.RETURN)
return
var distance = global_position.distance_to(target.global_position)
if distance > lose_interest_range:
target = null
change_state(EnemyState.RETURN)
return
if should_flee():
change_state(EnemyState.FLEE)
return
if can_cast_skill():
change_state(EnemyState.CAST_SKILL)
return
if distance <= attack_range:
change_state(EnemyState.ATTACK)
return
navigation_agent.target_position = target.global_position
move_by_navigation(chase_speed)
顺序解释:
目标不存在 → 返回
目标太远 → 放弃并返回
血量太低 → 逃跑
能放技能 → 放技能
进入攻击范围 → 普通攻击
否则 → 追击
这个顺序可以按怪物类型调整。
比如法师怪可能优先放技能,而不是靠近攻击。
21. lose_interest_range 是什么
detection_range 是发现范围。
lose_interest_range 是放弃追击范围。
通常:
lose_interest_range > detection_range
比如:
detection_range = 120
lose_interest_range = 220
意思是:
玩家进入 120 像素内,敌人发现玩家
玩家跑到 220 像素外,敌人才放弃
如果这两个一样,会出现:
玩家刚出检测圈
敌人立刻放弃
玩家又进来
敌人又追
AI 会很抖。
所以放弃范围要大一点。
22. RETURN 返回出生点
玩家跑远后,敌人应该回到原位或继续巡逻。
func physics_return(delta):
if has_valid_target():
var distance = global_position.distance_to(target.global_position)
if distance <= detection_range:
change_state(EnemyState.CHASE)
return
navigation_agent.target_position = spawn_position
move_by_navigation(move_speed)
if global_position.distance_to(spawn_position) <= return_threshold:
if patrol_points.is_empty():
change_state(EnemyState.IDLE)
else:
change_state(EnemyState.PATROL)
返回时如果玩家又靠近,可以重新追击。
23. should_flee()
逃跑适合这些敌人:
胆小怪
远程怪
法师怪
低血量怪
导出字段:
@export var can_flee: bool = false
@export_range(0.0, 1.0) var flee_hp_ratio: float = 0.25
@export var flee_distance: float = 120.0
判断:
func should_flee() -> bool:
if not can_flee:
return false
if target == null:
return false
var hp_ratio := float(current_hp) / float(max_hp)
return hp_ratio <= flee_hp_ratio
24. FLEE 逃跑逻辑
逃跑就是朝远离玩家的方向走。
func physics_flee(delta):
if not has_valid_target():
change_state(EnemyState.RETURN)
return
var distance = global_position.distance_to(target.global_position)
if distance >= flee_distance:
change_state(EnemyState.CHASE)
return
var direction = target.global_position.direction_to(global_position)
velocity = direction.normalized() * flee_speed * get_move_speed_multiplier()
move_and_slide()
update_facing(direction)
play_animation("walk")
这里不使用导航。
简单逃离可能会撞墙。
第一版可以接受。
如果想更高级,需要找一个远离玩家、且在导航区域内的点。
25. 远程怪的保持距离
远程怪不一定低血量才逃。
它可能希望始终和玩家保持距离。
加字段:
@export var preferred_distance: float = 90.0
@export var is_ranged_enemy: bool = false
在 CHASE 里可以加:
if is_ranged_enemy and distance < preferred_distance:
change_state(EnemyState.FLEE)
return
然后 FLEE 到距离够了再施法或追击。
26. ATTACK 攻击逻辑
进入攻击状态时 start_attack()。
func start_attack():
if attack_timer > 0:
change_state(EnemyState.CHASE)
return
attack_timer = attack_cooldown
velocity = Vector2.ZERO
move_and_slide()
face_target()
play_animation("attack")
attack_area.monitoring = true
await get_tree().create_timer(0.15).timeout
attack_area.monitoring = false
await get_tree().create_timer(0.25).timeout
if current_state == EnemyState.ATTACK:
change_state(EnemyState.CHASE)
这里用了简单的延迟:
0.15 秒打开攻击判定
0.25 秒后结束攻击
第一版足够。
后面可以改成动画事件。
27. 攻击判定
func _on_attack_area_entered(area: Area2D):
if current_state != EnemyState.ATTACK:
return
if not area.is_in_group("player_hurtbox"):
return
if area.has_method("take_damage"):
area.take_damage(base_damage, self)
如果你的 Player Hurtbox 也在 group hurtbox,那要避免敌人打到敌人。
建议:
玩家 Hurtbox group:player_hurtbox
敌人 Hurtbox group:enemy_hurtbox 或 hurtbox
或者 Hurtbox 里有阵营。
第一版先分 group 最简单。
28. face_target()
func face_target():
if target == null:
return
var direction = global_position.direction_to(target.global_position)
update_facing(direction)
29. update_facing()
func update_facing(direction: Vector2):
if animated_sprite == null:
return
if abs(direction.x) > 0.1:
animated_sprite.flip_h = direction.x < 0
如果你是四方向动画,后面可以按方向播放:
walk_up
walk_down
walk_left
walk_right
第一版左右翻转够了。
30. 技能释放系统
前面 Player 已经有 SkillData。
Enemy 也可以复用 SkillData。
Enemy 字段:
@export var skills: Array[SkillData] = []
@export var skill_cast_range: float = 160.0
@export var skill_cooldown_global: float = 2.0
var skill_cooldowns: Dictionary = {}
var global_skill_timer: float = 0.0
var selected_skill: SkillData = null
31. 更新技能冷却
func update_timers(delta):
if attack_timer > 0:
attack_timer -= delta
if hurt_timer > 0:
hurt_timer -= delta
if global_skill_timer > 0:
global_skill_timer -= delta
update_skill_cooldowns(delta)
func update_skill_cooldowns(delta):
for skill_id in skill_cooldowns.keys():
skill_cooldowns[skill_id] = max(skill_cooldowns[skill_id] - delta, 0.0)
32. can_cast_skill()
func can_cast_skill() -> bool:
if skills.is_empty():
return false
if global_skill_timer > 0:
return false
if not has_valid_target():
return false
var distance = global_position.distance_to(target.global_position)
if distance > skill_cast_range:
return false
selected_skill = choose_available_skill()
return selected_skill != null
33. choose_available_skill()
func choose_available_skill() -> SkillData:
for skill in skills:
if skill == null:
continue
if get_skill_cooldown_remaining(skill.id) <= 0:
return skill
return null
第一版直接选第一个可用技能。
以后可以根据距离、血量、概率选择。
34. 技能冷却函数
func get_skill_cooldown_remaining(skill_id: String) -> float:
return skill_cooldowns.get(skill_id, 0.0)
func start_skill_cooldown(skill: SkillData):
skill_cooldowns[skill.id] = skill.cooldown
global_skill_timer = skill_cooldown_global
global_skill_timer 是敌人的全局技能间隔。
防止多个技能连发。
35. CAST_SKILL 状态
func physics_cast_skill(delta):
velocity = Vector2.ZERO
move_and_slide()
技能释放逻辑放在 start_cast_skill()。
36. start_cast_skill()
func start_cast_skill():
if selected_skill == null:
change_state(EnemyState.CHASE)
return
face_target()
play_animation("cast")
await get_tree().create_timer(0.3).timeout
if current_state != EnemyState.CAST_SKILL:
return
cast_selected_skill()
await get_tree().create_timer(0.2).timeout
if current_state == EnemyState.CAST_SKILL:
change_state(EnemyState.CHASE)
这里模拟:
0.3 秒施法前摇
释放技能
0.2 秒后摇
回到追击
37. cast_selected_skill()
func cast_selected_skill():
if selected_skill == null:
return
var skill = selected_skill
selected_skill = null
start_skill_cooldown(skill)
match skill.skill_type:
SkillData.SkillType.PROJECTILE:
cast_projectile_skill(skill)
SkillData.SkillType.AOE:
cast_aoe_skill(skill)
SkillData.SkillType.BUFF:
cast_buff_skill(skill)
第一版敌人技能重点做:
PROJECTILE
AOE
BUFF
近战仍然用普通攻击。
38. cast_projectile_skill()
func cast_projectile_skill(skill: SkillData):
if skill.projectile_scene == null:
return
if not has_valid_target():
return
var projectile = skill.projectile_scene.instantiate()
get_skill_parent().add_child(projectile)
var direction = global_position.direction_to(target.global_position)
projectile.global_position = global_position + direction * 18
if projectile.has_method("setup"):
projectile.setup(
direction,
skill.damage,
self,
skill.applied_buffs,
skill.buff_apply_chance
)
注意这里伤害可以用:
skill.damage
或者:
skill.damage + enemy_attack
如果你有 final_attack,可以加上。
39. get_skill_parent()
func get_skill_parent() -> Node:
if SceneManager.current_map != null:
return SceneManager.current_map
return get_tree().current_scene
技能生成到当前地图下,切地图时会一起清理。
40. cast_aoe_skill()
func cast_aoe_skill(skill: SkillData):
if skill.aoe_scene == null:
return
var aoe = skill.aoe_scene.instantiate()
get_skill_parent().add_child(aoe)
if has_valid_target():
aoe.global_position = target.global_position
else:
aoe.global_position = global_position
if aoe.has_method("setup"):
aoe.setup(
skill.damage,
self,
skill.radius,
skill.applied_buffs,
skill.buff_apply_chance
)
敌人 AOE 可以释放在玩家脚下。
这适合法师怪。
41. cast_buff_skill()
敌人也可以给自己加 Buff。
func cast_buff_skill(skill: SkillData):
if buff_component == null:
return
for buff_data in skill.applied_buffs:
buff_component.add_buff(buff_data, self)
比如:
哥布林狂暴:
给自己 attack_up
42. 技能选择更智能一点
第一版选第一个可用技能。
稍微升级可以按距离选择。
func choose_available_skill() -> SkillData:
if not has_valid_target():
return null
var distance = global_position.distance_to(target.global_position)
var candidates: Array[SkillData] = []
for skill in skills:
if skill == null:
continue
if get_skill_cooldown_remaining(skill.id) > 0:
continue
if skill.skill_type == SkillData.SkillType.PROJECTILE:
if distance <= skill.range:
candidates.append(skill)
elif skill.skill_type == SkillData.SkillType.AOE:
if distance <= skill_cast_range:
candidates.append(skill)
elif skill.skill_type == SkillData.SkillType.BUFF:
candidates.append(skill)
if candidates.is_empty():
return null
return candidates.pick_random()
这样敌人会随机释放可用技能。
43. HURT 受伤状态
Enemy 受伤后进入 HURT。
func physics_hurt(delta):
velocity = Vector2.ZERO
move_and_slide()
if hurt_timer <= 0:
if has_valid_target():
change_state(EnemyState.CHASE)
else:
change_state(EnemyState.RETURN)
44. take_damage()
func take_damage(amount: int, attacker: Node = null):
if current_state == EnemyState.DEAD:
return
last_attacker = attacker
if attacker is Node2D:
target = attacker
var final_damage = calculate_received_damage(amount)
current_hp -= final_damage
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
print(name, " 受到伤害:", final_damage, " 当前血量:", current_hp)
if current_hp <= 0:
change_state(EnemyState.DEAD)
died.emit()
return
change_state(EnemyState.HURT)
受到玩家攻击后,即使玩家不在检测范围,也会把玩家设为 target。
这样玩家远程打怪,怪会反应。
45. calculate_received_damage()
func calculate_received_damage(amount: int) -> int:
return max(amount - final_defense, 1)
如果你接入 Buff 防御:
func recalculate_stats():
final_defense = base_defense
if buff_component != null:
final_defense += buff_component.get_defense_bonus()
46. 死亡状态
func enter_dead():
velocity = Vector2.ZERO
move_and_slide()
if buff_component != null:
buff_component.clear_all_buffs()
attack_area.monitoring = false
detection_area.monitoring = false
hurtbox.monitorable = false
play_animation("dead")
give_death_rewards()
await get_tree().create_timer(0.5).timeout
queue_free()
如果你有死亡动画,就等动画结束再 queue_free。
第一版用 timer 简单处理。
47. give_death_rewards()
func give_death_rewards():
if reward_given:
return
reward_given = true
if last_attacker != null and last_attacker.has_method("add_experience"):
last_attacker.add_experience(exp_reward)
drop_loot()
如果是 Boss,要保存死亡状态:
@export var persistent_id: String = ""
@export var save_defeated_state: bool = false
if save_defeated_state and persistent_id != "":
SaveManager.mark_state("defeated_enemies", persistent_id)
加到 give_death_rewards() 里。
48. drop_loot()
你前面可能已经有掉落逻辑。
简单版:
@export var drop_item: ItemData
@export var drop_quantity: int = 1
@export var drop_chance: float = 0.5
@export var pickup_scene: PackedScene
func drop_loot():
if drop_item == null:
return
if pickup_scene == null:
return
if randf() > drop_chance:
return
var pickup = pickup_scene.instantiate()
get_skill_parent().add_child(pickup)
pickup.global_position = global_position
if pickup.has_method("setup"):
pickup.setup(drop_item, drop_quantity)
49. play_animation()
func play_animation(anim_name: String):
if animated_sprite == null:
return
if animated_sprite.sprite_frames == null:
return
if not animated_sprite.sprite_frames.has_animation(anim_name):
anim_name = "idle"
if animated_sprite.animation != anim_name:
animated_sprite.play(anim_name)
50. 敌人类型设计
有了这些状态后,你可以做不同怪物。
51. 史莱姆
特点:
不会逃跑
不会放技能
发现玩家后追
近身攻击
配置:
can_flee = false
skills = []
move_speed = 35
chase_speed = 50
attack_range = 24
52. 哥布林
特点:
会巡逻
追击玩家
低血量逃跑
配置:
can_flee = true
flee_hp_ratio = 0.25
flee_distance = 140
move_speed = 45
chase_speed = 70
53. 骷髅弓手
特点:
远程攻击
保持距离
不太想贴脸
配置:
is_ranged_enemy = true
preferred_distance = 100
skill_cast_range = 180
skills = [arrow_shot]
attack_range = 40
54. 法师怪
特点:
远距离释放火球
偶尔给自己加护盾
玩家靠近会后退
配置:
is_ranged_enemy = true
preferred_distance = 120
skills = [fireball, defense_up_skill]
skill_cast_range = 200
can_flee = true
55. Boss
特点:
不逃跑
技能多
血厚
有阶段变化
死亡状态保存
配置:
can_flee = false
skills = [fireball, ice_nova, summon]
save_defeated_state = true
persistent_id = forest_boss_001
Boss 阶段变化以后可以再讲。
56. 状态机拆分思路
现在 Enemy.gd 会越来越长。
你会发现它开始包含:
移动
巡逻
追击
攻击
技能
掉落
经验
Buff
动画
存档状态
后面可以拆成组件:
EnemyHealthComponent
EnemyMovementComponent
EnemyAIController
EnemySkillCaster
EnemyLootDropper
EnemyAnimationController
甚至每个状态拆成脚本:
EnemyIdleState.gd
EnemyPatrolState.gd
EnemyChaseState.gd
EnemyAttackState.gd
但第一版不建议马上拆。
原因:
系统还没稳定
拆太早会让文件变多
你会在跳来跳去中迷路
现在先用一个 Enemy.gd 跑通。
等你能稳定做出几种敌人,再重构。
57. 简单 Enemy.gd 结构建议
为了让单文件不乱,可以按这个顺序组织代码:
1. signal / enum
2. export 配置
3. onready 节点引用
4. 运行时变量
5. _ready / _physics_process
6. 状态机 change_state / enter_state / exit_state
7. 各状态 physics_xxx
8. 移动相关
9. 攻击相关
10. 技能相关
11. 受伤 / 死亡
12. 掉落 / 奖励
13. 动画
14. 工具函数
别把函数随便插。
Godot 脚本一乱,调试时很像在衣柜里找充电线,哪根都像,哪根都不是。
58. 常见错误 1:敌人不追玩家
排查:
DetectionArea 是否有 CollisionShape2D
DetectionArea monitoring 是否开启
Player 是否加入 player group
body_entered 是否连接
target 是否被设置
change_state(CHASE) 是否执行
临时打印:
func _on_detection_body_entered(body):
print("发现:", body.name)
59. 常见错误 2:敌人追一下就放弃
排查:
lose_interest_range 是否太小
target 是否被清空
has_valid_target 是否返回 false
Player.is_dead 是否误返回 true
建议:
detection_range = 120
lose_interest_range = 220
60. 常见错误 3:敌人一直抖动
常见原因:
攻击范围边界来回切换
NavigationAgent 到达距离太小
target_position 每帧变化导致路径抖动
解决:
attack_range 稍微大一点
NavigationAgent target_desired_distance 设置大一点
到达目标用 6 或 8 像素阈值
例如:
navigation_agent.path_desired_distance = 4.0
navigation_agent.target_desired_distance = 8.0
61. 常见错误 4:敌人不会巡逻
排查:
PatrolPoints 节点是否存在
里面是否有 Marker2D
setup_patrol_points 是否调用
patrol_points 是否为空
change_state(PATROL) 是否执行
地图是否有导航区域
先打印:
print("巡逻点数量:", patrol_points.size())
62. 常见错误 5:敌人走不到巡逻点
排查:
巡逻点是否在 NavigationRegion2D 内
敌人是否在 NavigationRegion2D 内
导航区域是否烘焙或配置正确
障碍物是否阻挡
NavigationAgent2D 是否正常
如果你还没配导航,可以先不用 NavigationAgent,直接朝点移动。
63. 常见错误 6:敌人攻击不到玩家
排查:
attack_range 是否太小
AttackArea 是否打开 monitoring
AttackArea 的 CollisionShape2D 是否覆盖玩家
Player Hurtbox 是否加入 player_hurtbox group
碰撞层 mask 是否匹配
_on_attack_area_entered 是否触发
临时打印:
func _on_attack_area_entered(area):
print("攻击碰到:", area.name)
64. 常见错误 7:敌人攻击一直打很多次
原因:
AttackArea monitoring 开太久
每次 area_entered 多次触发
没有攻击冷却
解决:
攻击只在很短时间内开启
attack_timer 控制冷却
每次攻击记录 hit_targets
你可以加:
var attack_hit_targets: Array[Area2D] = []
攻击开始清空:
attack_hit_targets.clear()
命中时判断:
if attack_hit_targets.has(area):
return
attack_hit_targets.append(area)
65. 常见错误 8:敌人放不出技能
排查:
skills 是否拖了 SkillData
SkillData.id 是否不为空
global_skill_timer 是否为 0
skill_cooldowns 是否已经结束
target 距离是否小于 skill_cast_range
skill.projectile_scene 或 aoe_scene 是否设置
临时打印:
print("检查技能:", selected_skill)
66. 常见错误 9:敌人火球打到自己
排查:
Projectile 碰撞层是否检测到了自己的 Hurtbox
Projectile 生成位置是否太靠近自己
Projectile 是否忽略 attacker
Projectile 命中时可以加:
var owner_node = area.get_parent()
if owner_node == attacker:
return
更稳的方式是 Hurtbox 提供 owner_node:
if area.owner_node == attacker:
return
67. 常见错误 10:敌人逃跑撞墙
第一版简单逃跑不走导航,撞墙正常。
可以先接受。
如果要改善:
找一个远离玩家的导航点
用 NavigationServer 查询
或者在周围随机几个点,选距离玩家最远且可达的
这个属于 AI 高级优化。
现在不用急。
68. 常见错误 11:敌人死后还攻击
排查:
enter_dead 是否关闭 attack_area.monitoring
detection_area.monitoring 是否关闭
current_state 是否是 DEAD
异步 start_attack 里 await 结束后是否检查 current_state
异步函数里每次 await 后都要检查状态:
await get_tree().create_timer(0.15).timeout
if current_state != EnemyState.ATTACK:
return
否则敌人死了,旧协程回来继续打开攻击判定,属于亡灵返工。
69. 这一部分最重要的记忆点
敌人 AI 适合用状态机
常见状态包括 IDLE、PATROL、CHASE、ATTACK、RETURN、FLEE、CAST_SKILL、HURT、DEAD
detection_range 是发现范围
lose_interest_range 是放弃范围,应该比 detection_range 大
巡逻点可以用 PatrolPoints + Marker2D
追击用 NavigationAgent2D
攻击用 AttackArea 短暂开启
低血量逃跑用 FLEE
远程怪用 preferred_distance 保持距离
敌人技能可以复用 SkillData
眩晕可以覆盖当前状态,让敌人暂时不执行 AI
Enemy.gd 变长后再考虑拆组件,不要一开始过度架构
70. 你现在的小练习
按这个顺序做:
1. 扩展 EnemyState
2. 加入 IDLE / PATROL / CHASE / ATTACK / RETURN / FLEE / CAST_SKILL
3. 添加 detection_range / attack_range / lose_interest_range
4. DetectionArea 发现玩家后进入 CHASE
5. CHASE 中玩家太远进入 RETURN
6. RETURN 回到 spawn_position
7. 添加 PatrolPoints
8. Enemy 读取 PatrolPoints
9. PATROL 在巡逻点之间移动
10. 玩家进入范围后打断巡逻进入 CHASE
11. 进入 attack_range 后进入 ATTACK
12. AttackArea 短暂开启并伤害玩家
13. 添加 attack_cooldown
14. 添加 can_flee / flee_hp_ratio
15. 低血量进入 FLEE
16. FLEE 拉开距离后回到 CHASE
17. Enemy 添加 skills 数组
18. 添加 skill_cooldowns
19. CHASE 中判断 can_cast_skill()
20. 进入 CAST_SKILL
21. 法师怪释放 FireballProjectile
22. AOE 怪释放范围技能
23. 骷髅弓手保持 preferred_distance
24. 测试眩晕 Buff 能让敌人停止行动
25. 测试燃烧 Buff 能在敌人追击时持续掉血
26. 测试敌人死亡后停止所有攻击判定
完成这一部分之后,你的怪物就会从“看到玩家就直线冲过来”的简单敌人,升级成会巡逻、会追击、会放弃、会逃跑、会释放技能的战斗单位。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录