第二十七部分:敌人 AI 进阶:巡逻、追击、攻击、逃跑、技能释放、仇恨范围、状态机拆分

2026-05-19
348412 分钟
...

这一部分开始升级敌人 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 groupplayer_hurtbox
敌人 Hurtbox groupenemy_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 适合用状态机
常见状态包括 IDLEPATROLCHASEATTACKRETURNFLEECAST_SKILLHURTDEAD
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. 测试敌人死亡后停止所有攻击判定

完成这一部分之后,你的怪物就会从“看到玩家就直线冲过来”的简单敌人,升级成会巡逻、会追击、会放弃、会逃跑、会释放技能的战斗单位。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...