第十部分:Enemy AI 基础:巡逻、发现玩家、追击、攻击、脱战返回

2026-05-19
353012 分钟
...

这一部分开始做怪物 AI。

前面我们的怪物还是“木桩”:

玩家攻击

怪物扣血

怪物死亡

现在要让怪物开始像怪物:

平时在原地或附近巡逻
玩家靠近后发现玩家
追击玩家
进入攻击范围后攻击玩家
玩家跑远后脱战
回到出生点或继续巡逻

这就是最基础的敌人 AI 闭环。

1. 怪物 AI 的核心状态

我们先不要做复杂行为树。

第一版用状态机就够。

怪物可以有这些状态:

IDLE待机
PATROL巡逻
CHASE追击玩家
ATTACK攻击玩家
HURT受伤
RETURN返回出生点
DEAD死亡

可以先做简化版:

IDLE
CHASE
ATTACK
HURT
DEAD

等跑通后再加:

PATROL
RETURN

状态机思路和 Player 一样:

怪物同一时间只做一种主要行为

比如:

CHASE 状态追玩家
ATTACK 状态停下来攻击
HURT 状态受伤硬直
DEAD 状态死亡消失

2. Enemy 推荐节点结构

先做一个 Slime。

Slime CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── DetectionArea Area2D
│   └── CollisionShape2D
├── AttackRange Area2D
│   └── CollisionShape2D
├── AttackArea Area2D
│   └── CollisionShape2D
└── Hurtbox Area2D
    └── CollisionShape2D

每个子节点职责:

AnimatedSprite2D
播放 idle / walk / attack / hurt / dead 动画

CollisionShape2D
怪物身体碰撞用来撞地图

DetectionArea
发现玩家的范围

AttackRange
判断玩家是否进入可攻击距离

AttackArea
怪物攻击判定用来打玩家

Hurtbox
怪物受击判定用来被玩家打

你可能会觉得 Area2D 有点多。

但拆开后,逻辑非常清楚:

DetectionArea = 我看见你了吗
AttackRange = 我够得着你了吗
AttackArea = 我这一击打到你了吗
Hurtbox = 我被你打到了吗

Area2D 本来就是用于定义一块 2D 区域,并检测其他 CollisionObject2D 是否进入、离开或重叠,所以这种“检测范围”非常适合用它做。(Godot Engine documentation)

3. 第一版可以先少几个节点

如果觉得上面太多,第一版可以这样:

Slime CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── DetectionArea Area2D
│   └── CollisionShape2D
├── Hurtbox Area2D
│   └── CollisionShape2D
└── AttackArea Area2D
    └── CollisionShape2D

然后用距离判断是否能攻击:

var distance_to_player = global_position.distance_to(player.global_position)

if distance_to_player <= attack_distance:
    change_state(EnemyState.ATTACK)

也就是说:

DetectionArea 负责发现玩家
distance_to_player 负责判断能不能攻击
AttackArea 负责真正命中玩家

这个版本更适合入门。

4. 怪物移动仍然用 CharacterBody2D

怪物会移动、会撞地图,所以用 CharacterBody2D 很合适。

官方文档里也说明,CharacterBody2D 是用于脚本控制的物理身体,常用 move_and_slide() 处理移动和碰撞;移动这类逻辑应该放在 _physics_process() 里。(Godot Engine documentation)

所以怪物追玩家时,不建议直接:

position += direction * speed * delta

更推荐:

velocity = direction * speed
move_and_slide()

这和玩家移动一致。

5. 怪物状态机基础

先写 enum:

enum EnemyState {
    IDLE,
    CHASE,
    ATTACK,
    HURT,
    DEAD
}

var current_state: EnemyState = EnemyState.IDLE

然后分发:

func _physics_process(delta):
    match current_state:
        EnemyState.IDLE:
            physics_idle(delta)

        EnemyState.CHASE:
            physics_chase(delta)

        EnemyState.ATTACK:
            physics_attack(delta)

        EnemyState.HURT:
            physics_hurt(delta)

        EnemyState.DEAD:
            physics_dead(delta)

这个结构和玩家状态机一样。

你以后会发现:玩家、怪物、NPC,很多东西本质上都可以用状态机组织。

6. DetectionArea:发现玩家

DetectionArea 用来检测玩家是否靠近。

结构:

Slime
└── DetectionArea Area2D
    └── CollisionShape2D

这个 CollisionShape2D 可以用圆形:

CircleShape2D
半径 120 160

连接信号:

@onready var detection_area: Area2D = $DetectionArea

func _ready():
    detection_area.body_entered.connect(_on_detection_area_body_entered)
    detection_area.body_exited.connect(_on_detection_area_body_exited)

检测玩家:

var player: Node2D = null

func _on_detection_area_body_entered(body):
    if body.is_in_group("player"):
        player = body
        change_state(EnemyState.CHASE)

func _on_detection_area_body_exited(body):
    if body == player:
        player = null
        change_state(EnemyState.IDLE)

Area2Dbody_entered / body_exited 信号会在 body 进入或离开区域时触发,不过需要 Area2D 的 monitoring 开启。(Godot Engine documentation)

7. Player 要加入 player 组

上面的代码用了:

body.is_in_group("player")

所以 Player 必须加入 group:

player

操作:

选中 Player
右侧 Node 面板
Groups
添加 player

不要长期用:

if body.name == "Player":

因为节点改名后代码就坏了。

Group 更稳。

8. CHASE:追击玩家

追击逻辑很简单:

如果有 player
计算方向
设置 velocity
move_and_slide()

代码:

@export var chase_speed: float = 80.0

func physics_chase(delta):
    if player == null:
        change_state(EnemyState.IDLE)
        return

    var direction = global_position.direction_to(player.global_position)

    velocity = direction * chase_speed
    move_and_slide()

direction_to() 会得到从当前点指向目标点的单位方向。

比如怪物在左,玩家在右:

direction 大概是 Vector2(1, 0)

然后:

velocity = direction * chase_speed

怪物就向玩家移动。

9. 怪物追击时也要判断攻击距离

追到一定距离就进入攻击状态。

@export var attack_distance: float = 28.0

func physics_chase(delta):
    if player == null:
        change_state(EnemyState.IDLE)
        return

    var distance = global_position.distance_to(player.global_position)

    if distance <= attack_distance:
        change_state(EnemyState.ATTACK)
        return

    var direction = global_position.direction_to(player.global_position)
    velocity = direction * chase_speed
    move_and_slide()

这就是最基础的追击 → 攻击切换。

10. ATTACK:攻击玩家

攻击状态一般要做:

停下来
面向玩家
播放攻击动画
打开 AttackArea
攻击结束后关闭 AttackArea
如果玩家还在附近继续追击或继续攻击
如果玩家离开回到 Idle

第一版先简单:

func physics_attack(delta):
    velocity = Vector2.ZERO
    move_and_slide()

进入攻击状态:

func enter_state(state: EnemyState):
    match state:
        EnemyState.ATTACK:
            velocity = Vector2.ZERO
            update_attack_area_position()
            attack_area.monitoring = true
            animated_sprite.play("attack")

退出攻击状态:

func exit_state(state: EnemyState):
    match state:
        EnemyState.ATTACK:
            attack_area.monitoring = false

攻击动画结束时:

func _on_animation_finished():
    if current_state == EnemyState.ATTACK:
        if player != null:
            change_state(EnemyState.CHASE)
        else:
            change_state(EnemyState.IDLE)

注意:攻击动画不要循环,不然 animation_finished 不会按你预期结束。

11. 怪物 AttackArea 命中玩家

上一节玩家攻击怪物时:

Player AttackArea 检测 Enemy Hurtbox

现在怪物攻击玩家时:

Enemy AttackArea 检测 Player Hurtbox

所以 Player 也应该有 Hurtbox:

Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── Camera2D
├── InteractArea Area2D
├── AttackArea Area2D
└── Hurtbox Area2D
    └── CollisionShape2D

Player 的 Hurtbox 加入 group:

player_hurtbox

Enemy 的 AttackArea:

Layer = enemy_hitbox
Mask = player_hurtbox

Player Hurtbox:

Layer = player_hurtbox
Mask =

12. Player Hurtbox.gd

可以写一个通用 Hurtbox,但先简单写:

extends Area2D

func take_damage(amount: int, attacker: Node = null):
    var owner_node = get_parent()

    if owner_node != null and owner_node.has_method("take_damage"):
        owner_node.take_damage(amount, attacker)

如果 Player 的 Hurtbox 不是 Player 直接子节点,就用导出变量:

@export var owner_node: Node

func take_damage(amount: int, attacker: Node = null):
    if owner_node != null and owner_node.has_method("take_damage"):
        owner_node.take_damage(amount, attacker)

13. Player.gd 加 take_damage()

Player 需要生命值:

@export var max_hp: int = 100

var current_hp: int

初始化:

func _ready():
    current_hp = max_hp

受伤:

func take_damage(amount: int, attacker: Node = null):
    if current_state == PlayerState.DEAD:
        return

    current_hp -= amount
    print("玩家受伤:", amount, " 当前血量:", current_hp)

    if current_hp <= 0:
        change_state(PlayerState.DEAD)
        return

    change_state(PlayerState.HURT)

这个就接上了前面预留的 HURTDEAD 状态。

14. Enemy AttackArea 代码

Enemy.gd 里:

@export var attack_damage: int = 10

@onready var attack_area: Area2D = $AttackArea

var hit_targets: Array[Node] = []

_ready:

func _ready():
    attack_area.area_entered.connect(_on_attack_area_entered)
    attack_area.monitoring = false
    animated_sprite.animation_finished.connect(_on_animation_finished)

命中:

func _on_attack_area_entered(area):
    if current_state != EnemyState.ATTACK:
        return

    if not area.is_in_group("player_hurtbox"):
        return

    if hit_targets.has(area):
        return

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(attack_damage, self)

攻击开始时清空:

func enter_state(state: EnemyState):
    match state:
        EnemyState.ATTACK:
            hit_targets.clear()
            velocity = Vector2.ZERO
            update_attack_area_position()
            attack_area.monitoring = true
            animated_sprite.play("attack")

这个和玩家攻击怪物是同一套模式。

15. 怪物攻击冷却

现在有个问题:

怪物攻击动画结束

如果玩家还在身边

马上又攻击

可能攻击频率太高

所以需要攻击冷却。

加变量:

@export var attack_cooldown: float = 1.0

var can_attack: bool = true

追击时判断:

if distance <= attack_distance and can_attack:
    change_state(EnemyState.ATTACK)
    return

进入攻击时:

can_attack = false
start_attack_cooldown()

冷却函数:

func start_attack_cooldown():
    await get_tree().create_timer(attack_cooldown).timeout

    if current_state != EnemyState.DEAD:
        can_attack = true

完整一点:

func enter_state(state: EnemyState):
    match state:
        EnemyState.ATTACK:
            can_attack = false
            hit_targets.clear()
            velocity = Vector2.ZERO
            update_attack_area_position()
            attack_area.monitoring = true
            animated_sprite.play("attack")
            start_attack_cooldown()

注意这里的冷却是异步的。

如果怪物死亡了,冷却回来也不要继续乱改状态,所以加了:

if current_state != EnemyState.DEAD:

16. 攻击范围位置

怪物攻击玩家,也要让 AttackArea 朝向玩家。

先写一个方向变量:

var last_direction: Vector2 = Vector2.DOWN

追击时更新:

var direction = global_position.direction_to(player.global_position)
last_direction = direction
velocity = direction * chase_speed

攻击时设置 AttackArea:

@onready var attack_shape: CollisionShape2D = $AttackArea/CollisionShape2D

func update_attack_area_position():
    var shape := attack_shape.shape as RectangleShape2D

    if abs(last_direction.x) > abs(last_direction.y):
        attack_area.position = Vector2(22 if last_direction.x > 0 else -22, 0)

        if shape != null:
            shape.size = Vector2(26, 20)
    else:
        attack_area.position = Vector2(0, 22 if last_direction.y > 0 else -22)

        if shape != null:
            shape.size = Vector2(22, 26)

这和玩家攻击范围一模一样。

以后你会发现:很多代码可以抽成通用组件。

但第一版先写清楚,不急着抽象。

17. IDLE:待机状态

怪物没有发现玩家时,保持待机。

func physics_idle(delta):
    velocity = Vector2.ZERO
    move_and_slide()
    animated_sprite.play("idle")

不过注意,不要每帧都 play("idle") 也可以,虽然通常能跑,但有时会重置动画。

更稳一点:

func play_animation(anim_name: String):
    if animated_sprite.animation != anim_name:
        animated_sprite.play(anim_name)

然后:

func physics_idle(delta):
    velocity = Vector2.ZERO
    move_and_slide()
    play_animation("idle")

18. CHASE 动画

追击时播放 walk:

func physics_chase(delta):
    if player == null:
        change_state(EnemyState.IDLE)
        return

    var distance = global_position.distance_to(player.global_position)

    if distance <= attack_distance and can_attack:
        change_state(EnemyState.ATTACK)
        return

    var direction = global_position.direction_to(player.global_position)
    last_direction = direction

    velocity = direction * chase_speed
    move_and_slide()

    play_animation("walk")

如果你的怪物只有一个方向动画,用 flip_h

if direction.x != 0:
    animated_sprite.flip_h = direction.x < 0

史莱姆这类怪物可以先这么做。

19. HURT:受伤状态

怪物被打时进入 HURT。

func take_damage(amount: int):
    if current_state == EnemyState.DEAD:
        return

    current_hp -= amount
    print(name, " 受伤:", amount, " 当前血量:", current_hp)

    if current_hp <= 0:
        change_state(EnemyState.DEAD)
        return

    change_state(EnemyState.HURT)

进入 HURT:

func enter_state(state: EnemyState):
    match state:
        EnemyState.HURT:
            velocity = Vector2.ZERO
            play_animation("hurt")

受伤动画结束:

func _on_animation_finished():
    if current_state == EnemyState.HURT:
        if player != null:
            change_state(EnemyState.CHASE)
        else:
            change_state(EnemyState.IDLE)

如果你没有 hurt 动画,可以用 Timer:

func enter_state(state: EnemyState):
    match state:
        EnemyState.HURT:
            velocity = Vector2.ZERO
            flash()
            await get_tree().create_timer(0.2).timeout

            if current_state == EnemyState.HURT:
                if player != null:
                    change_state(EnemyState.CHASE)
                else:
                    change_state(EnemyState.IDLE)

20. 受伤时关闭攻击判定

如果怪物正在攻击,结果被玩家打断,必须关闭 AttackArea。

所以 exit_state(ATTACK) 要稳定执行:

func exit_state(state: EnemyState):
    match state:
        EnemyState.ATTACK:
            attack_area.monitoring = false

然后受伤时用:

change_state(EnemyState.HURT)

不要直接:

current_state = EnemyState.HURT

否则 exit_state 不会执行,AttackArea 可能一直开着。

21. DEAD:死亡状态

死亡状态:

停止移动
关闭所有检测
播放死亡动画
最后 queue_free()

基础版:

func enter_state(state: EnemyState):
    match state:
        EnemyState.DEAD:
            velocity = Vector2.ZERO
            attack_area.monitoring = false
            detection_area.monitoring = false
            hurtbox.monitorable = false
            play_animation("dead")

动画结束后:

func _on_animation_finished():
    if current_state == EnemyState.DEAD:
        queue_free()

如果没有 dead 动画:

func enter_state(state: EnemyState):
    match state:
        EnemyState.DEAD:
            queue_free()

第一版这样就够。

22. 脱战:玩家跑远后回到 Idle

如果 DetectionArea 的 body_exited 触发,就回到 Idle:

func _on_detection_area_body_exited(body):
    if body == player:
        player = null

        if current_state != EnemyState.DEAD:
            change_state(EnemyState.IDLE)

但这里有个手感问题:

玩家刚跑出检测范围
怪物立刻停下

可能有点傻。

以后可以做:

玩家离开检测范围后怪物继续追 2
如果仍然找不到玩家再脱战

第一版先简单。

23. RETURN:返回出生点

如果你希望怪物脱战后回到出生点,需要记录出生位置:

var spawn_position: Vector2

_ready:

func _ready():
    spawn_position = global_position

状态加:

RETURN

脱战时:

change_state(EnemyState.RETURN)

返回逻辑:

@export var return_speed: float = 60.0
@export var return_arrive_distance: float = 4.0

func physics_return(delta):
    var distance = global_position.distance_to(spawn_position)

    if distance <= return_arrive_distance:
        global_position = spawn_position
        change_state(EnemyState.IDLE)
        return

    var direction = global_position.direction_to(spawn_position)
    velocity = direction * return_speed
    move_and_slide()

    play_animation("walk")

注意:这里最后把 global_position = spawn_position 是为了防止差一点点停不准。

24. PATROL:巡逻

巡逻可以先做最简单的版本:

怪物在几个点之间走

节点结构:

Slime
├── PatrolPoints Node2D
│   ├── PointA Marker2D
│   └── PointB Marker2D

但更推荐把巡逻点放在地图里,不放在怪物下面。

第一版简单点,可以用代码写偏移点:

@export var patrol_radius: float = 48.0

var patrol_points: Array[Vector2] = []
var patrol_index: int = 0

_ready:

func _ready():
    spawn_position = global_position
    patrol_points = [
        spawn_position + Vector2(-patrol_radius, 0),
        spawn_position + Vector2(patrol_radius, 0),
    ]

PATROL 逻辑:

@export var patrol_speed: float = 40.0
@export var patrol_arrive_distance: float = 4.0

func physics_patrol(delta):
    if patrol_points.is_empty():
        change_state(EnemyState.IDLE)
        return

    var target = patrol_points[patrol_index]
    var distance = global_position.distance_to(target)

    if distance <= patrol_arrive_distance:
        patrol_index = (patrol_index + 1) % patrol_points.size()
        return

    var direction = global_position.direction_to(target)
    last_direction = direction

    velocity = direction * patrol_speed
    move_and_slide()

    play_animation("walk")

现在怪物就能左右巡逻。

25. IDLE 和 PATROL 怎么切换

你可以让怪物默认 PATROL:

var current_state: EnemyState = EnemyState.PATROL

或者待机几秒再巡逻。

简单版:

没有玩家PATROL
发现玩家CHASE
玩家离开RETURN
返回出生点PATROL

这样怪物行为会自然一点:

巡逻
发现玩家
追击
玩家跑远
返回出生地
继续巡逻

26. 完整 Enemy.gd 基础版

这份代码比较长,但它是一个完整 AI 骨架。

extends CharacterBody2D

enum EnemyState {
    IDLE,
    PATROL,
    CHASE,
    ATTACK,
    HURT,
    RETURN,
    DEAD
}

@export var max_hp: int = 30
@export var chase_speed: float = 80.0
@export var patrol_speed: float = 40.0
@export var return_speed: float = 60.0
@export var attack_distance: float = 28.0
@export var attack_damage: int = 10
@export var attack_cooldown: float = 1.0
@export var patrol_radius: float = 48.0

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var detection_area: Area2D = $DetectionArea
@onready var attack_area: Area2D = $AttackArea
@onready var attack_shape: CollisionShape2D = $AttackArea/CollisionShape2D
@onready var hurtbox: Area2D = $Hurtbox

var current_state: EnemyState = EnemyState.PATROL
var current_hp: int

var player: Node2D = null
var spawn_position: Vector2
var last_direction: Vector2 = Vector2.DOWN

var patrol_points: Array[Vector2] = []
var patrol_index: int = 0

var can_attack: bool = true
var hit_targets: Array[Node] = []

func _ready():
    current_hp = max_hp
    spawn_position = global_position

    patrol_points = [
        spawn_position + Vector2(-patrol_radius, 0),
        spawn_position + Vector2(patrol_radius, 0),
    ]

    detection_area.body_entered.connect(_on_detection_area_body_entered)
    detection_area.body_exited.connect(_on_detection_area_body_exited)

    attack_area.area_entered.connect(_on_attack_area_entered)
    attack_area.monitoring = false

    animated_sprite.animation_finished.connect(_on_animation_finished)

func _physics_process(delta):
    match current_state:
        EnemyState.IDLE:
            physics_idle(delta)

        EnemyState.PATROL:
            physics_patrol(delta)

        EnemyState.CHASE:
            physics_chase(delta)

        EnemyState.ATTACK:
            physics_attack(delta)

        EnemyState.HURT:
            physics_hurt(delta)

        EnemyState.RETURN:
            physics_return(delta)

        EnemyState.DEAD:
            physics_dead(delta)

func physics_idle(delta):
    velocity = Vector2.ZERO
    move_and_slide()
    play_animation("idle")

func physics_patrol(delta):
    if patrol_points.is_empty():
        change_state(EnemyState.IDLE)
        return

    var target = patrol_points[patrol_index]
    var distance = global_position.distance_to(target)

    if distance <= 4.0:
        patrol_index = (patrol_index + 1) % patrol_points.size()
        return

    var direction = global_position.direction_to(target)
    last_direction = direction

    velocity = direction * patrol_speed
    move_and_slide()

    play_animation("walk")
    update_flip(direction)

func physics_chase(delta):
    if player == null:
        change_state(EnemyState.RETURN)
        return

    var distance = global_position.distance_to(player.global_position)

    if distance <= attack_distance and can_attack:
        change_state(EnemyState.ATTACK)
        return

    var direction = global_position.direction_to(player.global_position)
    last_direction = direction

    velocity = direction * chase_speed
    move_and_slide()

    play_animation("walk")
    update_flip(direction)

func physics_attack(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func physics_hurt(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func physics_return(delta):
    var distance = global_position.distance_to(spawn_position)

    if distance <= 4.0:
        global_position = spawn_position
        change_state(EnemyState.PATROL)
        return

    var direction = global_position.direction_to(spawn_position)
    last_direction = direction

    velocity = direction * return_speed
    move_and_slide()

    play_animation("walk")
    update_flip(direction)

func physics_dead(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func change_state(next_state: EnemyState):
    if current_state == next_state:
        return

    if not can_change_to(next_state):
        return

    exit_state(current_state)
    current_state = next_state
    enter_state(current_state)

func can_change_to(next_state: EnemyState) -> bool:
    if current_state == EnemyState.DEAD:
        return false

    return true

func enter_state(state: EnemyState):
    match state:
        EnemyState.IDLE:
            play_animation("idle")

        EnemyState.PATROL:
            play_animation("walk")

        EnemyState.CHASE:
            play_animation("walk")

        EnemyState.ATTACK:
            can_attack = false
            hit_targets.clear()
            velocity = Vector2.ZERO
            update_attack_area_position()
            attack_area.monitoring = true
            play_animation("attack")
            start_attack_cooldown()

        EnemyState.HURT:
            velocity = Vector2.ZERO
            attack_area.monitoring = false
            flash()
            play_animation("hurt")

        EnemyState.RETURN:
            play_animation("walk")

        EnemyState.DEAD:
            velocity = Vector2.ZERO
            attack_area.monitoring = false
            detection_area.monitoring = false
            hurtbox.monitorable = false
            play_animation("dead")

func exit_state(state: EnemyState):
    match state:
        EnemyState.ATTACK:
            attack_area.monitoring = false

func play_animation(anim_name: String):
    if animated_sprite == null:
        return

    if animated_sprite.animation != anim_name:
        animated_sprite.play(anim_name)

func update_flip(direction: Vector2):
    if direction.x != 0:
        animated_sprite.flip_h = direction.x < 0

func update_attack_area_position():
    var shape := attack_shape.shape as RectangleShape2D

    if abs(last_direction.x) > abs(last_direction.y):
        attack_area.position = Vector2(22 if last_direction.x > 0 else -22, 0)

        if shape != null:
            shape.size = Vector2(26, 20)
    else:
        attack_area.position = Vector2(0, 22 if last_direction.y > 0 else -22)

        if shape != null:
            shape.size = Vector2(22, 26)

func start_attack_cooldown():
    await get_tree().create_timer(attack_cooldown).timeout

    if current_state != EnemyState.DEAD:
        can_attack = true

func take_damage(amount: int):
    if current_state == EnemyState.DEAD:
        return

    current_hp -= amount
    print(name, " 受伤:", amount, " 当前血量:", current_hp)

    if current_hp <= 0:
        change_state(EnemyState.DEAD)
        return

    change_state(EnemyState.HURT)

func flash():
    if animated_sprite == null:
        return

    animated_sprite.modulate = Color(1, 0.4, 0.4)
    await get_tree().create_timer(0.1).timeout

    if is_instance_valid(animated_sprite):
        animated_sprite.modulate = Color(1, 1, 1)

func _on_detection_area_body_entered(body):
    if body.is_in_group("player"):
        player = body
        change_state(EnemyState.CHASE)

func _on_detection_area_body_exited(body):
    if body == player:
        player = null

        if current_state != EnemyState.DEAD:
            change_state(EnemyState.RETURN)

func _on_attack_area_entered(area):
    if current_state != EnemyState.ATTACK:
        return

    if not area.is_in_group("player_hurtbox"):
        return

    if hit_targets.has(area):
        return

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(attack_damage, self)

func _on_animation_finished():
    if current_state == EnemyState.ATTACK:
        if player != null:
            change_state(EnemyState.CHASE)
        else:
            change_state(EnemyState.RETURN)
        return

    if current_state == EnemyState.HURT:
        if player != null:
            change_state(EnemyState.CHASE)
        else:
            change_state(EnemyState.RETURN)
        return

    if current_state == EnemyState.DEAD:
        queue_free()
        return

这已经是一个比较完整的初级怪物 AI 了。

它包含:

巡逻
发现玩家
追击
攻击
攻击冷却
受伤
死亡
返回出生点

27. 如果没有 hurt / dead / attack 动画怎么办

很多时候你暂时只有:

idle
walk

那这段代码里:

play_animation("attack")
play_animation("hurt")
play_animation("dead")

会报错或者没效果。

你可以先改成:

play_animation("idle")

或者写一个安全播放:

func play_animation(anim_name: String):
    if animated_sprite == 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)

这样临时没有动画也能跑。

28. 这个 AI 目前的缺点

这个版本虽然能跑,但还很“基础”。

问题包括:

追击是直线追会卡墙
不会绕路
没有视野遮挡
玩家站在墙后也能被发现
多个怪物可能挤在一起
攻击判定比较粗糙
没有击退
没有仇恨记忆

这些都正常。

第一版 AI 的目标不是聪明,而是:

有状态
能发现
能追
能打
能死

聪明是后面的事。

29. 什么时候需要 NavigationAgent2D

如果你的地图有墙、树、河流,怪物直线追玩家会卡住。

这时就要用寻路。

Godot 的 NavigationAgent2D 是用于 2D 寻路的 agent,可以根据导航数据计算到目标点的路径,并辅助父节点沿路径移动;官方文档也提到,它需要导航数据才能工作。(Godot Engine documentation)

不过现在先别上来就用它。

建议顺序:

1. 先做直线追击
2. 地图和碰撞跑通
3. 怪物 AI 状态机跑通
4. 再加 NavigationAgent2D

不然你会同时调:

怪物状态机
碰撞层
导航区域
TileMap 导航
路径跟随
动态避障

这锅有点大,容易糊。

30. 视野检测:以后可以用 RayCast2D

现在 DetectionArea 是圆形范围。

它的问题是:

玩家隔着墙也会被发现

以后可以加 RayCast2D

DetectionArea 发现玩家进入范围

RayCast2D 朝玩家发射

如果中间没有墙才真正发现玩家

这一版先不用。

RPG 早期怪物“隔墙感应”问题不大,先把闭环打通。

31. 多个怪物怎么管理

地图里可以建一个容器:

Enemies Node2D
├── Slime
├── Slime
└── Slime

每个怪物自己管理自己的 AI。

不要写一个巨大的 EnemyManager 来每帧控制所有怪物。

第一阶段:

每只怪物一个状态机
每只怪物自己检测玩家
每只怪物自己追击/攻击/死亡

这样最直观。

32. 怪物生成点

以后可以用 Marker2D 做生成点:

EnemySpawns Node2D
├── SlimeSpawn01 Marker2D
├── SlimeSpawn02 Marker2D
└── SlimeSpawn03 Marker2D

然后代码实例化 Slime。

但现在你可以先直接把 Slime 拖到地图里。

先手摆三只怪,测试 AI。

不要一上来就写刷怪系统,真的会绕远。

33. 碰撞层推荐再整理一下

这一阶段建议:

Layer 1world
Layer 2player_body
Layer 3enemy_body
Layer 4interactable
Layer 5player_hitbox
Layer 6player_hurtbox
Layer 7enemy_hitbox
Layer 8enemy_hurtbox

配置:

Player Body
Layer = player_body
Mask = world

Player Hurtbox
Layer = player_hurtbox
Mask =
Group = player_hurtbox

Player AttackArea
Layer = player_hitbox
Mask = enemy_hurtbox

Enemy Body
Layer = enemy_body
Mask = world

Enemy DetectionArea
Mask = player_body

Enemy AttackArea
Layer = enemy_hitbox
Mask = player_hurtbox

Enemy Hurtbox
Layer = enemy_hurtbox
Mask =
Group = hurtbox

重点记:

身体碰撞负责移动
Hitbox 负责打人
Hurtbox 负责被打
DetectionArea 负责发现

34. 常见错误 1:怪物发现不了玩家

排查:

Player 是否加入 player
DetectionArea 是否有 CollisionShape2D
DetectionArea monitoring 是否开启
DetectionArea Mask 是否勾 player_body
Player Body Layer 是否是 player_body
body_entered 信号是否连接

临时打印:

func _on_detection_area_body_entered(body):
    print("DetectionArea 发现:", body.name)

如果没有打印,基本就是碰撞层或 Shape 问题。

35. 常见错误 2:怪物能追但打不到玩家

排查:

Enemy AttackArea 是否有 CollisionShape2D
AttackArea 攻击时 monitoring 是否为 true
Player Hurtbox 是否是 Area2D
Player Hurtbox 是否有 CollisionShape2D
Player Hurtbox 是否加入 player_hurtbox
Enemy AttackArea Mask 是否勾 player_hurtbox
Player Hurtbox Layer 是否是 player_hurtbox
AttackArea 位置是否正确

临时打印:

func _on_attack_area_entered(area):
    print("Enemy attack hit area: ", area.name)

36. 常见错误 3:怪物攻击太快

加攻击冷却:

var can_attack: bool = true
@export var attack_cooldown: float = 1.0

只有 can_attack 为 true 时才能攻击。

攻击开始后设为 false。

冷却结束再设回 true。

37. 常见错误 4:怪物受伤后 AttackArea 没关

原因通常是你直接写了:

current_state = EnemyState.HURT

正确是:

change_state(EnemyState.HURT)

因为 change_state() 会调用:

exit_state(current_state)

从 ATTACK 离开时,才能关闭:

attack_area.monitoring = false

38. 常见错误 5:怪物死亡后还打人

死亡时要关闭:

attack_area.monitoring = false
detection_area.monitoring = false
hurtbox.monitorable = false

否则有些检测可能还在。

如果你直接 queue_free(),一般问题不大。

但如果你要先播放死亡动画,那死亡期间必须关闭检测。

39. 常见错误 6:怪物一直卡墙

这是直线追击的局限。

先接受它。

如果你地图障碍很多,后面再上:

NavigationRegion2D
NavigationAgent2D
TileMap navigation layer

寻路会是一个单独章节。

40. 这一部分最重要的记忆点

怪物 AI 也适合用状态机
DetectionArea 负责发现玩家
CHASE 状态负责追击玩家
ATTACK 状态负责攻击玩家
AttackArea 平时关闭攻击时打开
Player 也需要 Hurtbox怪物才能打玩家
攻击冷却防止怪物疯狂连击
HURT 状态用于受伤硬直
DEAD 状态要关闭检测和攻击
RETURN 状态让怪物脱战后回出生点
PATROL 状态让怪物平时有行为

41. 你现在的小练习

先做一个史莱姆:

1. 创建 Slime CharacterBody2D
2. 添加 AnimatedSprite2D
3. 添加 CollisionShape2D
4. 添加 DetectionArea
5. 添加 AttackArea
6. 添加 Hurtbox
7. Player 加入 player
8. Player 添加 Hurtbox并加入 player_hurtbox
9. Slime 默认 PATROL
10. 玩家进入 DetectionAreaSlime CHASE
11. Slime 靠近玩家后切 ATTACK
12. AttackArea 命中 Player Hurtbox
13. Player 扣血
14. 玩家跑远后 Slime RETURN
15. Slime 回出生点后继续 PATROL

完成这部分之后,你的 RPG 已经具备最基本的“探索 + 交互 + 战斗 + 敌人行为”雏形了。

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

分享文章

相关文章

更多文章 →
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 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...