
1. 添加敌人的整体思路与选型到了第7节这个阶段我们的Godot游戏练习项目应该已经具备了基本的地形场景、玩家角色的移动控制和简单的交互逻辑了。接下来要给游戏注入真正的内容——敌人。这个环节做得好不好直接决定了游戏是“一个可以走动的场景”还是“一款能玩的游戏”。先说说我个人对敌人系统的理解。很多新手学到这里第一反应是“敌人不就是把玩家的代码改一改吗”这个想法不能说全错但在实际做项目的时候很快就会发现敌人的行为逻辑远不只是“移动”这么简单。敌人要有巡逻、追击、攻击、受击、死亡这些状态要考虑怎么跟玩家碰撞、怎么处理伤害数值、怎么播放动画切换还要考虑敌人之间怎么避免重叠。如果一开始不把架构想清楚后面加新敌人类型的时候会非常痛苦。我在Godot 4.x版本里做敌人一般会先问自己三个问题这个敌人是近战型还是远程型它需不需要处理地形碰撞还是可以在空中飞行它的生成方式是固定场景摆好的还是运行时用代码刷出来的这三个问题决定了敌人用哪种基础节点。如果是地面近战型我建议直接用CharacterBody2D它自带的move_and_slide()可以省掉一大部分碰撞处理的麻烦。如果是飞行巡逻型用Area2D或者RigidBody2D会更灵活。不过对于练习项目来说我通常推荐从CharacterBody2D起步等摸透了这套逻辑之后再根据自己的需求换节点类型也很容易。这个练习里我们要做的敌人我按最常见的“巡逻-追击-攻击”三态行为来设计用Godot 4.6.3版本做演示。之所以选这个版本是因为它当前的动画播放器接口和物理系统API都比较成熟而且导出模板在项目Build时也很稳定不像早期4.x版本偶尔会出现编辑器崩溃这种问题。我实际写代码的习惯是先把敌人的“最小可用版本”跑通也就是不会动的、站桩的敌人先确认碰撞和受击链路是通的然后再逐步加巡逻逻辑、追击逻辑和攻击逻辑。这样可以保证每加一个功能都能立刻在编辑器里验证排查问题的时候也不会像无头苍蝇一样到处乱找。2. 敌人场景的搭建与节点结构设计2.1 敌人场景的节点树结构打开Godot编辑器在FileSystem面板里新建一个enemy.tscn场景根节点选CharacterBody2D名字改成Enemy。接下来按照下面的结构把节点搭起来Enemy (CharacterBody2D) ├── CollisionShape2D (矩形碰撞体) ├── Sprite2D (静态精灵或AnimatedSprite2D) ├── AnimationPlayer (状态动画) └── AttackArea (Area2D) └── CollisionShape2D (攻击判定范围)这个结构里其实涉及了一个很重要的设计思路敌人的身体碰撞和攻击判定区域是分开的。身体碰撞用CharacterBody2D主节点内置的CollisionShape2D负责跟墙体、玩家身体发生物理碰撞而攻击区域用子节点Area2D它不参与物理反弹只负责检测“谁进入了我的攻击范围”这样伤害判定就不会把敌人自己撞飞出去也不会出现敌人攻击玩家时把自己卡住的情况。Sprite2D我这里先放一张简单的占位贴图比如一个红色的方块或者圆形的png。练习阶段不必追求美术资源但是建议把贴图中心点Offset对准原点后面做旋转和翻转动画的时候就不会出现位置偏移的怪问题。2.2 碰撞层的规划设计这是很多教程不会专门讲、但实际项目里特别坑的地方。Godot的物理碰撞靠的是Layer和Mask两层配置Layer表示“我在第几层”Mask表示“我能检测到哪些层”。我建议养成一个习惯在项目一开始就规划好层数。比如:第1层世界环境地面、墙体第2层玩家第3层敌人第4层玩家攻击第5层敌人攻击对应到我们当前这个敌人节点Enemy的CollisionShape2D应该设置Layer为第3层Mask设置成第1层和第2层意思是“我只撞世界环境和玩家不跟其他敌人发生碰撞”。这样可以避免多个敌人堆在一起的时候互相推挤看起来像在排队。AttackArea这个子节点就特殊一点Layer不需要设置设为0但Mask要设成第2层意思是“只管检测玩家不关心其他东西”。我早期做项目的时候不重视层设置全部都用默认的第1层结果敌人撞敌人、攻击范围连地面都能触发调试了一整天才发现是层配置的问题。所以在这里专门提醒一下层规划一定要提前想清楚。2.3 动画的简易处理方案动画这块如果是美术资源没有到位的情况我推荐先不引入AnimatedSprite2D直接用Sprite2D加AnimationPlayer来实现“转向”和“受伤闪白”两个效果就够用了。等素材齐全了再替换成帧动画逻辑层面不用改动太多。具体做法是在AnimationPlayer里创建两个动画一个叫idle一个叫hurt。idle里做个简单的上下浮动或者左右摇摆的位移关键帧让敌人看起来有点活性hurt里把Sprite2D的Modulate颜色瞬间变为白色再恢复持续时间控制在0.1秒左右用来表现受击反馈。如果后面拿到了真正的帧动画资源就把Sprite2D替换成AnimatedSprite2D并给它的animation属性赋值对应的动画名原来代码里的播放调用方式基本不用大改。3. 敌人核心逻辑脚本的思路与实现3.1 状态机的代码框架敌人的AI我建议直接用最简单的状态机来实现不引入额外的插件和框架。状态机的核心就是“当前处于什么状态、应该执行什么逻辑、满足什么条件切换到下一个状态”。我们设定三个状态Patrol在指定范围内来回巡逻Chase发现玩家追击Attack进入攻击范围发动攻击在Godot 4的GDScript里可以用enum枚举来定义状态配合match语句分支处理。这是最直观的写法调试的时候也方便打印当前状态。extends CharacterBody2D enum State { PATROL, CHASE, ATTACK } export var move_speed : float 80.0 export var chase_speed : float 140.0 export var patrol_left_limit : float 80.0 export var patrol_right_limit : float 200.0 export var attack_range : float 40.0 export var attack_damage : int 10 export var attack_cooldown : float 1.0 var current_state : State State.PATROL var player : Node2D var patrol_target_x : float var attack_timer : float 0.0 func _ready(): patrol_target_x global_position.x patrol_right_limit player get_tree().get_first_node_in_group(player)这里我把player通过get_tree().get_first_node_in_group(player)的方式获取前提是玩家的根节点已经加入player组。这个方案比在编辑器里手动拖拽引用更灵活因为敌人如果是运行时生成的场景引用就不好提前指定用分组可以在代码里动态找到目标。3.2 巡逻与追击的逻辑实现_physics_process是Godot物理帧回调每帧固定时间调用适合做跟物理碰撞相关的一切逻辑。我们把状态机的判断和速度计算都放在这里。func _physics_process(delta): match current_state: State.PATROL: patrol_behavior(delta) State.CHASE: chase_behavior(delta) State.ATTACK: attack_behavior(delta) move_and_slide() func patrol_behavior(delta): if player global_position.distance_to(player.global_position) 200.0: current_state State.CHASE return var direction 0.0 if global_position.x patrol_target_x: direction -1.0 elif global_position.x patrol_start_x: direction 1.0 if absf(global_position.x - patrol_target_x) 2.0: patrol_target_x global_position.x - patrol_left_limit if patrol_target_x global_position.x else global_position.x patrol_right_limit velocity.x direction * move_speed velocity.y 0.0 flip_sprite(direction)这里有一个我走过的弯路要提醒很多人会把“到达边界就反转”写成if global_position.x patrol_target_x但如果敌人的移动速度很快一帧之内可能越过边界好几像素这样判断会漏掉那个临界帧敌人就会一直往一个方向冲。所以建议用绝对值差小于一个小阈值的写法比如上面代码里absf(...) 2.0这样更稳健。flip_sprite函数很简单其实就是根据朝向设置Sprite2D的flip_h属性。这样玩家看到敌人转向的时候贴图会左右镜像而不是一直朝着一个方向脸朝右。func flip_sprite(direction): if direction 0: $Sprite2D.flip_h false elif direction 0: $Sprite2D.flip_h true追击逻辑稍微复杂一点不仅要在水平方向追踪玩家还要判断玩家是否超出追踪范围。超过了一定距离之后敌人就放弃追击回到巡逻状态。func chase_behavior(delta): if !player: current_state State.PATROL return var dist global_position.distance_to(player.global_position) if dist 250.0: current_state State.PATROL patrol_target_x global_position.x return if dist attack_range: current_state State.ATTACK velocity Vector2.ZERO return var direction sign(player.global_position.x - global_position.x) velocity.x direction * chase_speed flip_sprite(direction)追击的判断里我用了一个“离开范围250”来重置状态这个值和进入追击的200预留了一点滞回空间。为什么要有这段滞回因为如果进追击和出追击的距离阈值完全相同玩家站在边界附近来回晃动敌人就会频繁地在巡逻和追击之间切换形成震颤看起来很傻。差个50像素的距离这个现象就基本消失了。3.3 攻击行为与冷却机制攻击行为在这个demo里我选择用“近战撞击”的方式也就是说敌人进入攻击范围后停止移动等待一个攻击冷却计时器每次冷却结束就对玩家造成一次伤害同时播放一个攻击动画。func attack_behavior(delta): if !player: current_state State.PATROL return if global_position.distance_to(player.global_position) attack_range 10.0: current_state State.CHASE return velocity Vector2.ZERO attack_timer - delta if attack_timer 0.0: attack_timer attack_cooldown perform_attack() func perform_attack(): $AnimationPlayer.play(attack) if player.has_method(take_damage): player.take_damage(attack_damage)冷却时间的控制方式是用一个浮点计时器在attack_behavior里每帧递减归零后执行攻击并重置。这个方案的好处是逻辑集中、容易加减速不用额外创建Timer节点在代码里改起来也很直观。player.take_damage(attack_damage)这里用了鸭子类型判断先检查玩家是否有take_damage方法再调用。这样做的好处是敌人不用知道玩家具体是什么类型只要实现了这个接口就能被打以后加个可以被打的NPC或者箱子也能复用同样的攻击逻辑。3.4 受击与死亡状态的处理敌人的生命值和受击逻辑我在这个练习里选择放在敌人脚本自身这样每个敌人实例可以有不同的血量以后扩展出“精英敌人血量500”“普通敌人血量50”这种设定也很方便。export var max_health : int 30 var health : int func _ready(): health max_health func take_damage(amount : int): health - amount $AnimationPlayer.play(hurt) if health 0: die() func die(): set_physics_process(false) $CollisionShape2D.set_deferred(disabled, true) $AttackArea/CollisionShape2D.set_deferred(disabled, true) # 这里是播放死亡动画后释放练习阶段直接淡出 var tween create_tween() tween.tween_property(self, modulate:a, 0.0, 0.4) tween.tween_callback(queue_free)死亡处理里面有几处细节我觉得比代码逻辑本身更重要首先set_physics_process(false)之后_physics_process就不会再执行了敌人停止一切行为。然后碰撞体禁用这样敌人死亡后不会挡住玩家走路。这里有一个Godot的经典坑在物理回调里直接改disabled属性会报错因为物理状态正在计算中所以必须用set_deferred(disabled, true)来延迟到物理帧结束后再改。其次我用create_tween()做了一个0.4秒的透明度渐隐动画而不是立刻queue_free()。这样玩家能看到敌人“死亡消失”的反馈而不是瞬间蒸发手感上会好很多。如果你有死亡动画资源把这段Tween替换成播放动画就OK然后在动画结束信号里再释放节点。4. 与玩家的交互和信号体系搭建4.1 玩家攻击时的伤害传输链路敌人的受击方法写好了接下来就得让玩家的攻击能真正打中敌人。这个过程有两种常见做法方法一玩家在攻击时主动检测攻击范围内哪些敌人然后调用每个敌人的take_damage方法二玩家攻击时生成一个临时Area2D靠物理引擎的碰撞信号来通知敌人扣血我比较推荐方法二因为它的解耦性更好。玩家的攻击范围用Area2D子节点表达不用在玩家脚本里写遍历所有敌人的逻辑敌人也不需要知道自己是怎么被打中的物理引擎会自动帮你完成检测。以玩家点击鼠标左键触发的近战攻击为例onready var attack_area : Area2D $AttackArea func _unhandled_input(event): if event.is_action_pressed(attack): attack_area.monitoring true $AttackTimer.start(0.15) func _on_attack_timer_timeout(): attack_area.monitoring false这里有个值得学习的细节把AttackArea的monitoring属性在攻击时置为true攻击结束后再置回false这样攻击判定只在一个极短的时间窗内生效不会出现“玩家站在敌人旁边自动每秒扣血”的BUG。时间窗0.15秒是我个人做手感的时候反复调下来的数值短了判定容易漏长了显得攻击很迟钝你可以根据自己的游戏节奏微调。4.2 用Area2D的信号来检测命中在AttackArea的脚本里我们需要连接body_entered信号。这个信号会在某个带有CharacterBody2D的物理体进入区域时触发。extends Area2D func _ready(): body_entered.connect(_on_body_entered) func _on_body_entered(body): if body.has_method(take_damage) body ! get_parent(): body.take_damage(10)这里body ! get_parent()是为了排除玩家自身因为攻击区域是挂在玩家身上的子节点如果不排除的话玩家自己走进自己的攻击范围也会掉血。值得一提的坑是body_entered信号只在“进入”那一刻触发一次如果你想要攻击持续命中需要用body_exited配合overlaps_body()轮询或者靠玩家跟敌人保持接触时的周期性计时器。我们在前面做的敌人攻击逻辑就是靠计时器循环触发的模式玩家侧其实也可以借鉴。只不过对于最常见的“挥一下剑造成一次伤害”这种需求单次进入检测已经够了。4.3 敌人掉落物与游戏节奏控制这个练习阶段我还没有急着做非常复杂的掉落系统但我会给敌人预留一个掉落接口。在die()函数里加一行func die(): # ... 原有逻辑 drop_item() func drop_item(): var item_scene preload(res://scenes/coin.tscn) if item_scene: var item item_scene.instantiate() get_parent().add_child(item) item.global_position global_position这个掉落接口理论上可以掉落金币、血包、弹药等任意东西。做掉落的时候有一点经验要分享不要用敌人自身作为掉落物的父节点来add_child因为敌人马上会queue_free而queue_free会连子节点一起释放掉落物会瞬间消失。正确做法是把掉落物添加到敌人的父节点也就是当前场景层。5. 常见问题与排查技巧实录5.1 敌人不会动或者移动方向不对这个是最容易出现的问题之一通常出在坐标参考系上。我见过不少初学者把patrol_target_x设成固定坐标值比如1000然后敌人在左下角出生跑来跑去都没法归位。这里的核心理解是巡逻边界最好是相对出生点算出来的也就是在_ready()里用global_position.x 偏移量来初始化。另一个很隐蔽的问题是敌人节点本身加了旋转或者Sprite的偏移没居中移动起来看起来像在“绕圈”或者“漂移”。解决办法是检查贴图偏移和节点朝向确保运动方向跟视觉方向一致。5.2 攻击检测重复触发我之前提过body_entered本身不会重复触发但如果攻击区域一直开启没有在攻击后关闭monitoring那么敌人进入区域后会收到一次伤害然后因为碰撞体还在区域内信号不会再次触发玩家会觉得伤害不稳定。解决方案就是把monitoring当做一个“攻击开关”来用只在确定的时间窗口内开启。如果你需要持续的范围伤害比如火墙、毒雾那可以考虑用overlaps_body()方法在_physics_process里每帧检测加上自己的伤害间隔计时器。5.3 敌人重叠和卡位如果多个敌人同时追击玩家很容易出现两个敌人在窄通道里互相卡住的问题。根因是碰撞层设置成了互相碰撞。之前我们在设计层的时候把敌人物体设置在Layer 3、Mask只包含1和2就已经避免了敌人之间的碰撞。但如果你就是想让敌人之间也有物理体积那也可以接受它们互相挡路只是AI复杂度会提升——需要加绕路逻辑或者推开机制。我个人的建议是练习阶段就让敌人之间不碰撞因为它们没有做复杂寻路硬碰硬只会让集群看起来特别笨。等项目做到“成群敌人围着玩家”这种场景时再引入分离力或者Steering Behavior也来得及。5.4 导出打包后敌人消失在编辑器里运行一切正常但Build导出后敌人完全消失了。这个大概率不是脚本逻辑的问题而是导出模板缺少了资源引用或者脚本里的preload路径写错了。Godot 4.x里有一个比较常见的坑如果你在编辑器里创建文件之后又重命名过场景或脚本文件旧的.uid文件可能还残留着导致资源引用出现偏差。解决办法是在FileSystem面板里右键点击项目根目录选“Scan for Changes”必要时可以删掉.godot文件夹重新导入但注意删掉后编辑器需要完整导入一次时间有点久。另外最新版本导出模板的安装路径如果不对Build的时候会提示找不到导出模板。Godot 4.6.3的导出模板在编辑器的“Editor - Manage Export Templates”里下载后会自动放到用户目录下的Godot文件夹中。如果你用的笔记本电脑有多个磁盘或者设置了系统环境变量有时候安装位置不统一也会有找不到模板的情况建议直接重新下载安装一次很省事。5.5 我调试敌人行为时的套路总结最后分享一套我个人调试敌人AI的实用套路第一先把move_speed和chase_speed调到很小的值比如20方便观察移动轨迹。第二在编辑器右上角的Debug菜单里打开“Visible Collision Shapes”把碰撞体可视化显示出来这样能直接看到碰撞区域的位置和大小。第三在_physics_process里临时加上一行print(current_state)跑起来看控制台的状态切换就能非常清晰地看出敌人是在哪个状态、哪一行代码出了问题。调试完再把这些临时代码注释掉保持项目整洁。这个方法有效到我现在做复杂AI也在沿用先跑通再优化效率最高。6. 敌人开发中的性能与扩展性思考先说实话一个练习项目谈性能优化有点早。但我觉得“用正确的姿势做练习”这件事本身是被低估的。很多人在练习阶段随手糊代码等到项目大了再后悔重构困难这是最常见的学习路线翻车点。性能层面最重要的一件事是控制_physics_process里做运算的频率。敌人多的时候比如50个敌人同时跑_physics_process每帧都调用distance_to来做距离判断这个运算量其实还好现代电脑完全带得动。但如果每个敌人每帧都要用get_tree().get_first_node_in_group(player)去找玩家性能消耗就会明显上升因为get_first_node_in_group要遍历场景树。正确做法是_ready时缓存一次玩家的引用后续就复用这个变量。扩展性层面我建议把敌人的属性和AI行为尽量做成export可配置的。就像我们代码里已经写好的move_speed、chase_speed、patrol_left_limit这些变量全部暴露在Inspector面板上以后做不同种类的敌人只要复制场景、填不同参数就行不用改任何代码。如果想让同一种敌人有不同的攻击方式就把perform_attack做成虚函数在子类里override这是GDScript继承机制很擅长的事情。说一个更实际的场景假设你做到第10节的时候需要新增一个从空中俯冲偷袭的飞行敌人。这时候如果你是按我们这套结构写的只需要新建一个场景继承或者复制敌人逻辑把移动方式从水平改成抛物线加一条新的ATTACK分支就完事了。但如果一开始把所有逻辑都堆在玩家脚本里那扩展一个新敌人类型就得重写小半套代码体验非常糟糕。Godot的节点树和继承体系本身就是个“搭积木”的过程我们在第一小节设计的Enemy根节点加上AttackArea子节点的结构是很多游戏敌人的通用骨架。做到心中有这个结构后面加东西就不会乱七八糟。我自己的习惯是每完成一个阶段的敌人功能就会在项目里用一个专门的TestLevel场景去验证所有敌人的行为这样不会污染主关卡。这个测试场景放一堵墙、一个玩家出生点、几个不同类型的敌人跑起来看行为是否正常。第7节的内容这个场景刚够我摆了一个敌人做巡逻测试又摆了一个敌人专门测试受击扣血效果还是蛮直观的。