Godot 4 2D游戏教程:用状态机实现敌人巡逻、追击与攻击AI
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然后按下面的结构搭EnemyCharacterBody2DCollisionShape2DAnimatedSprite2DStateMachineNodePatrolStateChaseStateAttackStateCollisionShape2D 的形态我建议用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_speed30-50太慢显得呆太快巡逻不像警戒chase_speed100-130明显快于玩家慢走、慢于玩家快跑vision_range150-250和屏幕大小有关横版一般不超过屏幕宽度的一半attack_range25-40略大于敌人攻击动画的实际挥击距离lose_sight_time1.5-2.5越小越死心眼越大越迟钝attack_cooldown1.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看起来才像一个活物而不是一段只会跑逻辑的代码。后面系列会继续讲攻击反馈、受击硬直、掉落物和音效把敌人从能跑能追变成能被打、能奖励玩家的完整循环。到那时候你会感谢现在的自己骨架打得稳后面全是往桌上摆菜的事。