这期是系列二的第13篇。如果你跟完了之前的教程,应该已经能搭出基础场景、搞定角色移动和碰撞处理了。但这些能力拼在一起,游戏仍然缺一个关键的东西——敌人。确切地说,是缺一个会思考、能反应、让玩家觉得"这游戏有魂儿"的敌人。
很多新手做敌人AI,第一反应就是在_process()里贴一坨if/else:玩家离近了就追,离远了就回去,血少了就逃跑。单看一个敌人问题不大,可敌人一多、行为一复杂,这套代码立刻变成无人敢动的意大利面。今天这篇,我把打磨过很多次的状态机方案完整拆给你看,把敌人从"会动的障碍物"变成"真正有行为逻辑的对手"。这个方案用的是Godot 4.x自带的GDScript,不需要装任何插件,理解之后可以平移到你自己的项目里,做Boss也好、做小怪也好,都能直接套。
1. 内容整体设计与思路拆解
1.1 为什么偏偏选状态机
状态机这个词听起来唬人,其实你天天都在用。红绿灯就是最典型的状态机:红灯、绿灯、黄灯,三个状态来回切换,每个状态只做自己该做的事,切换条件写在马路上。敌人AI本质上也是一回事——待机、巡逻、追击、攻击、受击、死亡,这些就是状态,玩家靠近了、被打到了、血条清零了,就是触发切换的条件。
我用状态机之前也走过弯路。早期项目里写过一个多段攻击Boss,动作有七八种,直接用一个超长match堆逻辑,加一个动作就要把整个函数翻一遍,后来只过了两周我自己都看不懂那段代码了。换状态机之后最直观的感受是:每个状态的逻辑被关进独立的"隔间"里,改巡逻不会碰到攻击,加一个新行为也只需要新增一个状态节点,不用担心把其他地方碰坏。
Godot的场景树天然适合状态机,因为每个状态可以是一个节点,也可以是一段独立的脚本状态类。你不需要引入复杂的插件,只用语言原生的枚举加match,就能把状态机写得清清楚楚。我见过不少团队非要为了状态机上设计模式,反而把项目搞复杂。自己写状态机,最明白、最可控。
1.2 状态机架构怎么规划才不乱
这一篇我准备做一套通用性比较强的敌人AI框架,包含六个核心状态:待机、巡逻、追击、攻击、受击、死亡。你拿到手之后可以按需删减,比如炮台类敌人不需要巡逻,远程法师可以把攻击换成施法。
状态流大概是这么走的:敌人出生后进入待机,站两秒或者随机事件触发巡逻;视野检测到玩家,切换到追击;距离足够近,切换攻击;被玩家打了,切受击,播放受伤动画之后回到追击或待机;血量归零,切死亡,禁用碰撞并播放死亡动画。
这里有一个新手特别容易搞混的点:切换状态,不等于切换函数。你要管理的不是"现在执行哪段代码",而是"当前状态下的每帧逻辑"和"离开当前状态时要清理什么"。举个例子,敌人从追击切到攻击,如果追击状态里一直在播放跑步动画,切到攻击时必须立刻停掉跑步动画并播放攻击动画。如果忘了这一步,就会看到敌人一边挥拳一边滑冰,相当出戏。
2. 核心细节解析与实操要点
2.1 节点设计与场景结构
动手写代码之前,先把敌人场景的节点结构搭好。我推荐的最小结构是:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── RayCast2D/Area2D (用于视野和攻击检测) ├── AnimationPlayer └── StateMachine (自定义节点,也可以直接挂在根节点上)给的是一个基准结构,没有固定要求。关键点在于碰撞与动画分离。Sprite2D只管显示,AnimationPlayer只管播动画,逻辑统一放在根节点的脚本里。这样分工清晰,找人找错都方便。
物理层设置是很多人忽略的坑。把敌人放在layer 2、玩家放在layer 1,通过碰撞掩码控制互相能撞到的对象。千万不要把所有东西都丢在同一层,否则你做一个Area2D检测攻击范围时,会发现敌人的攻击判定连掉在地上的金币都一起检测到了。
2.2 状态定义与切换条件的设计
状态用枚举定义,这是最直观的做法。GDScript里直接写:
enum State { IDLE, PATROL, CHASE, ATTACK, HURT, DEAD }每个状态要回答三个问题:进入时要做什么、持续中每帧做什么、退出时要做什么。我在代码里用一组对应的函数去承载它们,而不是把所有逻辑塞进一个process()。这个习惯很关键,可以让状态逻辑完全独立,每个函数只处理一件事,测试起来也方便。
切换条件的设计比状态本身更重要。你可能遇到过敌人"抽搐"的情况——在追击和待机之间疯狂横跳,原因就是切换条件写得太"激进"。玩家刚踏进视野边缘,敌人立刻追击;玩家只要往后挪半步,敌人又切回待机。镜头前就像抽风一样。
解决方案有两个,先选一个用就好。一是加"切换冷却",状态切换后至少停留0.2秒才能再切;二是视野检测用"延迟确认",玩家进入视野后持续0.15秒才真正触发追击,离开视野也同理。这两个小技巧能干掉九成以上的敌人抽搐问题。
2.3 状态机代码的骨架写法
我习惯把状态机直接写在敌人主控脚本里,因为这套逻辑本身只服务这一个敌人。如果你的敌人类型特别多,以后可以考虑拆成独立脚本,但现在先保持简单实用。
主控脚本的核心骨架是:
extends CharacterBody2D enum State { IDLE, PATROL, CHASE, ATTACK, HURT, DEAD } @export var current_state: State = State.IDLE @export var move_speed: float = 60.0 @export var chase_speed: float = 120.0 @export var attack_range: float = 30.0 @onready var player: CharacterBody2D = get_node("../Player") @onready var sprite: Sprite2D = $Sprite2D @onready var animation_player: AnimationPlayer = $AnimationPlayer func _ready(): _enter_state(current_state) func _physics_process(delta): _state_process(current_state, delta) _update_timer(delta) func _enter_state(state: State): match state: State.IDLE: animation_player.play("idle") State.PATROL: animation_player.play("walk") State.CHASE: animation_player.play("run") State.ATTACK: animation_player.play("attack") State.HURT: animation_player.play("hurt") State.DEAD: animation_player.play("dead") $CollisionShape2D.set_deferred("disabled", true) func _state_process(state: State, delta: float): match state: State.IDLE: _process_idle(delta) State.PATROL: _process_patrol(delta) State.CHASE: _process_chase(delta) State.ATTACK: _process_attack(delta) State.HURT: _process_hurt(delta) State.DEAD: pass func _change_state(next: State): current_state = next _enter_state(next)这是一套框架,单看它什么都干不了,但每个_process_xxx函数里填入具体逻辑,整套AI就活了。而且你永远不会在攻击逻辑里看到待机相关代码,维护性天差地别。
3. 实操过程与核心环节实现
3.1 先做待机和巡逻:让敌人"活"起来
先从最简单的待机做起。待机不需要做什么复杂操作,播放一个待机动画,停留1.5秒到2秒,然后切换到巡逻。巡逻则是让敌人按照固定路线来回走,倒不是说一定要一条路线,简单做法是让敌人朝某个方向走,碰到墙壁或者走到边界就转向。
_process_patrol的核心长这样:
func _process_patrol(delta: float): velocity = Vector2.ZERO velocity.x = patrol_direction * patrol_speed move_and_slide() # 碰到障碍物时转向 if is_on_wall(): patrol_direction *= -1 sprite.flip_h = (patrol_direction < 0)这里有个地方容易踩坑:patrol_direction如果写成1和-1来回切换,敌人会在原地反复横跳,因为碰到墙壁后转向,下一帧又撞到同一面墙,结果就是方向来回翻转、角色原地抖动。我的建议是,转向之后加一个"转向缓冲"计时器,0.1秒内不允许再次转向,实测下来稳定很多。
待机切换巡逻的条件用计时器:待机计时结束就切巡逻。巡逻切换待机可以用路径走到尽头、巡逻时长限制等,都行。核心是把计时器做一个统一的_update_timer函数,方便所有状态复用。
3.2 追击与攻击的实现
追击的核心逻辑是获取玩家方向,朝玩家移动。代码并不复杂:
func _process_chase(delta: float): if not _contains_player(): _change_state(State.PATROL) return var direction = (player.global_position - global_position).normalized() velocity = direction * chase_speed move_and_slide() sprite.flip_h = (direction.x < 0) if global_position.distance_to(player.global_position) <= attack_range: _change_state(State.ATTACK)_contains_player()里做得是视野判断:一是直线距离,二是角度和遮挡。最简单的遮挡检测用RayCast2D,射线一端挂在敌人身上,目标方向指向玩家,如果射线命中了碰撞体且碰撞体是玩家,说明视野没有被挡住。需要注意RayCast2D的碰撞掩码要和玩家所在的层对应,否则射线会穿过玩家直接打在后面的墙上。
攻击状态需要注意:不要在_physics_process里直接改玩家血量。更好的做法是,在攻击动画的关键帧上调用一个hit信号,或者使用AnimationPlayer的动画事件调用_apply_damage()。这样玩家能通过翻滚或走位躲开攻击判定,打击感也会更好。具体做法是给攻击动画添加一个回调轨迹,在动画帧上插入emit_signal("attack_hit"),主控脚本里连接这个信号处理伤害。
攻击范围用Area2D比用距离计算更精准。我之前做近战敌人,直接算distance_to,经常出现敌人手还没抬起来,玩家已经开始掉血的情况。后来改成在攻击动画播放到中间位置时,临时启用一个AttackArea,帧结束就禁用,打击感立刻上来了。
3.3 受击与死亡:把反馈做扎实
受击状态是一个"打断逻辑"。玩家攻击打到敌人,需要立刻终止当前动作,播放受击动画并产生小小的击退。击退不能用move_and_slide一直推,那样敌人会被推着滑过整个地板。一般做法是记录击退初速度,在0.15到0.2秒内把速度衰减到零。
func _enter_state(state: State): match state: State.HURT: animation_player.play("hurt") velocity = knockback_vector * 120.0 # 通过计时器在0.2秒后结束受击状态 func _process_hurt(delta: float): velocity = velocity.move_toward(Vector2.ZERO, delta * 800.0) move_and_slide()死亡状态处理上,有一个细节要提醒:敌人死亡后不要立刻queue_free(),至少等死亡动画播完。可以在死亡动画播放时把碰撞全部禁用,然后使用animation_player的animation_finished信号做延迟删除。否则你会在敌人倒地那一瞬间看到它凭空消失,非常廉价。
3.4 把六个状态串起来的状态机主逻辑
很多同学学到这总会有个疑问:状态和状态之间的切换条件到底应该放在哪里?放在_process_chase里,还是放在_physics_process统一判断?我的做法是:切换条件尽量放在各个_process_xxx函数内部,因为每个状态最清楚自己什么时候该结束。待机自己决定追不追,追击自己决定打不打,受击自己决定结束后去哪,这样的代码可读性最好。
需要额外处理的是全局异常情况,比如敌人正在攻击时突然被玩家打死。所以我在_physics_process里加了一个血量判断,血量归零强制切死亡状态,这个逻辑独立优先于任何状态。
func _physics_process(delta): if current_state != State.DEAD and health <= 0: _change_state(State.DEAD) return _state_process(current_state, delta) _update_timer(delta)我见过不少团队在这里纠结:死亡判断放在状态内部会漏掉,比如攻击动画播完才检查血量,那玩家打完最后一击,敌人还得把攻击动作耍完了才肯死,观感很差。采用这种全局优先判断,虽然写法上"不优雅",但实际效果最可靠。
4. 常见问题与排查技巧实录
4.1 环境问题:Godot下载打不开和游戏乱码
每次发教程,留言区总能看到这类问题,确实很影响新手开局,就放到这期一起说说。
Godot下载打不开,绝大多数情况不是你电脑的问题,而是你下错了版本。Godot官方提供了标准版和Mono版,Mono版自带.NET支持,如果你装了别的版本的.NET或者干脆没装,双击就是打不开。标准版一般不会出现打不开的情况,除非你下载的是2.x或3.x的老版本,在新系统上偶尔会有兼容问题。我的建议是:新项目直接去官网下4.x标准版,压缩包解压后直接运行exe,不要放在带中文路径的目录里,这个坑我踩得很深,中文路径偶尔会有奇怪问题。
游戏乱码问题则分两种。第一种是脚本编辑器里的中文注释显示成乱码,这种其实是文件编码问题,Godot 4.x默认用UTF-8,如果你用旧版编辑器或者Windows记事本保存过文件,编码不一致就会乱码。第二种是游戏运行时界面文字显示成方块或不正常,这通常是你没有给Label等界面控件配置支持中文的字体。Godot默认字体不覆盖全部中文字符,你需要导入一个中文字体文件,然后在Theme里全局设置默认字体。
4.2 状态机最容易踩的四个坑
状态切不回去。处理方式可以画一张状态转换表,类似思维导图,每次改动前先检查这张表,确保每个状态都有出去的路径。比如战斗中玩家藏起来了,追击状态最好设置一个"丢失目标计时器",超过3秒没有看到玩家就回巡逻,否则敌人就会卡在追击状态里,永远追不上但也永远不死心。
动画衔接生硬或者播不出来。最常见的原因是AnimationPlayer里动画名字和代码字符串对不上。Godot的动画名区分大小写,"Run"和"run"是两个名字。你核对代码无误的情况下动画还在发病,请把AnimationPlayer面板打开检查一下动画列表,另外play("idle")这类调用如果动画名不存在,控制台会出现错误提示,留意一下输出面板。
物理层碰撞不生效。这个问题特别隐蔽。你以为敌人该检测到玩家,但实际上检测不到,先别急着怀疑代码逻辑,看看两个对象的collision_layer和collision_mask。上帝视角检查一遍:敌人的碰撞层指向玩家所在层,玩家也指向敌人所在层,两个层必须匹配。我遇到过一次很煎熬的排查,代码逻辑完全正确,最后发现是玩家的collision_layer设成了2,而敌人RayCast只看layer1,差了这一个数字,白白浪费一晚上。
状态切换过于频繁。之前提过的抽搐问题,再补充一句:除了切换冷却,还有一个原因是input事件和physics事件混用导致的。如果你用_input里的按键信号去切状态,又用_physics_process里的物理判断去切状态,两套逻辑就会互相打架。统一用一个入口,比如全局只有一个ProcessState函数,所有切换都走它,就不会乱。
4.3 调试和优化技巧
状态机调试有个独门技巧:做一个简单的调试面板,把当前状态实时显示在屏幕上。我在开发时会在敌人头顶上方加一个Label,用代码实时刷新:
@onready var debug_label: Label = $DebugLabel func _process(_delta): debug_label.text = State.keys()[current_state]调试结束后,把这个Label隐藏或删掉就行。有这行显示,你在游戏里就能直观看到敌人脑子里的想法:它在发呆、在巡逻、还是在追你。状态切换异常时一眼就能盯出问题在哪,比猜逻辑快十倍。
性能方面,做大量敌人时要注意射线检测频率。如果场上同时有20个敌人,每个敌人每帧都发射RayCast,还是有一定压力的。可以把检测间隔调大,比如0.2秒检测一次视野,而不是每帧都做。这对我们这种关注2D小游戏的作者来说,是一个性价比非常高的小优化,效果几乎无感,但帧数能很直观地保持稳定。
5. 扩展思路:这套状态机还能往哪走
这套敌人AI框架稳定跑起来之后,继续往上的方向有很多。做Boss战,可以在现有状态里增加阶段切换,比如血量降到一半,追加一个"狂暴"状态,移动速度翻倍,攻击方式增加新的远程技能。做队友或者NPC,也可以套用类似逻辑,把追击状态换成"跟随"状态,把攻击换成对话触发。
还有一个很实用的扩展:做一个通用状态机组件,把敌人和玩家共用的部分抽象出来。比如玩家也有行走、跳跃、攻击、受击这些状态,底层机制完全一致,只是触发的条件和移动参数不同。把状态节点化,用状态节点替代枚举,你甚至可以在编辑器中拖拽连线来配置敌人AI,是更强大的方向。不过那套东西复杂了不少,先把今天这套吃透再去折腾,会比较从容。
按照我个人经验来说,最值得投入的不是代码架构本身,而是花时间打磨每个状态之间的"手感"。攻击范围的数值、攻击动画延迟生效的帧位置、敌人追击速度与玩家速度的差值,这些参数对游戏体验的影响远大于状态机写得好不好。你可以在测试后把数值记录下来慢慢调整,我在实际开发中会记录每一版速度调整后的手感变化,时间长了这会成为你最宝贵的游戏设计资料库。
这期内容就到这里,代码框架可以直接抄回去改。下一篇我会做一个小小的实战案例,把今天这套AI放进一个完整的关卡里,和主角的战斗系统串起来跑通整个流程。有问题评论区见,看到都会回。