把 godot2D游戏教程系列二(11)做到这一篇,其实已经进入一个很有意思的阶段。前面若干篇把场景、角色、输入、动画、碰撞这些基础零件都搭得差不多了,玩家手里已经有一个能跑、能跳、能攻击的“角色原型”,接下来真正考验人的不是“某个功能怎么做”,而是“我手上这些零件怎么拼成一个耐玩的2D关卡”。所以这一篇我不打算再单独讲某个孤立的节点或动画,而是把敌人AI、平台布局、关卡流程和玩家死亡重试全部塞进一个完整的示例关卡里,让你做完之后直接得到一个可以反复游玩的2D平台游戏雏形。
这个定位适合两类人:第一种是从系列一、系列二一路跟过来的新手,你已经会用TileMap画地图、会用CharacterBody2D写移动,迫切需要一个把知识串起来的整合例子;第二种是有点经验但总觉得自己做的demo“不像游戏”的开发者,其实你缺的往往不是功能,而是敌人行为逻辑和关卡节奏的设计方法。这一篇会同时照顾两边的需求,每个节点、每段脚本我都会解释“为什么这么写”,而不是只丢一段代码让你抄。
1. 本期内容从哪切入:第11篇到底在解决什么问题
1.1 为什么这个阶段适合做敌人AI与关卡整合
我在很多地方看到有人问“教程我都照着做了,为什么自己的游戏还是不好玩?”答案十有八九不是贴图问题,也不是音乐问题,而是关卡里没有任何东西在“回应”玩家。一个只会左右移动的敌人、一个能让玩家掉下去就重来的坑,就已经能让原本平静的demo产生紧张感。这正是系列二后半段的价值:抛开基础功能,转向游戏性构建。
按照我的习惯,第11篇是系列二里“从功能到系统”的转折点。前半篇我会用状态机实现一套完整的敌人AI,包含巡逻、追击、攻击、受击、死亡五种行为;后半篇会把敌人布置到用TileMap搭建的关卡里,再加上存档点、收集品和死亡重试UI,最终形成一个闭环。做完这一篇,你会得到一个可以不断往里面加新内容的框架,而不是一坨只能演示的散装场景。
1.2 完成后的效果与技能树
当你把本篇示例完整做下来,会得到下面这些能力:
- 用State(状态)模式管理敌人行为,不用再写一坨几百行的if分支
- 理解PathFollow2D、Area2D、RayCast2D在敌人AI中的分工
- 知道怎么用TileMap按区块规划关卡难度,而不是随手乱画
- 会处理玩家受击无敌帧、敌人死亡掉落、关卡重载这些“手感细节”
- 面对“敌人穿墙”“玩家连续扣血”“关卡重载后敌人复活在错误位置”这类问题能自己排查
先说清楚,本篇不涉及复杂的光照、音效动态混音或存档加密那些后期话题。那些属于系列后半程的内容。这一篇的核心就是两个字:整合。把已经学过的东西真正放到一个运行流程里,并且保证它好玩、好调试、好扩展。
2. 从敌人开始:用状态机管好2D AI行为逻辑
2.1 为什么2D敌人适合状态机而不是复杂的决策树
2D平台游戏的敌人通常不需要自动驾驶那种复杂决策,它们的智力水平基本是“看到玩家就追,追近了就打,打完了歇一会”这种量级。面对这种需求,最适合的骨架就是状态机。你可以把状态机理解成一个人在不同处境下的行为切换:在走廊里巡逻的人是巡逻状态,看到可疑目标切到追击状态,距离足够近进入攻击状态,被扇了一巴掌进入受击状态。每个状态只管自己的事,代码自然就清晰了。
用GDScript实现状态机有很多种姿势,我推荐最朴素的“枚举+match分支”方式,因为2D敌人的状态数量通常不会超过六七个,搞复杂的状态管理框架反而增加阅读成本。下面是枚举定义:
enum State { IDLE, # 待机 PATROL, # 巡逻 CHASE, # 追击 ATTACK, # 攻击 HURT, # 受击 DEAD # 死亡 } var current_state: State = State.IDLE我见过有些人用@export状态名加字符串比对,也能跑,但枚举的好处是编译期能帮你检查拼写错误,性能也更好。之后在_physics_process里每帧调用一个处理函数,按当前状态分流即可。
2.2 最简单的巡逻方案
巡逻是大多数地面敌人的基础状态。最简单可靠的做法不是用PathFollow2D,而是“直线移动+边缘检测”。先让敌人以固定速度朝一个方向移动,然后朝移动方向的地面下方打一条短射线,如果射线检测不到地面,说明走到边缘了,就转向。
具体实现我在敌人根节点下挂了三个子节点:CharacterBody2D、RayCast2D(朝下检测地面)、Area2D(检测玩家进入范围)。
@export var patrol_speed := 80.0 @onready var ray_edge: RayCast2D = $EdgeRay func _physics_process(delta): match current_state: State.PATROL: velocity.x = patrol_direction * patrol_speed if not ray_edge.is_colliding(): flip_direction() move_and_slide()ray_edge的配置比较关键。它的position要设置在角色脚部略靠移动方向的一侧,target_position设置为Vector2(0, 24)这样的长度,大约一个瓷砖高度再加一点余量。这里有个容易踩的坑:射线如果只检测正下方,敌人走到两个平台间距较大的地方还是会掉下去;所以建议同时在前进方向侧前方加一条水平射线,用来检测墙壁,碰到墙也转向。
很多新手问我为什么不用PathFollow2D做巡逻,我的回答是:PathFollow2D适合飞行物、虫子、圆规式移动的敌人,它需要你手工铺设路径,敌人就被锁死在路线上;而射线检测让敌人能和关卡地形自然互动,遇到坑会回头,遇到墙也会回头,使用体验更接近“活物”。如果你的敌人是螃蟹或者史莱姆,用射线方案明显更合适。
2.3 追击与攻击状态:让敌人会“追”而不是“粘”
玩家进入Area2D检测范围后,敌人从巡逻切到追击。关键是追击速度不要设置得和玩家移动速度一样快,否则玩家永远甩不掉敌人,挫败感极强。我的经验是:追击速度是玩家最大水平速度的60%到75%,留给玩家反应的余地。
State.CHASE: var dir = sign(player.global_position.x - global_position.x) velocity.x = dir * chase_speed apply_gravity(delta) move_and_slide()攻击状态也不复杂,关键是冷却。不设置冷却的敌人会变成机关枪,玩家只要站在旁边就被连续判定,游戏直接没法玩。我在敌人身上放了一个Timer节点用于攻击冷却,冷却结束才能再次攻击,同时攻击时播放动画并产生伤害判定区域。这里给一个更完整的敌人脚本骨架,这部分建议直接抄:
extends CharacterBody2D enum State { IDLE, PATROL, CHASE, ATTACK, HURT, DEAD } @export var patrol_speed := 80.0 @export var chase_speed := 140.0 @export var attack_cooldown := 1.5 @export var max_hp := 3 var current_state: State = State.IDLE var patrol_direction := 1 var hp: int @onready var chase_area: Area2D = $ChaseArea @onready var attack_area: Area2D = $AttackArea @onready var edge_ray: RayCast2D = $EdgeRay @onready var wall_ray: RayCast2D = $WallRay @onready var anim: AnimatedSprite2D = $AnimatedSprite2D @onready var attack_timer: Timer = $AttackTimer func _ready(): hp = max_hp current_state = State.PATROL chase_area.body_entered.connect(_on_chase_area_body_entered) attack_area.body_entered.connect(_on_attack_area_body_entered) func _physics_process(delta): match current_state: State.PATROL: velocity.x = patrol_direction * patrol_speed if not edge_ray.is_colliding() or wall_ray.is_colliding(): flip_direction() move_and_slide() State.CHASE: var dir = sign(PlayerManager.player.global_position.x - global_position.x) velocity.x = dir * chase_speed if abs(PlayerManager.player.global_position.x - global_position.x) > chase_area.width: current_state = State.PATROL move_and_slide() State.ATTACK: velocity.x = 0 anim.play("attack")AttackArea的body_entered信号只在碰撞进入瞬间触发一次,适合做攻击判定。至于玩家是否掉血,建议由玩家脚本自行判断,敌人节点不要直接改玩家血量,这样解耦更干净。我会在玩家脚本里提供一个take_damage(amount)方法,敌人只需要触发攻击动画并调用这个方法。
2.4 受击、死亡与掉落实物的正确姿势
受击状态是整个AI里最容易被忽略却最影响手感的部分。敌人被玩家击中后如果没有一点点停顿和击退,看起来就像在打空气。我的做法是:进入HURT状态后设置一个0.15秒到0.25秒的受击计时器,期间敌人不受控制、不检测碰撞,同时给一个朝向玩家相反方向的击退速度,然后切回CHASE或PATROL。
func take_damage(damage_amount: int): if current_state == State.DEAD: return hp -= damage_amount if hp <= 0: current_state = State.DEAD play_dead_effects() return current_state = State.HURT hurt_timer.start() velocity.x = -sign(PlayerManager.player.global_position.x - global_position.x) * 120死亡这里有个小技巧:不要立刻queue_free,而是先播放一个死亡动画、禁用碰撞、让尸体存在0.8秒左右。否则在玩家看来敌人是“瞬间消失”,很廉价。死亡掉落物可以用一个简单的DropManager单例来管理,敌人被击杀时调用它生成金币或回复道具。这个单例可以沿用系列前面的资源管理思路,不要为了掉落物单独写一堆逻辑在敌人脚本里。
3. 让关卡“会说话”:平台布局与敌人密度怎么设计
3.1 难度曲线的搭建原则
敌人AI写好了,接下来就是关卡。很多人挂着AI后直接把敌人往地上乱摆,结果要么是玩家开局被围殴,要么是全程无聊到跺脚。做2D平台关卡,我心里一直记着“安全区—教学区—挑战区—高潮区”的四段式节奏。
如果用一个比较朴素的比喻:你请朋友来家里吃饭,不能一进门就灌三斤白酒,得先坐下喝口茶,再上凉菜,然后才是主菜。关卡也一样。一开始的平地是安全区,让玩家熟悉操作;接下来放一个最低级的小怪,这是教学区,告诉玩家“这个游戏里敌人会打人”;然后加入需要跳跃配合的平台组合,这是挑战区;最后在关底安排一批敌人配合移动平台,这是高潮区。按照这个节奏摆敌人,比均匀分布要耐玩得多。
敌人密度方面,我总结了一个经验值:相邻两个“强制战斗点”之间至少留出3到5秒的纯移动时间,让玩家有喘息机会。如果是连续跳跃段,中间不要放远程敌人,否则玩家一边跳一边挨打,很快就会暴躁。这条原则我不只在示例关卡里用,后来做其他项目也一直保留。
3.2 用TileMap快速搭建可迭代的地形
Godot 4里用TileMap画关卡是基本功。我的习惯是:新建一个TileMap节点,为它创建一个TileSet资源,把地面、平台、装饰、尖刺分别放到不同的图层。注意要分图层:地面层用来承载碰撞,装饰层只负责视觉,尖刺层可以单独设置碰撞层和伤害层。
很多新手犯的错是只用一个地图层把所有东西画上去,结果要修改碰撞形状时牵一发而动全身。正确做法示例:
- 图层0:Ground,用于物理碰撞,物理层设为1
- 图层1:Decoration,关闭碰撞,只显示草、石头、背景装饰
- 图层2:Hazards,用于尖刺,单独物理层并挂Area2D检测
尖刺这类陷阱不要直接做在TileSet里,因为TileSet不能方便地绑定伤害回调。我更推荐的做法是:在尖刺瓦片上放一个独立的Area2D子场景,每次在地图上铺尖刺时直接实例化这个小场景。这样一来“伤害判定”和“显示”就分开了,后续想换成岩浆或者电流都只需要换贴图。
平台地形的高低差也有讲究。跳跃高度如果以64像素为基准,那么平台之间差一格的落差最舒服,差两格就需要助跑。我画图时习惯先把关键节奏点和跳跃距离标在草稿纸上,再落到TileMap里,避免画完才发现平台间距根本跳不过去。
3.3 存档点和收集品:延长关卡的重玩价值
存档点,或者叫检查点,是整个关卡里最重要的软设计。没有存档点的平台游戏,玩家死一次要跑完整张图,很快就会关掉游戏。我在系列前面提到过一次检查点,这里给出更系统的方法:做一个Checkpoint场景,包含一个静态Sprite、一个Area2D和一个已激活状态变量。
当玩家的Area2D进入检查点时,如果检查点还没有激活,就播放一个激活动画(比如旗帜升起或火焰点燃),然后把自身位置写入PlayerManager的respawn_position。玩家死亡后,不是重载整个场景,而是把玩家位置移动到存档点、恢复生命值、刷新地图上的敌人。重载整个场景虽然简单,但代价是所有收集品和已经打开的机关都会重置,这对玩家来说是一种隐性的惩罚。
收集品的设计建议和检查点联动:金币或者宝石被吃掉后,不要直接queue_free,而是放入一个collected数组并隐藏自身。这样玩家从检查点复活后,已经吃完的金币不会再次出现,游戏的推进感就保住了。如果你采用“死一次重置所有收集品”的设计,玩家就会学会无视收集品直接跑图,这违背了收集品的设计初衷。
4. 完整关卡流程的整合实现:从“几个脚本”到“一个游戏”
4.1 场景树与脚本分层:避免到处都是全局变量
当项目里同时有敌人、玩家、UI、关卡管理器时,第一件要做的就是理顺场景树。我推荐下面这种结构:
Main (Node) ├── GameManager (Node, autoload) ├── Level (Node2D) │ ├── TileMapGround │ ├── Hazards │ ├── Enemies │ │ ├── Slime (CharacterBody2D) │ │ ├── Slime2 │ │ └── Flyer │ ├── Player (CharacterBody2D) │ └── Camera2D ├── HUD (CanvasLayer) │ ├── HealthLabel │ ├── CoinCounterLabel │ └── DeathRestartButton └── Audio这里的关键是GameManager用autoload单例承载跨场景数据(生命值、金币数、检查点位置)。敌人的脚印脚本里我用了PlayerManager.player来引用玩家,更规范的写法是把玩家引用也放在autoload里,在玩家_ready时注册进去。不要用get_tree().get_first_node_in_group("player")到处找,那样角色一多就会乱。
脚本分层我坚持一个原则:每个脚本只做“一件事”。敌人脚本管自己的状态机,不碰UI;玩家脚本只管移动和受伤,不碰金币计数;HUD脚本通过信号监听血量变化和金币变化。具体做法是用信号解耦。举个例子,玩家受伤后发出health_changed信号,HUD脚本监听这个信号并更新心形图标,而不是在玩家脚本里直接写find_child("HealthLabel").text = ...。
4.2 敌人生成、计数与清理
整合作战里敌人最让人头疼的不是写AI,而是管理它们的生命周期。玩家死了,敌人应该全部重置;玩家离开某个区域,敌人不应该还在后台计算。我给Enemies容器挂了一个简单脚本,负责以下三件事:
- 提供get_alive_enemy_count(),方便在敌人清零时触发机关
- 在敌人从_ready注册进来、在_exit_tree注销出去
- 提供reset_all()方法,遍历子节点重新调用init()
清零触发机关是很多横版游戏里常见的设计:比如消灭所有敌人后,大门打开。实现这个功能时不要用Timer轮询,而是让每个敌人死亡时发出signal,由Enemies容器汇总后判断数量,这样可以保证没有延迟。
死亡清理方面,我遇到过最典型的Bug是敌人死亡时会掉落金币,但金币又触发了一次敌人计数变化,造成计数错乱。解决办法是给金币单独一个Group,敌人死亡信号只在敌人group内部传播,不要用一个通用的”所有东西死亡都可监听“的信号。分组管理是Godot里很基础但很重要的手段,建议在每个子场景的_ready里加add_to_group,确保场景树结构变化时也能正确分组。
4.3 玩家受击、死亡重试与手感缓冲
玩家受伤目前是平台上最需要打磨的反馈之一。我的实现方案是:玩家有一个health属性和一个immune_timer,受击后先进入2秒无敌帧,期间人物材质变成半透明闪烁,并且碰撞层暂时忽略敌人但保留和地面的碰撞。这一步能解决“一碰就连续掉血”的老大难问题。
无敌帧的具体实现方式有很多,最常见也最稳的是在_take_damage里判断:
func take_damage(damage: int): if immune_timer.time_left > 0.0: return health -= damage immune_timer.start() modulate.a = 0.5 emit_signal("health_changed", health) if health <= 0: die()die()的流程设计比很多人想象中复杂一点。如果直接隐藏角色,玩家的受击反馈太突然;如果立刻重载场景,又显得惩罚过重。我选择做一个1秒的死亡动画:玩家向上弹起一点、播放死亡动画、屏幕短暂变暗,然后通过GameManager的respawn()把玩家传送到检查点。这个缓冲时间虽然只有一秒,但对玩家的情绪影响非常大,也让游戏看起来更“贵”。
5. 调试与手感调优实录:项目里真正遇到的坑
5.1 常见问题速查表
做完这个整合关卡,我整理了实际调试过程中遇到的高频问题。给出一张速查表,方便你项目里碰到了直接对号入座。
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| 敌人走到平台边缘会“悬挂”不回头 | 下检测射线太短,没有覆盖平台边缘 | 把EdgeRay长度增加到24~36像素,并加上水平墙检测 |
| 玩家碰到敌人后连续掉血 | 没有无敌帧或无敌帧计时器没启动 | 在take_damage里先检查immune_timer |
| 敌人追击玩家时左右抖动 | 直接用玩家global_position计算方向,但玩家也在移动 | 只记录追击开始时的最后已知玩家坐标,或降低状态切换频率 |
| 关卡重载后金币复活 | 金币直接queue_free而不是记录状态 | 用collected数组管理,死亡后保持已收集状态 |
| TileMap中尖刺有显示但无伤害 | 伤害检测是独立Area2D,没有和TileMap层级对齐 | 检查Area2D碰撞shape位置和大小是否覆盖视觉区域 |
| 玩家从检查点复活却被卡在墙里 | 检查点位置是全局坐标,但该点碰撞体和地面重叠 | 把检查点的position稍微抬高2~3像素,并确保生成点在地形上方 |
这条表里的问题全部是我自己实际调试中碰到过的,尤其第一条,看似是射线方向问题,其实是射线起点没有放在“移动方向侧”导致的。EdgeRay的position如果是(0, 0),虽然能检测到正下方,但在平台边缘时它检测到的是平台边缘内部还是外部,判断会不准。正确做法是把RayCast2D的position设置在角色脚部靠移动方向一侧,而不是正中心。
5.2 物理参数与移动手感调优
手感调优是个老生常谈的话题,但很多人不知道具体该调哪些参数。结合这个教程的完成度,我给出几个比较关键的数字参考,不是标准答案,而是起点:
- 玩家最大水平速度:240到320,太快了在窄平台关开容易飞出去,太慢则没有爽感
- 玩家跳跃高度:控制在2格TileMap高度(约128像素)以内,配合80到100像素的加速时间
- 敌人巡逻速度:玩家速度的30%到40%
- 玩家追击速度:玩家速度的60%到75%
- 受击击退速度:约300像素每秒,持续0.1秒到0.15秒,太强会让玩家觉得角色像纸片
这里有一个我自己很看重的细节:跳跃时不要一刀切地给一个固定垂直速度。更好的做法是“按下跳跃键给一个较大的初始速度,松开跳跃键时立即乘以0.5”,这种可变跳跃高度让玩家按住和点按产生完全不同的跳跃结果,是所有平台游戏手感的关键之一。你可以在玩家脚本里给一个jump_release_multiplier变量,代码如下:
func _physics_process(delta): if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = jump_velocity if Input.is_action_just_released("jump") and velocity.y < 0: velocity.y *= 0.5就这么两行,手感会有一个质的提升。如果再配上coyote time(离开地面后一小段时间仍可跳跃),就不用我多解释它的威力了。
5.3 性能优化与场景切换的小细节
2D游戏性能瓶颈一般不在绘图本身,而在物理节点数量。敌人、金币、粒子、火焰特效都会产生CollisionShape2D和物理碰撞检测,数量一多帧率就掉。做整合关卡时我给自己定的规则是:屏幕上同时存在的物理体不超过50个,尤其是不要为了每个金币都挂一个Area2D。金币这种大量存在的物件,用单个Area2D笼罩一个区域统一检测一次,命中后按距离分配金币即可。
另外,不要在_physics_process里每个敌人每帧都调用全局寻路或计算距离。敌人之间其实不需要互相知道,只需要知道玩家位置。距离计算用 distance_squared_to 而不是 distance_to,可以省一次开平方运算。平方比较不会出错,但平方根消耗更高。对于几十个敌人来说区别不大,但当你在做密集型的弹幕场或大批量敌人场景时,积累的差距就出来了。
还有一个和场景切换相关的细节:从关卡切换到结算界面时,如果自动加载的GameManager还持有旧的敌人或玩家的引用,会造成内存泄漏或旧信号重复触发。我在项目里习惯在场景退出监听信号,并在_ready中集中注册、在_exit_tree中断开所有信号连接。Godot 4里可以用Signal连接带CONNECT_ONE_SHOT或使用Callable断开,总之记住:自动加载单例不要持有场景内节点的引用,只持有数值,把节点引用留到场景内部管理。
6. 往这个框架里再加内容的扩展思路
第11篇本身是一个样板工程,做完之后它应该成为你继续加料的基础。如果你做完还不够过瘾,我建议按下面两个方向继续扩展:
第一个方向是加新敌人。不用重写状态机,只要在State枚举里加一个状态,比如“远程攻击”“冲刺”“飞天遁地”。某个敌人从PATROL切换到新状态时,只需要定义好进入条件,其他状态不受影响。状态机的扩展性在这个阶段开始展示威力。第二个方向是加Boss战。可以把普通敌人状态机稍作修改,加入阶段血量阈值和技能触发时间线。比如半血以下切换成狂暴状态,攻击频率翻倍,移动速度提升,这其实只是在状态机里加了一个“狂暴判断”分支。
我个人在这个项目上最深的体会是:游戏开发到后期,最难的不是某个功能怎么实现,而是所有功能同时运行时如何不互相打架。状态机、信号、分组管理、检查点存档,这些设计模式本质上都是在减少系统之间的混乱。你把这套框架用熟了,以后再写别的2D项目,思路会清晰很多,而不至于每次都从零开始堆脚本。
最后再分享一个小技巧:做关卡整合时,随手准备一个“开发快捷键”。我在项目里加了F2立即切换到下一段关卡、F5直接重置敌人,用来反复测试敌人AI和玩家死亡。这个小工具省下的时间非常可观,强烈建议你也配置一套属于自己的。