角色移动与跳跃手感
用 CharacterBody2D 写一个手感好的平台跳跃角色:加速度和摩擦、由跳跃高度反推重力、下落加速、可变跳跃高度、土狼时间和跳跃缓冲。
平台跳跃游戏好不好玩,很大程度上取决于角色"跟不跟手":按下方向键多久能跑到全速、松手后滑多远、跳起来是飘还是脆、在平台边缘晚按了一点还能不能跳上去。这些感觉都来自几个数字。本章把这些数字一个个拆开讲清楚,每个都能在下面的示例里直接拖动、马上试。
先玩一下。这是第二部分三章共用的示例关卡,本章只显示和移动、跳跃有关的参数。点击画面运行,用 ←→ 或 AD 移动,空格 跳跃。
先选对节点:CharacterBody2D 还是 RigidBody2D
Godot 的 2D 物理里有四种"物体"节点,它们都继承自 PhysicsBody2D,区别在于"谁来决定它怎么动":
| 节点 | 谁让它动 | 典型用途 |
|---|---|---|
StaticBody2D | 不动 | 地面、墙。本例的 TileMapLayer 会自动为瓦片生成静态碰撞体 |
AnimatableBody2D | 你用代码或动画改它的位置 | 移动平台、电梯(第 8 章) |
RigidBody2D | 物理引擎根据力、重力、碰撞来算 | 箱子、滚石、布娃娃、弹球 |
CharacterBody2D | 你设定速度,引擎只负责"沿着墙面滑动" | 玩家角色、敌人 |
玩家角色几乎总是用 CharacterBody2D。原因很简单:我们想要的手感(瞬间转身、松手立刻停、空中还能改方向、跳跃高度精确可控)都不是真实物理。用 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,单位是像素/秒)。每个物理帧做三件事:
- 根据输入和重力修改
velocity; - 调用
move_and_slide(),它会按velocity × delta移动角色,撞到东西时沿表面滑动,并把被挡住的那部分速度去掉(比如落地后velocity.y变成 0); - 读取移动结果:
is_on_floor()、is_on_wall()、is_on_ceiling()。
示例里主角的 _physics_process 就是按这个顺序写的:
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,下坡和站在下降的移动平台上时不会一顿一顿地"弹起来"。
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)和上方向键:
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)
]
}这样做的好处是:换键、加手柄支持、做"按键设置"菜单时,游戏逻辑一行都不用改。读取输入有两种方式。第一种是轮询:在每帧里主动问。
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,适合"按一下触发一次"的操作。
## 事件:有输入发生时引擎调用。适合"按一下触发一次"的操作,比如暂停菜单。
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_tree().paused = not get_tree().paused动作也可以在代码里添加,比如做改键功能时:
## 在代码里添加一个动作并绑定按键(一般在 项目设置 → 输入映射 里点出来就行)。
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,不会越过目标。
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²。角色全速向右跑时玩家改按左键,大约多久后角色开始向左移动?
- 0.1 秒
- 0.15 秒
- 0.2 秒
- 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 轴朝下,起跳速度取负号:
## 由"跳多高、多久到顶"反推重力和起跳速度:
## 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 秒)则像在月球上。
下落加速
真实的抛物线上升和下落是对称的。但在游戏里,对称的跳跃会让人觉得"飘":玩家已经决定要落地了,角色却还在慢慢往下掉。几乎所有手感好的平台游戏都会让下落时的重力更大:
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 秒,红点就是松键的时刻,之后轨迹很快到顶。
宽容度:土狼时间和跳跃缓冲
玩家的反应和手指都不是完美的。两种常见的"冤枉"情况:
- 在平台边缘想跳,但按键晚了两三帧,角色已经走出了边缘,
is_on_floor()已经是 false,结果直接掉下去。 - 角色快落地时提前按了跳,按的时候还在空中,跳跃被忽略;落地后玩家以为自己按过了,不会再按。
解决办法是两个短时间窗口:
- 土狼时间(coyote time,名字来自动画片里跑出悬崖后还在空中停一下的土狼):离开地面后的
coyote_time秒内,仍然允许起跳。 - 跳跃缓冲(jump buffer):按下跳跃键后,把这次"想跳"记住
jump_buffer秒;这段时间内一旦落地就自动起跳。
两者都用一个倒计时变量实现,起跳条件是"缓冲计时 > 0 且 土狼计时 > 0":
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.08 到 0.15 秒之间(约 5 到 9 帧)。太大会出问题:土狼时间 0.3 秒时,角色明显已经离开平台还能起跳,玩家会觉得在空中二段跳。
如果起跳时只把 _buffer_timer 清零,忘了清零 _coyote_timer,会出现什么问题?
- 落地后无法起跳
- 在平台上起跳后,短时间内再按一次跳跃会在空中再跳一次
- 跳跃缓冲失效
起跳后角色离开地面,土狼计时器开始倒数但仍大于 0。这时再按跳,缓冲计时器变成正数,条件满足,于是在空中又跳了一次。
常见问题
is_on_floor() 一直是 false:先看是不是在 move_and_slide() 之前读的(读到的是上一帧的结果);再看 up_direction 是否还是 Vector2.UP;最后看 motion_mode,它必须是默认的 MOTION_MODE_GROUNDED。MOTION_MODE_FLOATING 模式下没有"地板"的概念,所有碰撞都算墙,那是给俯视角游戏用的。想让跳跃更"快更脆"、但跳跃高度不变,应该怎么改?
- 减小到顶时间
- 增大跳跃高度
- 减小最大下落速度
由 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算重力和起跳速度。 - 下落加速、可变跳跃高度、最大下落速度、土狼时间、跳跃缓冲,每一个都是几行代码,合起来决定了"手感"。
下一章在同一个角色上加动画和各种反馈效果:同样的移动参数,加上挤压拉伸、烟尘、震动之后,感觉会完全不同。