这个系列写到第二阶段的第三篇,说实话比我想象中的节奏要快。前两篇我们把Godot 2D游戏项目的基础骨架搭完了:角色能响应键盘输入,场景里能跳,UI能显示一句话。这些听起来很基础,但真要把它们组合成一个能跑起来、能让人玩下去的小游戏,中间还有不少脏活。这一篇的主题就是把这些脏活拆开揉碎,做一个短小但完整的2D平台跳跃demo,重点解决三件事:角色动作的动画状态机、TileMap关卡搭建、以及敌人和游戏状态的串联。适合刚好跟着系列走到这里的朋友,也适合那些已经能写简单脚本、想开始做完整小游戏的新手。我会保持手把手的风格,每一步都告诉你怎么操作,以及为什么这么做。
1. 项目整体设计与思路拆解
1.1 为什么第二季第三篇选择“平台跳跃”这个载体
把项目从零散的功能点收敛成“一个游戏”的时候,选型很重要。2D游戏类型很多,为什么我坚持用平台跳跃来做第二阶段的第二个完整demo?因为它几乎覆盖了一个2D游戏开发里最核心的底层系统:物理碰撞、玩家输入、动画切换、关卡摆放、敌人交互和UI反馈。
平台跳跃的手感要求很高,而这种高要求正好逼你把引擎的核心机制搞清楚。比如CharacterBody2D的move_and_slide怎么工作,什么时候要手动加重力,跳跃高度和跳跃速度之间怎么换算。这些东西不做一个实际项目,光看文档很难有体感。另一个原因是Godot的2D物理、TileMap、Camera2D在这个类型里表现非常成熟,官方很多示例项目也是平台跳跃,遇到问题相对容易找到参考。
我之前做过一个纯UI交互的demo,又做过一个平面移动的测试,发现“角色能走”和“角色好走”完全是两回事。第三篇如果继续堆功能而忽视手感,游戏会显得很“散”。所以这一篇在功能闭环之外,专门花了一节讲跳缓冲、土狼时间、加速度和摩擦系数。这些参数看起来很小,但正是它们决定了一个游戏是“像那么回事”还是“像PPT”。
1.2 场景树结构设计:一个节点一张职责卡
很多新手做项目喜欢把所有代码挂在主场景的根节点上,图省事。等代码过了一千行,你改一个跳跃功能都要小心翼翼,因为不知道哪里会引用到全局变量。这个项目从开始就明确了一点:一个场景只负责一件事,节点之间用信号通信。
以这个demo为例,我的场景树长这样:
code复制Game.tscn(主场景)
├── GameManager(节点,承载游戏状态和分数)
├── Level(Node2D,关卡根节点)
│ ├── TileMapLayer(地面和平台)
│ ├── Background(Parallax2D,背景视差)
│ └── EnemyContainer(Node2D,用来放敌人实例)
├── Player(CharacterBody2D,玩家角色)
│ ├── CollisionShape2D
│ ├── AnimatedSprite2D
│ └── Camera2D
└── UI(CanvasLayer)
├── ScoreLabel
├── HintLabel
└── GameOverPanel
Player是独立场景,直接实例化到主场景。Camera2D放在Player下面,这样镜头天然跟着角色走,不需要单独写跟随逻辑。Level作为关卡根节点,只负责管理场景里的静态物体和敌人生成。UI在CanvasLayer下面,不受摄像机影响,负责显示分数和游戏状态。
GameManager是一个普通场景里的普通节点,没有用AutoLoad单例。因为这个项目足够小,普通节点完全够用。如果你打算做多个场景切换、数据跨场景保存,再考虑用AutoLoad单例。小项目里过早引入全局单例反而会让依赖关系变复杂。
1.3 功能闭环:从“能跳”到“能玩”
这一篇的目标不是做一个“演示若干Godot功能的沙盒”,而是要有一个能跑完的玩家循环:角色出生后进入关卡,可以移动、跳跃;碰到敌人会扣血或死亡;收集到金币会加分;掉出地图会重置位置;分数达到目标或者死亡后显示对应UI。
这个闭环实现起来不难,但设计上要注意顺序。我通常先把核心物理手感调好,再摆关卡,最后做敌人和UI。因为敌人巡逻和计分系统都要依赖角色能不能正常移动和碰撞。如果你先写了一大堆UI代码,再回头调手感,往往要反复改一堆硬编码。
还要提醒一个点:这个小demo里的敌人不需要特别聪明。一个来回巡逻的怪物,加上碰到玩家后触发伤害逻辑,已经足够让玩家感到“这是一个游戏”。复杂的行为树和AI寻路不是这个阶段的目标,放到系列后面再讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:角色控制、动画状态机、物理参数
2.1 CharacterBody2D 与 move_and_slide 的核心机制
Godot的2D角色有几种选择:StaticBody2D、RigidBody2D、CharacterBody2D、Area2D。平台跳跃的主角我几乎只用CharacterBody2D。它不直接受物理引擎的力和碰撞影响,而是让你通过代码设置velocity,再调用move_and_slide让Godot帮你把角色沿着速度方向移动,并在这个过程里自动处理碰撞。
角色脚本的核心结构是这样的:
gdscript复制extends CharacterBody2D
@export var move_speed := 200.0
@export var acceleration := 1500.0
@export var friction := 1800.0
@export var gravity := 980.0
@export var jump_velocity := -400.0
func _physics_process(delta: float) -> void:
var direction := Input.get_axis("move_left", "move_right")
if not is_on_floor():
velocity.y += gravity * delta
if direction != 0:
velocity.x = move_toward(velocity.x, direction * move_speed, acceleration * delta)
else:
velocity.x = move_toward(velocity.x, 0, friction * delta)
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
move_and_slide()
move_toward是很好的函数,它会让速度平滑靠近目标值,而不是瞬间切换。acceleration越大,角色响应越快,但也不是越大越好,太大会让你觉得角色“滑不留手”。friction控制松键后角色多久停下来。这两个值需要配合项目里的瓦片尺寸和游戏节奏反复试。
is_on_floor()这个函数只有在move_and_slide()执行成功后,并且当前frame确实碰到地板时才返回true。所以不要在调用move_and_slide之前判断is_on_floor,那一定是上一帧的残留状态。这是我早期常踩的坑。
2.2 动画状态机:用代码还是AnimationTree
角色动画是2D游戏视觉反馈中最重要的一环。简单的项目可以直接在_physics_process里用AnimatedSprite2D.play()切换动画:
gdscript复制if not is_on_floor():
animated_sprite.play("jump")
elif abs(velocity.x) > 10:
animated_sprite.play("run")
else:
animated_sprite.play("idle")
这种写法非常直观,适合角色只有三四个动画的情况。但问题也随之而来:条件一多,代码里到处是if判断,比如“正在攻击时不能走路”“受击时不能跳跃”“下落超过一定速度要用fall动画”。这时候就该上状态机。
Godot自带的AnimationTree里有一个AnimationNodeStateMachine,正好用来做这类动画状态切换。你需要在Player场景里挂AnimationTree节点,选择动画树文件,然后把根节点类型设为StateMachine,再添加idle、run、jump、fall四个状态,连接跳转条件。每个状态对应AnimatedSprite2D里的一个动画。
我这里提供一个更稳的经验:就算你用AnimationTree,也最好在角色脚本里维护一个current_state枚举,比如IDLE、RUN、JUMP、FALL,然后在_physics_process里根据物理状态设置这个枚举,最后统一把枚举映射成动画参数。这样动画播放逻辑只在一个地方集中处理,避免动画状态和物理状态互相打架。
我自己的项目里,AnimationTree负责播放,脚本负责判断状态。脚本里大概长这样:
gdscript复制enum PlayerState { IDLE, RUN, JUMP, FALL }
var current_state: PlayerState = PlayerState.IDLE
func _update_animation_parameters() -> void:
if current_state == PlayerState.JUMP:
animation_tree.set("parameters/playback/transition_request", "jump")
elif current_state == PlayerState.FALL:
animation_tree.set("parameters/playback/transition_request", "fall")
...
这种方式在角色增加攻击、翻滚、受伤时会非常省心,后面加状态基本不需要重写逻辑。
2.3 手感调优:加速度、摩擦、跳跃缓冲、土狼时间
手感这个东西听起来玄,但落到参数上非常具体。我调参数时常用一个概念:跳跃高度大约等于jump_velocity^2 / (2 * gravity)。比如jump_velocity = -400,gravity = 980,理论上最高点约81像素。如果我的瓦片是16x16,那角色大概能跳5格高。这个数值很好用来判断关卡中的平台间距是否合理。
另一个影响手感很大的点是跳跃缓冲和土狼时间。玩家在快落地前按跳跃键,或者在离开平台边缘后一小段时间内按跳跃键,都应该让跳跃生效。这两个机制能大幅降低玩家“我明明按了跳跃为什么没跳”的挫败感。
实现起来不复杂,在角色脚本里加两个计时器:
gdscript复制@export var jump_buffer_time := 0.12
@export var coyote_time := 0.08
var jump_buffer_timer := 0.0
var coyote_timer := 0.0
func _physics_process(delta: float) -> void:
if Input.is_action_just_pressed("jump"):
jump_buffer_timer = jump_buffer_time
else:
jump_buffer_timer = max(jump_buffer_timer - delta, 0)
if is_on_floor():
coyote_timer = coyote_time
else:
coyote_timer = max(coyote_timer - delta, 0)
if jump_buffer_timer > 0 and coyote_timer > 0:
velocity.y = jump_velocity
jump_buffer_timer = 0
coyote_timer = 0
这两个小机制让跳跃体验变得非常跟手。我见过不少项目,功能都做完了,就是觉得“跳不起来”,十有八九是没做缓冲或者判定太严格。另外,跳跃键触发时不要直接写velocity.y = jump_velocity,最好在按下瞬间立刻把coyote_timer清零,否则会出现一次起跳被连续触发两次的问题。
3. 实操过程:TileMap关卡搭建、摄像机、敌人与UI
3.1 TileMap 瓦片碰撞与图层配置
关卡搭建是2D游戏里最耗时间也最出效果的部分。Godot 4从4.3版本开始推荐使用TileMapLayer节点,同一个场景里可以放多个TileMapLayer,分别负责地面、背景装饰、前景遮挡。如果你还在用Godot 4.2或更早,就用TileMap节点,核心概念差不多。
我建关卡的时候会先把瓦片图导入项目,然后创建TileSet资源。打开TileSet面板后,要注意两件事:一是给atlas源配置瓦片的大小,比如16x16或32x32;二是在“物理层”里用画笔给需要的瓦片画上碰撞多边形。这一步新手很容易漏,瓦片摆了一大堆,运行后角色直接掉下去。
正确流程是:
- 创建TileSet,添加一个AtlasSource,选择瓦片图片。
- 设置瓦片尺寸和网格,让编辑器正确切分。
- 切换到“物理层”选项卡,给地面瓦片添加碰撞形状。
- 创建
TileMapLayer节点,把TileSet拖进去。 - 在2D视口里,用“选择”“绘制”工具把瓦片刷上去。
刷瓦片时最好把不同的图层分开。比如地面和平台是一个TileMapLayer,背景装饰是另一个TileMapLayer,并且把背景层的z_index设为负数,或者放在更早的绘制顺序上。这样即使瓦片图片有重叠,也不会出现背景盖住角色的尴尬情况。
3.2 摄像机平滑跟随与2D视觉原理
摄像机是整个2D视觉体验的神经中枢。摄像机跟得好不好,直接影响玩家能不能第一时间判断角色位置和接下来要去的方向。最简单有效的做法是把Camera2D作为Player的子节点,然后开启position_smoothing_enabled = true,把平滑速度设为5到10之间。这样镜头不会僵硬地锁死角色,而是有一个“追上目标”的过程,看起来更自然。
但2D视觉不只是“跟随”这么简单,还涉及视觉引导。举个例子:角色往右走的时候,最好让角色在屏幕左侧偏中的位置,而不是始终在正中间,这样玩家能多看到一些右边的地形。实现方式有两种:一种是在Camera2D上设置一个drag_margin和偏移量;另一种是在代码里根据角色朝向动态调整镜头位置。我倾向于在角色脚下放一个Marker2D,根据facing_direction改变Camera2D的offset.x偏移,效果更自由。
背景视差也是2D视觉原理里非常出效果的一环。用Parallax2D节点,把背景层挂在关卡里,设置scroll_scale为比如(0.5, 1),这样背景横向移动速度只有主层的一半,会产生非常明显的纵深感。如果你用的是Godot旧版里的ParallaxBackground,概念一样,只是组织方式略有不同。
这里有一个容易踩的坑:Camera2D的limit如果不设置,镜头会跟着角色离开关卡范围,露出灰白色的“虚空”。所以我通常会在每个关卡里放一个静态的边界矩形,或者在Camera2D里手动设置四个方向的limit。如果你是动态生成关卡,建议用代码根据TileMap的实际大小设置limit,不要让玩家看到场景外的东西。
3.3 敌人AI与玩家交互
这个demo里的敌人,我用了一个非常简单但足够用的巡逻怪:它在平台上左右移动,撞墙转身,走到平台边缘前也会转身。这个行为看起来不聪明,但在平台跳跃游戏里已经能制造不少麻烦。
敌人直接复用CharacterBody2D,因为它也涉及碰撞和物理。角色脚本长这样:
gdscript复制extends CharacterBody2D
@export var move_speed := 60.0
@export var gravity := 980.0
var direction := -1
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity.y += gravity * delta
velocity.x = direction * move_speed
move_and_slide()
if is_on_wall():
direction *= -1
单靠is_on_wall还不够,遇到没有墙的平台边缘,敌人会直接走出去掉下去。为了处理这种情况,需要给敌人加两个RayCast2D,一个朝前下方,一个朝前上方,或者用前方和下方两个射线组合判断。更简单的方案是在敌人前方再放一个Area2D,当它离开地板时触发转向。这些实现细节视你的项目结构而定,原则就是“不要相信复杂公式,先用最短路径跑通”。
玩家和敌人的交互我习惯用Area2D。在Player身上加一个Area2D的碰撞层,专门用来检测伤害,而不是用整个身体碰撞体。这样“碰到头顶的敌人”和“被敌人侧面撞到”可以区分处理,前者可以设计成踩怪跳,后者才扣血。这里我只做最简单的逻辑:任何情况下碰到敌人,玩家都会进入受伤状态并重置位置。
顺带提一下“Godot AI”这个概念。很多初学者一听AI就以为要写行为树、寻路、状态机。其实在这个demo里,一个direction *= -1就是最基本的AI逻辑。等后面敌人种类多了,再把state拆成PatrolState、ChaseState,那就是一个标准的有限状态机设计,到时候再细致展开。
3.4 计分UI与游戏状态控制
游戏状态我用一个GameManager节点来管理。它负责记录分数、处理玩家死亡、重置关卡。UI的分数显示不能由Player直接修改Label文本,那会让Player和UI强耦合。更好的方式是用信号。
我习惯先在GameManager里定义一个信号:
gdscript复制signal score_changed(new_score)
var score := 0
func add_score(points: int) -> void:
score += points
score_changed.emit(score)
然后在UI里用score_changed.connect去更新Label。金币检测照样用Area2D,金矿收集时调用GameManager.add_score(10),同时把金币自身隐藏或销毁。
UI层用CanvasLayer承载,不管摄像机怎么动,UI永远固定。计分Label放在左上角,字号不用太大,但要保证清晰。Godot默认字体够用,但如果你要用中文,建议自己准备一个支持中文的字体文件拖进项目,在Label的Theme里设置Font。不设置的话,中文可能显示成方块,这是一个很常见的小坑。
游戏状态我一般用一个简单的枚举:RUNNING、GAME_OVER、WIN。在_process或_physics_process里先判断当前状态,如果不在RUNNING状态就不处理输入和重力,这样能避免玩家死亡后角色还在自己动。
4. 常见问题与排查技巧实录
4.1 角色卡墙、抖动问题
我在调平台跳跃时遇到最多的就是角色贴在墙上不停抖动,或者挤进墙里再被弹出来。这个问题的根源通常是碰撞形状和物理参数不匹配。第一种情况是碰撞形状太大,比如用了整张长方形的精灵图片,而图片里的人物只占中间一小块,导致明明没有碰到墙,碰撞体却先卡住了。解决方法是把CollisionShape2D调小一点,或者改成圆形碰撞,圆形在2D平台游戏里穿过小障碍时表现更顺滑。
第二种情况是move_and_slide的safe_margin设置太宽。Godot默认的安全边界是0.001,看起来很小,但如果你在高速移动下还是可能发生穿透。我把safe_margin设置为0.08后,角色高速移动时更稳,但也需要确认不会因为边界过大导致靠近墙壁时提前被挡住。这个参数需要在你的特定关卡里实测,不存在通用最优值。
还有一种“抖动”其实来自摄像机,不是角色。当摄像机平滑跟随和角色移动频率不匹配时,背景会出现高频抖动感。可以先关掉smoothing测试,如果抖动消失,就说明不是角色碰撞问题,而是镜头平滑参数需要调低。
4.2 动画播放不同步/状态切换闪断
角色明明在跑步,画面却不停闪烁idle和run,或者跳跃动画播了一瞬间就回到idle。这类问题绝大多数是动画切换条件写得太松了。
举个例子,如果你在_physics_process里判断:
gdscript复制if is_on_floor():
play("idle")
if abs(velocity.x) > 10:
play("run")
这两个if条件会同时成立,每一帧都先播放idle再立刻播放run,视觉上就是闪断。解决办法是让条件互斥,尽量用elif,或者把动画切换独立成一个函数,只调用一次。
使用AnimationTree状态机时,注意状态之间的过渡时间。默认自动过渡是0毫秒,如果设置了一个比较大的transition_time,可能动画之间会有交叉淡入淡出的效果,这在角色状态切换上不一定好看。我一般把idle与run之间的过渡设为80毫秒,run到jump设为0毫秒,这样“跳”更干脆。
4.3 TileMap碰撞不可用、图层顺序错误
摆好了瓦片,运行后角色直接穿透地面,这个问题的排查顺序很固定。先检查TileSet资源里是否建立了物理层,并且物理层上是否给瓦片画了碰撞多边形。有时候建了物理层但忘了画碰撞,瓦片照样能显示,但没有碰撞。
再检查TileMapLayer节点的collision_enabled属性是否被关闭。这个属性一般在编辑器里默认开启,但如果你用代码动态生成TileSet时复制过节点,有可能被覆盖成false。最后再看图层顺序。如果角色看起来在地板下面穿行,其实是视觉图层顺序问题,地面被画在了角色同一层且后绘制,导致角色视觉上被遮挡。这时调整z_index或者TileMapLayer的绘制顺序就能解决。
4.4 摄像机边界与关卡缩放问题
Camera2D的limit是很多人的痛点。我见过不少项目,场景设计得很精致,但镜头能移动到场景外面,露出空白区域。Godot 4里可以直接在Camera2D节点上设置limit_left、limit_top、limit_right、limit_bottom,也可以用一个脚本根据TileMap的get_used_rect()动态设置。我的做法是尽量用动态设置,这样关卡调整后不需要手动改数字。
如果你在游戏里支持窗口分辨率缩放,要注意CanvasLayer和Camera2D的zoom属性。2D游戏里常见的像素风会设置camera.zoom = Vector2(2, 2)或者更高,这会让UI标签也变大,但CanvasLayer默认不受摄像机zoom影响,所以UI是像素大小固定的。如果你希望UI也跟随缩放,可以调整CanvasLayer的transform,但新手阶段不建议动这个,保持默认更简单。
4.5 性能优化初探:对象池、可见性
这个demo规模小,基本不需要刻意优化,但有些习惯要养成。比如敌人和金币不要在游戏开始时一次性全部生成,而是在玩家接近时再实例化。最简单的做法是用VisibleOnScreenNotifier2D节点,当它进入屏幕后触发实例化,离开屏幕后回收。
金币这类东西一多,就要避免每帧重复创建、销毁节点。对象池就是把用过的金币实例放进一个数组,需要时从数组里取,而不是queue_free()销毁再新建。Godot节点创建和销毁虽然有缓存优化,但大量同时操作时还是会卡。金币在平台跳跃里出现频率很高,这个习惯可以帮你避免后期项目体量变大时老掉帧。
还有一个容易被忽略的性能问题:tilemap的碰撞层开太多。在TileSet里如果给每个瓦片都画了复杂的碰撞多边形,并且场景里几千块瓦片,碰撞计算的负担是很大的。我一般用“简单矩形碰撞”做地面和墙,特殊装饰瓦片不开碰撞,这样物理计算会省很多。
5. 个人心得与后续扩展
做完这个demo之后,我对Godot 2D的整个工作流有了更清晰的认知。以前觉得游戏手感是“玄学”,现在发现大部分手感都能落成具体的参数和机制,比如跳跃缓冲、土狼时间、加速度、摩擦系数。这些参数没有绝对正确的标准答案,但你把它们逐个拆出来调试,游戏的感觉就会一点点变好。
在项目结构上,我强烈建议从第一天就保持节点职责清晰。哪怕只是一个人做的小游戏,也要把UI、角色、关卡、敌人分离。因为开发到后面,你会发现你最常做的事不是“加新功能”,而是“改旧逻辑”。如果旧逻辑散落在一整个大脚本里,每改一次都可能引入新bug。
后续我大概率会给敌人加上一个简单的有限状态机,让它在巡逻和追击之间切换;再给关卡加入金币、传送门和短场景切换。到了系列下一阶段,我会把回合制战斗和背包系统加进来,那时候场景规模会大很多,现在打下的节点结构和代码规范应该能撑得住。
如果你做完这个平台跳跃demo,一定要花时间自己调一遍参数。别人给的数值只能作为起点,真正属于你项目手感的东西,一定是你亲手试出来的那一组。
