ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Godot 4.x敌人AI状态机教程:从零搭建可扩展的NPC行为框架

Godot 4.x敌人AI状态机教程:从零搭建可扩展的NPC行为框架 这期是系列二的第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放进一个完整的关卡里和主角的战斗系统串起来跑通整个流程。有问题评论区见看到都会回。
返回列表