Godot 2D游戏教程系列走到现在,玩家角色的移动、跳跃、攻击基本都能跑了,下一步自然就是给这个世界添点"活物"。这一节(系列二第11篇)我们不继续做玩家,而是转头做敌人AI:让一个怪物能在场景里来回巡逻、发现玩家后追上去、碰到就攻击。这是很多2D横版游戏的地基之一,做完之后你会发现,后面再往场景里加新怪物,成本会低很多。
本节默认你已经掌握了Godot 4的基础操作场景搭建、节点挂载、信号连接,以及CharacterBody2D的移动套路。如果你是从系列一跟过来的,那直接照做就行;如果是半路来看的,代码部分我会尽量写完整,照着抄也能跑通。
1. 动手前先理清楚:敌人AI到底要哪些状态
先别急着写代码。做AI最忌讳的就是上来就堆逻辑,一旦逻辑多了,if套if,后面想加功能时根本不敢动。我在这节里的做法是:先把"敌人会干什么"拆清楚,再用状态机把每个行为装进独立的小盒子里。
对于大多数2D横版游戏来说,一个基础敌人只需要三个状态就能撑起完整的战斗体验:
- 巡逻:在固定路径点之间来回走,玩家没进入视野时保持警戒状态。
- 追击:视野里出现玩家后,朝玩家方向移动,试图接近。
- 攻击:距离足够近时出手,砍完一刀之后要有冷却时间,不能无脑连续攻击。
这三个状态已经覆盖了从"你没发现我"到"你发现我了"再到"我在打你"的完整闭环。不是所有敌人都需要很复杂的AI,先把这套骨架跑通,后面不管是加飞行怪、远程怪,都是在骨架之上做替换和扩展。
为什么用状态机而不是在一个_physics_process里写一堆布尔开关?因为状态机的核心约束是"同一时刻只执行一个行为"。用布尔数组的话,is_patrolling、is_chasing、is_attacking三个变量同时为真时,代码根本不知道你到底该干什么,而状态机从结构上就杜绝了这种混乱。它强迫你想清楚:什么条件下从巡逻切到追击,什么条件下从追击回到巡逻,切换前要不要清理现场。这种"约束"看着好像多写了点代码,实际省掉的调试时间远远不止这些。
顺带一提,这套状态机的设计不只适用于敌人,玩家角色的移动、爬梯、游泳、受伤硬直,全都可以复用同一套框架。教程后面如果做Boss战,也只是往这个框架里加新状态而已,性质完全一样。
2. 敌人场景基础搭建:碰撞、动画与朝向处理
2.1 场景节点结构
新建一个场景,根节点用CharacterBody2D,命名为Enemy,然后按下面的结构搭:
- Enemy(CharacterBody2D)
- CollisionShape2D
- AnimatedSprite2D
- StateMachine(Node)
- PatrolState
- ChaseState
- AttackState
CollisionShape2D 的形态我建议用CapsuleShape2D,不要直接用矩形。矩形在斜坡或者起伏地形上容易卡角,胶囊体虽然也不完美,但实际体感会好不少。尺寸按你的精灵图来,比如16x32像素的角色,碰撞体宽度给10到14,高度给24到28,略小于视觉体积,给玩家一个"看起来擦到但没受伤"的宽容度。这在动作游戏里是非常常规的做法,玩家不会因为一个像素的擦碰就掉血,体验会舒服很多。
AnimatedSprite2D 的动画,至少准备三组:idle、walk、attack。如果没有现成美术资源,用Godot编辑器里自带的Demo角色顶一下也行,重点是先把逻辑跑通,动画后面替换成正式素材,代码一行都不用改。这类临时资源我在实际开发中经常用,别觉得丢人,先把玩法验证了,美术是后话。
2.2 朝向处理
横版游戏里敌人必须知道自己在朝哪个方向走,这不仅影响动画的翻转,还影响攻击判定。我习惯在敌人脚本里维护一个facing变量:
extends CharacterBody2D class_name Enemy @export var move_speed: float = 40.0 @export var vision_range: float = 200.0 @export var attack_range: float = 30.0 var facing: int = 1 # 1 表示朝右,-1 表示朝左 func _physics_process(delta: float) -> void: move_and_slide() if velocity.x < -1: facing = -1 elif velocity.x > 1: facing = 1 $AnimatedSprite2D.flip_h = facing == -1flip_h控制水平翻转,这样往左走时不需要单独做一套向左的动画,省了一半美术工作量。攻击判定框如果要做得精细,也应该跟着朝向走,具体做法是攻击时生成一个Area2D,根据facing决定它生成在身体左侧还是右侧。这个细节我会在后面的攻击状态里展开讲。
注意:
move_and_slide()要在所有设置 velocity 的代码之后调用。如果某个状态里忘了设置 velocity,敌人在空中可能会因为重力越掉越快,排查时先检查这个调用顺序,比找半天逻辑问题快得多。
3. 状态机框架:一个通用基类和切换流程
3.1 状态基类
我先写一个最精简的EnemyState基类,每种状态都继承它,重写需要的方法。这里我选择用 Node 而不是普通对象来做状态,好处是可以直接在编辑器里看到每个节点,调试时也能清楚知道当前停留在哪个状态上。
class_name EnemyState extends Node var enemy: Enemy var state_machine: EnemyStateMachine func enter() -> void: pass func exit() -> void: pass func physics_update(delta: float) -> void: passenter和exit是状态的入口和出口钩子。enter里通常做初始化,比如清零计时器、播放待机动画;exit里做清理,比如重置动画、隐藏攻击判定框。这两个钩子的价值在于:每个状态只关心自己在做什么,切换时谁都不会留下残留状态。最常见的抖动现象,十有八九就是某个状态退出时没清理,被上一个状态的余温干扰了。
3.2 状态机主体
class_name EnemyStateMachine extends Node @export var initial_state: EnemyState var current_state: EnemyState func _ready() -> void: for child in get_children(): child.enemy = get_parent() child.state_machine = self current_state = initial_state if current_state: current_state.enter() func switch_state(new_state: EnemyState) -> void: if new_state == current_state: return if current_state: current_state.exit() current_state = new_state current_state.enter() func _physics_process(delta: float) -> void: if current_state: current_state.physics_update(delta)这段代码的核心逻辑很简单:_ready时把 enemy 引用注入每个子状态,然后进入初始状态;switch_state负责退出旧状态、进入新状态;每个物理帧把控制权交给当前状态。
有个细节值得注意:switch_state里我加了if new_state == current_state: return。不加这个判断的话,如果某个状态的代码里对同一状态连续调用多次切换,enter会被反复触发,动画重播、计时器重清零,各种莫名其妙的bug就是这样来的。在真实项目里我吃过这个亏,一个敌人AI莫名其妙地抽搐,最后定位到就是多余的重复切换。
注入 enemy 的方式是get_parent(),前提是状态机节点必须是 Enemy 的直接子节点。如果你以后想把状态机做成独立复用的模块,可以把注入改成@export手动拖,但教程这套结构,get_parent()最省事也最直观,改起来也容易。
3.3 状态之间怎么通信
状态切换还不够,状态之间偶尔需要传递信息,比如追击状态想知道上次巡逻停在哪,或者攻击状态结束后应该回到追击还是巡逻。两个做法:
- 直接在需要时从状态机获取其他状态:
state_machine.get_node("PatrolState")。 - 在 Enemy 上维护共享数据,状态只通过 enemy 读写。
我推荐以第二种为主。以"攻击完回到哪个状态"为例,攻击状态只需要问 enemy 是否还能看到玩家,能就切追击,不能就切巡逻,它不需要理解"视线"的原理,只需要调用enemy.can_see_player()。这样每个状态都保持独立,测试起来非常舒服,也方便以后单元测试。
4. 三个状态逐个实现
4.1 PatrolState:路径点移动与停留
巡逻状态需要一组路径点。最直观的做法是在场景里给敌人摆几个Marker2D子节点,命名为PatrolPointA、PatrolPointB,然后代码里遍历子节点收集坐标。我这里是收集所有以PatrolPoint开头的 Marker2D 的位置,按顺序走,到头之后原地停一秒再掉头:
extends EnemyState @export var patrol_speed: float = 40.0 @export var wait_time: float = 1.0 var points: Array[Vector2] = [] var target_index: int = 0 var wait_timer: float = 0.0 func enter() -> void: points.clear() for child in enemy.get_children(): if child is Marker2D and child.name.begins_with("PatrolPoint"): points.append(child.global_position) if points.is_empty(): push_warning("敌人没有配置巡逻点") target_index = 0 wait_timer = 0.0 func physics_update(delta: float) -> void: if enemy.can_see_player(): state_machine.switch_state(state_machine.get_node("ChaseState")) return if points.is_empty(): enemy.velocity = Vector2.ZERO return if wait_timer > 0.0: wait_timer -= delta enemy.velocity = Vector2.ZERO return var target: Vector2 = points[target_index] var to_target: Vector2 = target - enemy.global_position var distance: float = to_target.length() if distance < 4.0: target_index = (target_index + 1) % points.size() wait_timer = wait_time enemy.velocity = Vector2.ZERO return enemy.velocity = to_target.normalized() * patrol_speed注意一个边界:路径点数量只有1的时候,(0 + 1) % 1 = 0,会一直走向同一个点,到达后陷入原地踏步。我通常会在enter里判断points.size() < 2时直接禁用巡逻,只保留待机动画,这种防御式写法可以避免很多后期调试的尴尬。关卡设计时少摆一个点这种事,真的会让人查半天。
4.2 ChaseState:视野检测与追击逻辑
追击状态的触发条件由can_see_player()掌控,这是整个敌人AI里最重要的一环。最简单版本就是距离判断:
func can_see_player() -> bool: var player = get_tree().get_first_node_in_group("player") if player == null: return false return global_position.distance_to(player.global_position) <= vision_range前提是把玩家节点放进player组。get_first_node_in_group比到处find_child性能好、也更规范,建议养成用分组的习惯。哪怕场景里只有一个玩家,也用分组而不是直接拖引用,因为换场景后旧引用会失效,分组查找每次都是实时获取,天然免疫空指针问题。
追击状态本身:
extends EnemyState @export var chase_speed: float = 110.0 @export var lose_sight_time: float = 2.0 var lose_countdown: float = 0.0 func enter() -> void: lose_countdown = 0.0 func physics_update(delta: float) -> void: if not enemy.can_see_player(): lose_countdown += delta if lose_countdown >= lose_sight_time: state_machine.switch_state(state_machine.get_node("PatrolState")) else: # 视觉丢失后原地犹豫,避免来回抽搐 enemy.velocity = Vector2.ZERO else: lose_countdown = 0.0 var player = enemy.get_player() var direction: Vector2 = player.global_position - enemy.global_position enemy.velocity = direction.normalized() * chase_speed if direction.length() <= enemy.attack_range: state_machine.switch_state(state_machine.get_node("AttackState"))这里我特意设计了lose_sight_time参数。如果玩家一离开视野就立刻切回巡逻,敌人会在视野边界来回抽风,看起来像触电一样。给两秒的"犹豫期",玩家跑出视野后敌人会原地停一下再回去,观感自然很多。这个参数别看小,它是AI手感的重要调节旋钮之一,实机测试时一定要亲手调一调。
追击时如果敌人的碰撞体在悬崖边缘,它仍然会直直地走出去,掉下悬崖。处理方案要么是给追击路径做导航网格(NavigationAgent2D),要么做边缘检测(RayCast2D往下探底,没底就停)。导航网格是正解,但这一节先不铺开,读者可以按自己的关卡需求补。如果关卡里没有悬崖,这段可以跳过。
4.3 AttackState:攻击判定与冷却
攻击状态有两种实现思路:一种是纯代码控制动画和判定,另一种是用AnimationPlayer的动画事件来触发攻击判定。教程采用混合方式:状态负责节奏控制,攻击伤害通过表现层触发:
extends EnemyState @export var attack_cooldown: float = 1.5 @export var attack_anim_time: float = 0.6 var cooldown: float = 0.0 var attacking: bool = false func enter() -> void: enemy.velocity = Vector2.ZERO attacking = false cooldown = 0.2 func physics_update(delta: float) -> void: var player = enemy.get_player() if player == null: state_machine.switch_state(state_machine.get_node("PatrolState")) return var distance_to_player: float = enemy.global_position.distance_to(player.global_position) # 距离超出攻击范围太多,回到追击 if distance_to_player > enemy.attack_range * 1.3: state_machine.switch_state(state_machine.get_node("ChaseState")) return enemy.velocity = Vector2.ZERO enemy.facing = 1 if player.global_position.x > enemy.global_position.x else -1 cooldown -= delta if cooldown <= 0.0 and not attacking: attacking = true enemy.play_attack_animation() cooldown = attack_cooldown # 延迟一小段生成攻击判定框,配合动画挥舞时机的表现 await get_tree().create_timer(attack_anim_time * 0.4).timeout if state_machine.current_state == self: enemy.spawn_attack_hitbox() attacking = false这里有两个关键细节。
第一,异步等待攻击判定时,必须检查state_machine.current_state == self。因为 await 期间玩家可能已经跑了,状态已经切回追击,如果还照常生成攻击判定,就会出现"敌人在追击路上突然甩出一刀"的穿模情况。这种检查是异步操作场景下的必备防护,写一次就够,但必须写。
第二,attack_range * 1.3作为回到追击的阈值,而不是直接用attack_range。如果玩家刚好在攻击范围边缘,敌人会反复在"切攻击"和"切追击"之间横跳。留一点迟滞(hysteresis)区间,能有效消除高频抖动。这个思想在处理所有状态切换边界时都通用,后面调试时你会回来感谢这一条的。
攻击判定框我用Area2D,在enemy.spawn_attack_hitbox()里根据 facing 偏移生成,监听area_entered,检测到玩家后调用玩家的take_damage(1)。伤害数字、击退效果、受击反馈属于另一个大话题,教程系列里会把它单独放一节,这里先只留接口,能跑通血量变化就够。代码如下:
func spawn_attack_hitbox() -> void: var hitbox := Area2D.new() var shape := CollisionShape2D.new() shape.shape = RectangleShape2D.new() shape.shape.size = Vector2(24, 24) hitbox.add_child(shape) hitbox.position = Vector2(facing * 20, -8) hitbox.monitoring = true hitbox.area_entered.connect(_on_hitbox_area_entered) add_child(hitbox) # 动画结束后自动清理 get_tree().create_timer(0.15).timeout.connect(hitbox.queue_free) func _on_hitbox_area_entered(area: Area2D) -> void: if area.is_in_group("player_hurtbox"): var player = area.get_parent() if player.has_method("take_damage"): player.take_damage(1)这个临时生成的判定框不要在场景里常驻,攻击动作结束后自动销毁,既省性能又不会出现"转身之后身后还有判定"的怪事。
5. 需要特别小心的几个细节
5.1 状态切换顺序引发的"抖动"问题
前面提到过,切换条件如果设计得不够严谨,敌人会在两个状态之间疯狂切换。最常见的就是"追击"和"攻击"的边界。解决方案就是上面的迟滞区间:进入攻击的条件是距离 <= attack_range,退出攻击的条件是距离 > attack_range * 1.3,两个阈值之间留了空隙,而不是同一个阈值,抖动自然消失。
同样的思路也适用于"追击"和"巡逻":进入追击用视野范围,退出追击用视野范围 * 1.1或者带上计时器。我给的是lose_sight_time计时器方案,两者可以叠加使用,效果更好。实测下来,我偏好混合使用:既有时间缓冲,又在边界上留微量迟滞,视觉上最舒服。
5.2 动画与状态的同步
状态切换后,动画必须立刻跟着变,否则会出现"敌人在走但播放的是待机动画"的错位感。我的习惯是在每个状态的enter里直接写死动画播放:
# PatrolState.enter() enemy.get_node("AnimatedSprite2D").play("walk") # ChaseState.enter() enemy.get_node("AnimatedSprite2D").play("walk") # AttackState.enter() enemy.get_node("AnimatedSprite2D").play("attack")有人会问,巡逻和追击都是 walk,为什么不在 Enemy 上做一个统一方法?因为不同状态的动画节奏可能不同,攻击前摇时想播放 walk,攻击动作时想播放 attack,统一方法反而限制灵活性。让每个状态自己决定播放哪个动画,是状态机模式最自然的延伸。
还有一点,攻击动画播放完毕的检测。用AnimatedSprite2D.animation_finished信号最稳,不要让状态里的计时器硬卡。计时器受物理帧延迟影响,动画帧才是真正的节奏参考。不过要注意,Godot 里循环动画默认不触发animation_finished,所以攻击动画要记得在动画编辑里把 Loop 关掉。这个坑我在初学时候栽过一次,攻击动画永远不结束,状态也切不回去。
5.3 敌人与玩家碰撞的物理处理
CharacterBody2D默认不会主动推开另一个CharacterBody2D,两个物体相遇会直接重叠。所以敌人撞到玩家时,表现可能是"穿过身体"。想让敌人在近身时能顶住玩家,两种常见做法:
- 敌人设置
motion_mode = MotionMode.FLOATING,并且不主动推玩家,让玩家的move_and_slide把它推开,但这种方案手感通常比较奇怪。 - 攻击状态下让敌人拥有轻微的前推力,通过
velocity朝玩家方向缓慢移动,同时检测is_on_wall()来停下。
更省事的方案是给玩家的移动加push逻辑,用move_and_collide或者move_and_slide的碰撞回调来推敌人。这个属于玩家侧的设计,一般制作组会统一处理。我的态度是:先留一个接口,等玩家侧的逻辑定下来再补,不要在敌人侧强行解决物理推挤问题。
5.4 调试时最该看什么
状态机调试,第一步永远是把当前状态显示出来。最笨也最有效的方法:在 Enemy 场景里挂一个 Label,_process里刷新文本为state_machine.current_state.name,跑起来看文字变化。比如你发现敌人一直显示 PatrolState,说明视野检测有问题;一直显示 AttackState 说明退出条件没满足。不用加任何打印,视觉化调试比控制台日志直观得多。
我还踩过一个具体的坑:当敌人掉出地图后,get_player()返回空,直接调global_position.distance_to()会报空指针。所以我在追击和攻击状态里都加了if player == null的提前返回,宁可让这个遗落的敌人发呆,也不能让场景报错闪退。这种防御式写法不值得炫技,但它真的能让你的游戏测试顺畅很多。
6. 参数调优与扩展方向
参数这块我给一个经过实测的初始参考值,读者可以按自己的关卡速度感调整:
| 参数 | 建议初值 | 说明 |
|---|---|---|
| patrol_speed | 30-50 | 太慢显得呆,太快巡逻不像警戒 |
| chase_speed | 100-130 | 明显快于玩家慢走、慢于玩家快跑 |
| vision_range | 150-250 | 和屏幕大小有关,横版一般不超过屏幕宽度的一半 |
| attack_range | 25-40 | 略大于敌人攻击动画的实际挥击距离 |
| lose_sight_time | 1.5-2.5 | 越小越"死心眼",越大越迟钝 |
| attack_cooldown | 1.0-2.0 | 给玩家留下反击窗口 |
调参数的原则是"先手感后数值"。我建议先把 chase_speed 调到比玩家移动稍快一点,然后反向调 vision_range,让玩家感觉"潜入被发现了"而不是"屏幕外就被锁定了"。横版游戏里,视野是个球形半径在二维空间的投影,高度方向其实不用管太多,水平距离是主导因素。
6.1 扩展一:视线遮挡检测
如果要让敌人"真的用眼睛看",可以加一条RayCast2D,从敌人位置指向玩家,如果被墙体挡住就视为不可见。这个和平台跳跃里的射线检测是同一套思路,性能开销极小,放在can_see_player()里即可。唯一要注意的是射线不要打在敌人自己的碰撞体上,把 RayCast2D 的碰撞掩码设置好,只检测环境层。
6.2 扩展二:远程敌人的改造
想把近战敌人改成射箭的远程怪,只需要替换 AttackState 里的spawn_attack_hitbox()为spawn_projectile(),生成一个子弹Area2D,给它一个速度向量飞向玩家。代码里会多一个子弹场景,但状态机的骨架完全不用动。这就是当初拆状态的好处,改动被限制在一个很小的局部,不会牵一发动全身。
6.3 扩展三:Boss战的多阶段
Boss 的本质还是这套状态机,只是在每个状态下叠加更多机制。比如 Boss 的"追击"变成"冲刺"、"攻击"变成"三连击"。你甚至可以给状态机加一个"阶段"变量,血量低于一半时解锁新的状态切换条件。地基是同一块,往上盖多少层楼取决于你的游戏设计。
这套敌人AI到这里已经能完整跑通了。我在实际项目中反复用这套框架做过多种敌人,最大的感受是:写状态机本身不难,难的是想清楚每个切换条件在极端情况下会不会出问题。每次看到敌人抽搐、穿墙、原地发呆,先别急着调参数,回去检查状态切换条件是不是有重叠区间、退出时有没有清理该清理的东西。把这些边界处理好了,AI看起来才像一个"活物",而不是一段只会跑逻辑的代码。后面系列会继续讲攻击反馈、受击硬直、掉落物和音效,把敌人从"能跑能追"变成"能被打、能奖励玩家"的完整循环。到那时候你会感谢现在的自己:骨架打得稳,后面全是往桌上摆菜的事。