第十部分:Enemy AI 基础:巡逻、发现玩家、追击、攻击、脱战返回
这一部分开始做怪物 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)
Area2D 的 body_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)
这个就接上了前面预留的 HURT 和 DEAD 状态。
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 1:world
Layer 2:player_body
Layer 3:enemy_body
Layer 4:interactable
Layer 5:player_hitbox
Layer 6:player_hurtbox
Layer 7:enemy_hitbox
Layer 8:enemy_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. 玩家进入 DetectionArea 后,Slime 切 CHASE
11. Slime 靠近玩家后切 ATTACK
12. AttackArea 命中 Player Hurtbox
13. Player 扣血
14. 玩家跑远后 Slime RETURN
15. Slime 回出生点后继续 PATROL
完成这部分之后,你的 RPG 已经具备最基本的“探索 + 交互 + 战斗 + 敌人行为”雏形了。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录