GameTech/Godot 4.7 互动教程
目录示例游戏
第二部分 · 2D 基础 · 第 6 章

角色移动与跳跃手感

用 CharacterBody2D 写一个手感好的平台跳跃角色:加速度和摩擦、由跳跃高度反推重力、下落加速、可变跳跃高度、土狼时间和跳跃缓冲。

平台跳跃游戏好不好玩,很大程度上取决于角色"跟不跟手":按下方向键多久能跑到全速、松手后滑多远、跳起来是飘还是脆、在平台边缘晚按了一点还能不能跳上去。这些感觉都来自几个数字。本章把这些数字一个个拆开讲清楚,每个都能在下面的示例里直接拖动、马上试。

先玩一下。这是第二部分三章共用的示例关卡,本章只显示和移动、跳跃有关的参数。点击画面运行,用 ←→ 或 AD 移动,空格 跳跃。

先把加速度拖到 200,跑几步再松手:角色像在冰面上,起步慢、停不住。再拖到 6000:几乎瞬间到全速、瞬间停下,很"硬"。默认的 1800 介于两者之间。后面每一节都会解释这些滑块背后的公式。

先选对节点:CharacterBody2D 还是 RigidBody2D

Godot 的 2D 物理里有四种"物体"节点,它们都继承自 PhysicsBody2D,区别在于"谁来决定它怎么动":

节点谁让它动典型用途
StaticBody2D不动地面、墙。本例的 TileMapLayer 会自动为瓦片生成静态碰撞体
AnimatableBody2D你用代码或动画改它的位置移动平台、电梯(第 8 章)
RigidBody2D物理引擎根据力、重力、碰撞来算箱子、滚石、布娃娃、弹球
CharacterBody2D你设定速度,引擎只负责"沿着墙面滑动"玩家角色、敌人

玩家角色几乎总是用 CharacterBody2D。原因很简单:我们想要的手感(瞬间转身、松手立刻停、空中还能改方向、跳跃高度精确可控)都不是真实物理。用 RigidBody2D 的话,你只能给它施加力和冲量,速度由引擎积分出来,要做到"松手就停"得和摩擦、阻尼反复较劲。看一下用刚体写角色的样子:

snippets/rigid_example.gd第 5–17 行对比:用 RigidBody2D 做角色在工程中查看 ↗
@export var move_force := 900.0
@export var jump_impulse := 420.0


func _ready() -> void:
	lock_rotation = true              # 否则角色会像箱子一样翻滚


func _physics_process(_delta: float) -> void:
	var dir := Input.get_axis("move_left", "move_right")
	apply_central_force(Vector2(dir * move_force, 0))       # 只能"推"它,速度由物理引擎算
	if Input.is_action_just_pressed("jump"):
		apply_central_impulse(Vector2(0, -jump_impulse))    # 是否站在地上,还得自己另外检测

这段代码能跑,但起步和停下的快慢取决于质量、摩擦、阻尼几个物理参数的组合,很难调出想要的感觉;它也不会告诉你"现在是否站在地上"。CharacterBody2D 则反过来:速度完全由你的代码决定,引擎只在你调用 move_and_slide() 时帮你处理碰撞,并顺便告诉你是否碰到了地板、墙或天花板。

什么时候反而该用刚体?当"被物理推着走"本身就是玩法时,比如《愤怒的小鸟》里的鸟、可以被推倒的箱子堆、物理解谜游戏里的球。

velocity 和 move_and_slide()

CharacterBody2D 有一个内置属性 velocity(Vector2,单位是像素/秒)。每个物理帧做三件事:

  1. 根据输入和重力修改 velocity;
  2. 调用 move_and_slide(),它会按 velocity × delta 移动角色,撞到东西时沿表面滑动,并把被挡住的那部分速度去掉(比如落地后 velocity.y 变成 0);
  3. 读取移动结果:is_on_floor()、is_on_wall()、is_on_ceiling()。

示例里主角的 _physics_process 就是按这个顺序写的:

player/player.gd第 75–97 行主角每个物理帧要做的事在工程中查看 ↗
func _physics_process(delta: float) -> void:
	if dead:
		velocity.y = minf(velocity.y + get_gravity_value() * delta, max_fall_speed)
		position += velocity * delta        # 死亡后不再参与碰撞,直接下落出屏幕
		return
	_apply_gravity(delta)
	_handle_jump(delta)
	_handle_horizontal(delta)

	var was_on_floor := is_on_floor()
	if not was_on_floor:
		_fall_speed = velocity.y
	move_and_slide()
	if not was_on_floor and is_on_floor():
		_on_landed()

	_check_hazards()
	if level and global_position.y > level.get_bounds().end.y + 64.0:
		die()
	_update_visual(delta)
	Tuning.stat("速度 x", roundi(velocity.x))
	Tuning.stat("速度 y", roundi(velocity.y))
	Tuning.stat("在地面", is_on_floor())

注意几个细节:

  • move_and_slide() 内部已经乘了 delta,所以 velocity 存的是"每秒"的速度,你不要自己再乘一次。
  • is_on_floor() 的结果来自上一次 move_and_slide()。所以代码先记下 was_on_floor,移动之后再比较一次,就能知道"这一帧刚落地",用来触发落地效果(第 7 章)。
  • Godot 的 2D 坐标 y 轴向下,所以向上跳的速度是负数,重力让 velocity.y 增大。
  • 什么算"地板"由 up_direction(默认 Vector2.UP)和 floor_max_angle(默认 45°)决定。主角场景里还把 floor_snap_length 设成了 6,下坡和站在下降的移动平台上时不会一顿一顿地"弹起来"。
从 Godot 3 迁移的坑:3.x 里叫 KinematicBody2D,写法是 velocity = move_and_slide(velocity, Vector2.UP)。到了 4.x,节点改名为 CharacterBody2D,velocity 和 up_direction 都变成了节点自己的属性,move_and_slide() 不带参数,返回值是"是否发生了碰撞"的 bool。照搬 3.x 的写法会直接报错。

_physics_process 和 _process

Godot 每个节点有两个"每帧"回调:

_process(delta)_physics_process(delta)
调用频率每渲染一帧调用一次,跟显示器刷新率走(60、120、144 Hz…),delta 不固定固定频率,默认每秒 60 次(项目设置 physics/common/physics_ticks_per_second),delta 基本恒定
适合做界面、视觉效果、和物理无关的动画移动物理体、读取碰撞结果、所有影响游戏结果的逻辑

角色移动要放在 _physics_process 里,原因有两个:一是物理引擎本身按固定步长运行,move_and_slide() 的碰撞结果要和它对齐;二是固定步长让手感和帧率无关——在 144 Hz 显示器上跳的高度应该和 60 Hz 一样。本章的跳跃轨迹小部件也是按 1/60 秒一步来模拟的,和游戏里一致。

示例里的相机抖动、金币旋转这类纯视觉的东西放在 _process 里,高刷新率显示器上会更顺滑。

输入映射 InputMap

代码里不直接写"空格键",而是写动作名 "jump"。动作和按键的对应关系在 项目设置 → 输入映射 里配置,保存在 project.godot 里。示例给 jump 绑了三个键:空格(keycode 32)、W(87)和上方向键:

project.godot第 38–44 行project.godot 里的输入映射(编辑器自动生成,平时不用手改)在工程中查看 ↗
jump={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}

这样做的好处是:换键、加手柄支持、做"按键设置"菜单时,游戏逻辑一行都不用改。读取输入有两种方式。第一种是轮询:在每帧里主动问。

snippets/input_examples.gd第 5–11 行轮询输入的几个函数在工程中查看 ↗
func _physics_process(_delta: float) -> void:
	# 轮询:每一帧主动去问"现在按着没有"
	var dir := Input.get_axis("move_left", "move_right")      # -1 ~ 1;手柄摇杆会得到中间值
	var holding := Input.is_action_pressed("jump")            # 这一帧是否按着
	var pressed := Input.is_action_just_pressed("jump")       # 是否"这一帧刚按下"
	var released := Input.is_action_just_released("jump")    # 是否"这一帧刚松开"
	print(dir, holding, pressed, released)

Input.get_axis("move_left", "move_right") 等于"右的强度减去左的强度",键盘上只会是 -1、0、1,手柄摇杆则能得到中间值,并且已经处理了死区(上面配置里的 "deadzone": 0.2)。第二种是事件:引擎在有输入时调用 _unhandled_input,适合"按一下触发一次"的操作。

snippets/input_examples.gd第 16–19 行事件方式:按 Esc 暂停在工程中查看 ↗
## 事件:有输入发生时引擎调用。适合"按一下触发一次"的操作,比如暂停菜单。
func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_cancel"):
		get_tree().paused = not get_tree().paused

动作也可以在代码里添加,比如做改键功能时:

snippets/input_examples.gd第 24–30 行运行时添加动作在工程中查看 ↗
## 在代码里添加一个动作并绑定按键(一般在 项目设置 → 输入映射 里点出来就行)。
func add_dash_action() -> void:
	if not InputMap.has_action("dash"):
		InputMap.add_action("dash")
	var ev := InputEventKey.new()
	ev.physical_keycode = KEY_SHIFT        # 用物理键位,换了键盘布局位置也不变
	InputMap.action_add_event("dash", ev)
移动用轮询:每个物理帧都需要知道方向键的状态。跳跃用 is_action_just_pressed 也放在 _physics_process 里——Godot 4 会按物理帧来记录"刚按下",不会因为渲染帧和物理帧不同步而漏掉。

水平移动:加速度和摩擦

最直接的写法是 velocity.x = dir * max_speed:按下就全速,松手就停。这种"零惯性"手感适合一些精确平台游戏,但大多数时候显得生硬。改进办法是让速度以固定的速率逼近目标值,这正是 move_toward(from, to, delta) 做的事:从 from 向 to 走一步,步长最多 delta,不会越过目标。

player/player.gd第 139–147 行加速、减速和空中操控在工程中查看 ↗
func _handle_horizontal(delta: float) -> void:
	var dir := Input.get_axis("move_left", "move_right")
	var accel := acceleration if is_on_floor() else acceleration * air_control
	if dir != 0.0:
		velocity.x = move_toward(velocity.x, dir * max_speed, accel * delta)
		facing = signf(dir)
	else:
		var decel := friction if is_on_floor() else friction * air_control
		velocity.x = move_toward(velocity.x, 0.0, decel * delta)

按着方向键时,每秒速度最多改变 acceleration;松手时,每秒减少 friction。所以:

  • 从静止加速到全速需要 max_speed / acceleration 秒。默认 260 / 1800 ≈ 0.14 秒,约 9 帧。
  • 从全速刹停需要 max_speed / friction 秒,期间滑行距离是匀减速公式 v² / (2 × friction),默认约 15 像素。
  • 转身时速度要先减到 0 再反向加速,所以 acceleration 同时决定了"转身有多灵活"。
  • 在空中,加速度和摩擦都乘以 air_control。小于 1 时空中改变方向更困难,跳跃的"承诺感"更强;等于 0 就是起跳后完全不能改方向。

下面的图画出了按住方向键一段时间再松开的速度曲线。上方的小方块按位移走,可以直观看到"刹车距离"。

为什么不用 lerp(velocity.x, target, 0.2)?它每帧走剩余距离的 20%,越接近目标走得越慢,理论上永远到不了,而且结果依赖帧率。move_toward 是匀加速,到达时间可以算出来,也和帧率无关(只要乘了 delta)。

最高速度 300 px/s,加速度 1500 px/s²,摩擦 3000 px/s²。角色全速向右跑时玩家改按左键,大约多久后角色开始向左移动?

  1. 0.1 秒
  2. 0.15 秒
  3. 0.2 秒
  4. 0.3 秒

按着方向键时用的是 acceleration(代码里只要 dir != 0 就走第一个分支),目标是 -300,所以速度从 300 降到 0 需要 300 / 1500 = 0.2 秒。摩擦只在松开方向键时起作用。

跳跃:先定高度和时间,再反推重力

新手常见的做法是先随便设一个重力,比如 980,再调起跳速度直到"高度差不多"。问题是重力和起跳速度同时影响高度和滞空时间,调一个另一个就变。更好的办法是直接设定设计师真正关心的两个量:跳多高(jump_height,记作 h)和多久到最高点(time_to_peak,记作 t),然后用公式算出重力 g 和起跳速度 v₀。

竖直上抛运动(先不管 y 轴朝下,取向上为正):

  • 速度:v(τ) = v₀ − g·τ。到最高点时速度为 0,即 v₀ − g·t = 0,得 v₀ = g·t。
  • 位移:y(τ) = v₀·τ − ½·g·τ²。在 τ = t 时等于 h:h = g·t·t − ½·g·t² = ½·g·t²。
  • 所以 g = 2h / t²,代回去 v₀ = g·t = 2h / t。

默认 h = 110、t = 0.38,得到 g ≈ 1524 px/s²,v₀ ≈ 579 px/s。回到 Godot 的 y 轴朝下,起跳速度取负号:

player/player.gd第 62–70 行由高度和时间反推重力和起跳速度在工程中查看 ↗
## 由"跳多高、多久到顶"反推重力和起跳速度:
##   h = v0·t - ½·g·t²,最高点时 v = v0 - g·t = 0
##   => g = 2h / t²,v0 = 2h / t
func get_gravity_value() -> float:
	return 2.0 * jump_height / (time_to_peak * time_to_peak)


func get_jump_velocity() -> float:
	return -2.0 * jump_height / time_to_peak

这两个值每次都现算,所以在网页上拖动"跳跃高度"时,重力和起跳速度会一起变,到顶时间保持不变。反过来只拖"到顶时间",高度不变,只有快慢变了——短时间(0.25 秒)的跳跃显得有力、利落,长时间(0.6 秒)则像在月球上。

下落加速

真实的抛物线上升和下落是对称的。但在游戏里,对称的跳跃会让人觉得"飘":玩家已经决定要落地了,角色却还在慢慢往下掉。几乎所有手感好的平台游戏都会让下落时的重力更大:

player/player.gd第 102–108 行下落时重力乘以倍数,并限制最大下落速度在工程中查看 ↗
func _apply_gravity(delta: float) -> void:
	if is_on_floor():
		return
	var g := get_gravity_value()
	if velocity.y > 0.0:
		g *= fall_multiplier    # 下落比上升更快,跳跃更"干脆"
	velocity.y = minf(velocity.y + g * delta, max_fall_speed)

下落时重力是 m·g,从高度 h 落回地面的时间满足 h = ½·(m·g)·t_f²,所以 t_f = √(2h / (m·g)) = t / √m。默认 m = 1.6,下落时间约为上升时间的 79%。在上面的轨迹图里把"下落重力倍数"拖到 3,橙色段明显变陡。

最后一行用 minf 把下落速度限制在 max_fall_speed。从很高的地方掉下来时,速度不会无限增大,玩家还有时间看清落点并调整方向。这个上限也避免了速度太快时一帧移动超过一个瓦片、直接穿过薄平台的问题。

可变跳跃高度

马里奥式的跳跃:轻点跳得低,按住跳得高。实现方法是在上升途中松开跳跃键时,把剩余的上升速度乘以一个系数 jump_cut(见下一节代码的最后几行)。

为什么乘 0.45 效果这么明显?从速度 v 开始上升,还能再升高 v² / (2g)。速度乘以 k 后,剩余高度变成 k²·v² / (2g),也就是原来的 k² 倍。k = 0.45 时剩余高度只剩约 20%。在轨迹图里把"按住跳跃键"拖到 0.1 秒,红点就是松键的时刻,之后轨迹很快到顶。

在示例里把松键保留速度设成 1(等于关掉这个功能),再试着轻点空格:无论按多短,都会跳到最高。再设回 0.45,轻点、长按的区别就出来了。

宽容度:土狼时间和跳跃缓冲

玩家的反应和手指都不是完美的。两种常见的"冤枉"情况:

  1. 在平台边缘想跳,但按键晚了两三帧,角色已经走出了边缘,is_on_floor() 已经是 false,结果直接掉下去。
  2. 角色快落地时提前按了跳,按的时候还在空中,跳跃被忽略;落地后玩家以为自己按过了,不会再按。

解决办法是两个短时间窗口:

  • 土狼时间(coyote time,名字来自动画片里跑出悬崖后还在空中停一下的土狼):离开地面后的 coyote_time 秒内,仍然允许起跳。
  • 跳跃缓冲(jump buffer):按下跳跃键后,把这次"想跳"记住 jump_buffer 秒;这段时间内一旦落地就自动起跳。

两者都用一个倒计时变量实现,起跳条件是"缓冲计时 > 0 且 土狼计时 > 0":

player/player.gd第 113–134 行土狼时间、跳跃缓冲和可变跳跃高度在工程中查看 ↗
func _handle_jump(delta: float) -> void:
	# 土狼时间:在地面时一直重置,离开地面后开始倒数
	if is_on_floor():
		_coyote_timer = coyote_time
	else:
		_coyote_timer -= delta

	# 跳跃缓冲:按下跳跃键后,在 jump_buffer 秒内落地都会自动起跳
	if Input.is_action_just_pressed("jump"):
		_buffer_timer = jump_buffer
	else:
		_buffer_timer -= delta

	if _buffer_timer > 0.0 and _coyote_timer > 0.0:
		velocity.y = get_jump_velocity()
		_buffer_timer = 0.0
		_coyote_timer = 0.0
		_on_jumped()

	# 可变跳跃高度:上升途中松开按键,砍掉一部分上升速度
	if Input.is_action_just_released("jump") and velocity.y < 0.0:
		velocity.y *= jump_cut

注意起跳后要把两个计时器都清零,否则在土狼时间内连按两次会跳两次。下面的小部件用同样的逻辑模拟:角色一直向右跑,跑出平台边缘后会掉进坑里。把播放速度调慢,试试在不同时刻按跳。

在示例里把土狼时间设为 0,然后在平台边缘跑着跳:你会发现明明"还在边上"却经常掉下去。再设为 0.1,同样的操作几乎每次都能跳起来。把跳跃缓冲设为 0 后连续跳台阶,会感觉偶尔"吞键"。

这两个值通常在 0.08 到 0.15 秒之间(约 5 到 9 帧)。太大会出问题:土狼时间 0.3 秒时,角色明显已经离开平台还能起跳,玩家会觉得在空中二段跳。

如果起跳时只把 _buffer_timer 清零,忘了清零 _coyote_timer,会出现什么问题?

  1. 落地后无法起跳
  2. 在平台上起跳后,短时间内再按一次跳跃会在空中再跳一次
  3. 跳跃缓冲失效

起跳后角色离开地面,土狼计时器开始倒数但仍大于 0。这时再按跳,缓冲计时器变成正数,条件满足,于是在空中又跳了一次。

常见问题

is_on_floor() 一直是 false:先看是不是在 move_and_slide() 之前读的(读到的是上一帧的结果);再看 up_direction 是否还是 Vector2.UP;最后看 motion_mode,它必须是默认的 MOTION_MODE_GROUNDED。MOTION_MODE_FLOATING 模式下没有"地板"的概念,所有碰撞都算墙,那是给俯视角游戏用的。
实测跳跃高度比 h 低几像素:公式是连续时间下的结果,而游戏每 1/60 秒走一步,并且每一步先改速度再移动。这样离散积分出来的最高点会比 h 略低,默认参数下大约低 5 像素。上面轨迹图里的"实际最高"就是按离散步长算的。设计关卡时给平台高度留一点余量即可。

想让跳跃更"快更脆"、但跳跃高度不变,应该怎么改?

  1. 减小到顶时间
  2. 增大跳跃高度
  3. 减小最大下落速度

由 g = 2h / t²,h 不变时减小 t,重力和起跳速度都会变大,整个跳跃更短促。高度由 h 单独决定,不受影响。

小结

  • 玩家角色用 CharacterBody2D:改 velocity,调用不带参数的 move_and_slide(),再读 is_on_floor()。
  • 移动逻辑写在 _physics_process,输入用动作名,按键在输入映射里配置。
  • move_toward 让速度匀速逼近目标,max_speed / acceleration 就是起步时间。
  • 先定跳跃高度 h 和到顶时间 t,再用 g = 2h/t²、v₀ = 2h/t 算重力和起跳速度。
  • 下落加速、可变跳跃高度、最大下落速度、土狼时间、跳跃缓冲,每一个都是几行代码,合起来决定了"手感"。

下一章在同一个角色上加动画和各种反馈效果:同样的移动参数,加上挤压拉伸、烟尘、震动之后,感觉会完全不同。