GameTech/Godot 4.7 互动教程
目录示例游戏
第四部分 · 3D 实战 · 第 16 章

FPS(上):第一人称控制器

用 CharacterBody3D 写一个完整的第一人称控制器:身体和头分开转、鼠标视角与 Web 上的鼠标锁定、加速度和摩擦、跳跃和重力、蹲下与头顶检测、镜头晃动、冲刺 FOV,以及帧率无关的平滑插值。

第一人称游戏里,玩家看到的一切都是从"眼睛"出发的,控制器的手感直接决定了游戏好不好玩:鼠标是不是跟手、停下来会不会滑、跳起来是不是太飘、走路时镜头晃得舒不舒服。本章逐个拆开这些细节,每一项都对应示例里的一个滑块。

先把敌人数量拖到 0,专心试手感。按住 W 走几步再松开,看"实时数据"里的速度怎么变化;按住 Shift 冲刺,注意视野变宽;按住 Ctrl 走到平台下的拱门里松开,看看能不能站起来。

节点结构:身体管左右,头管上下

player/player.tscn第 15–48 行player.tscn 的节点部分在工程中查看 ↗
[node name="Player" type="CharacterBody3D" groups=["player"]]
collision_layer = 2
collision_mask = 5
floor_snap_length = 0.3
script = ExtResource("1")

[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
position = Vector3(0, 0.9, 0)
shape = SubResource("capsule")

[node name="Head" type="Node3D" parent="."]
position = Vector3(0, 1.62, 0)

[node name="Camera3D" type="Camera3D" parent="Head"]
current = true
fov = 75.0
near = 0.03
far = 250.0

[node name="AimRay" type="RayCast3D" parent="Head/Camera3D"]
enabled = false
target_position = Vector3(0, 0, -100)
collision_mask = 5

[node name="Weapons" type="Node3D" parent="Head/Camera3D"]
script = ExtResource("2")
weapons = Array[ExtResource("7")]([ExtResource("3"), ExtResource("4")])

[node name="Rifle" parent="Head/Camera3D/Weapons" instance=ExtResource("5")]
transform = Transform3D(0.7, 0, 0, 0, 0.7, 0, 0, 0, 0.7, 0.16, -0.15, -0.3)

[node name="Shotgun" parent="Head/Camera3D/Weapons" instance=ExtResource("6")]
visible = false
transform = Transform3D(0.7, 0, 0, 0, 0.7, 0, 0, 0, 0.7, 0.16, -0.16, -0.28)

结构是 Player(CharacterBody3D)→ Head(Node3D)→ Camera3D → 武器。为什么要把一个"看"的动作拆到两个节点上?

  • 左右转(偏航)转的是整个身体。身体的朝向决定了按 W 往哪走,所以它必须转在 Player 上。
  • 上下看(俯仰)只转头。如果连身体一起往下转,按 W 就会往地里钻;胶囊碰撞体也会跟着倾斜。
  • 每个节点只绕一根轴转,就不用操心欧拉角的组合顺序和万向节锁(第 15 章):身体只改 rotation.y,头只改 rotation.x。

相机再单独占一层,是为了让镜头晃动、后坐力、落地下沉这些"表演"只动相机的局部位置和角度,不污染头部真正的朝向。武器挂在相机下面,所以永远跟着视角走。

碰撞体是一个胶囊,position.y = 0.9 让它的底部正好在 Player 的原点上:原点就是脚底,这样改胶囊高度(蹲下)时脚不会离地。

鼠标视角

player/player.gd第 72–84 行鼠标移动 → 身体偏航 + 头部俯仰在工程中查看 ↗
func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		# screen_relative 是鼠标在屏幕上实际移动的像素,不受窗口拉伸(stretch)影响,适合做视角
		_look(event.screen_relative)


## 鼠标和触屏共用:relative 是移动了多少像素。
func _look(relative: Vector2) -> void:
	var d := relative * deg_to_rad(mouse_sensitivity)
	rotate_y(-d.x)                                  # 身体左右转(偏航 yaw)
	var pitch_dir := 1.0 if invert_y else -1.0
	head.rotation.x = clampf(head.rotation.x + d.y * pitch_dir,
			-deg_to_rad(max_pitch), deg_to_rad(max_pitch))   # 头上下看(俯仰 pitch),限制角度

几个细节:

  • 用 _unhandled_input 而不是 _input:UI 控件(比如暂停菜单里的按钮)先处理事件,没被 UI 吃掉的才轮到游戏。
  • 用 screen_relative 而不是 relative:relative 会被窗口拉伸模式(stretch)缩放,窗口越大,同样的鼠标移动转得越少;screen_relative 是鼠标在屏幕上实际移动的像素,适合做视角。
  • 灵敏度的单位是"度 / 像素"。0.12 意味着鼠标移动 750 像素转一整圈的四分之一(90°)。
  • 俯仰角用 clampf 限制在 ±88°,不到 90° 是为了避开正上方 / 正下方时的万向节锁和 look_at 的退化情况。反转 Y 轴只是把符号反过来。
把鼠标灵敏度拖到 0.4,轻轻一动视角就飞出去;拖到 0.04,转身要把鼠标划过整张桌子。勾上反转 Y 轴,鼠标往前推变成低头。

鼠标锁定与 Web 的限制

FPS 需要把鼠标"锁"在窗口里:光标隐藏,鼠标可以无限地往一个方向移动。Godot 里一行代码就够了:Input.mouse_mode = Input.MOUSE_MODE_CAPTURED。在桌面版上这样就行,但在浏览器里有两条额外的规则:

  1. 必须由用户的操作触发。浏览器的 Pointer Lock API 只接受在点击或按键的处理过程中发出的请求,游戏一启动就自动锁定会被拒绝。所以示例先显示一层"点击画面开始"的提示,在点击事件里请求锁定。
  2. 按 Esc 由浏览器解除锁定,游戏通常收不到这个 Esc 按键。所以不能靠"监听 Esc 来暂停",而要每帧检查锁定状态:锁过、现在又不是锁定状态,就说明玩家按了 Esc,进入暂停。
main.gd第 48–115 行开始 / 暂停流程和鼠标锁定在工程中查看 ↗
## Web 上的鼠标锁定(Pointer Lock)有两条规则:
##   1. 必须在用户点击 / 按键的处理过程中请求,否则浏览器直接拒绝;
##   2. 用户按 Esc 时浏览器自己解除锁定,游戏往往收不到这个 Esc。
## 所以:点击提示层时请求锁定;之后每帧检查锁定状态,"锁过又丢了"就暂停。
## 手机上没有鼠标锁定:网页一打开触屏按键,游戏就直接开始,视角改由右半屏滑动控制。
func _on_touch_mode(on: bool) -> void:
	if on and not _running:
		_set_running(true)


func _on_overlay_clicked() -> void:
	capture_mouse()
	_set_running(true)


## 请求锁定鼠标。Web 版的输入事件是在浏览器的点击回调里同步处理的,所以这里仍算"用户手势"。
## Godot 自带的实现在锁定失败时(比如窗口没有焦点)会留下一条未捕获的 Promise 错误,
## 所以 Web 上自己调用 requestPointerLock() 并把失败吞掉;锁定成功后 Input.mouse_mode 会读到 CAPTURED。
func capture_mouse() -> void:
	if OS.has_feature("web"):
		JavaScriptBridge.eval("""(function () {
			var c = document.getElementById('canvas');
			var p = c && c.requestPointerLock ? c.requestPointerLock() : null;
			if (p && p.catch) p.catch(function () {});
		})()""")
	else:
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


func _process(_delta: float) -> void:
	var captured := Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
	if Tuning.touch_mode:
		hud.set_hint("")                                 # 手机上用虚拟按键,不需要锁定鼠标
	elif captured:
		_had_capture = true
		hud.set_hint("")
	elif _running and _had_capture:
		_had_capture = false
		_set_running(false)
		overlay.show_paused()
	elif _running and not _dev_mode:
		# 锁定没成功(比如无头浏览器、或者刚按过 Esc 浏览器还在冷却):游戏照常运行,提示再点一次
		hud.set_hint("鼠标未锁定:点一下画面即可用鼠标转视角")
	if Engine.get_process_frames() == _flash_frame:
		weapons.models[weapons.current].flash()
	Tuning.stat("FPS", Engine.get_frames_per_second())
	Tuning.stat("敌人", spawner.alive().size())
	Tuning.stat("击杀", kills)
	# 渲染统计:每帧的绘制调用次数和三角形数,是判断 Web 版性能的两个主要指标
	Tuning.stat("绘制调用", RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TOTAL_DRAW_CALLS_IN_FRAME))
	Tuning.stat("三角形", RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TOTAL_PRIMITIVES_IN_FRAME))


func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_cancel") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE      # 桌面版:Esc 释放鼠标(下一帧会进入暂停)
	elif _running and event is InputEventMouseButton and event.pressed and not Tuning.touch_mode \
			and Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
		capture_mouse()                                  # 运行中但没锁定:这次点击用来重新锁定,不开火
		weapons.block_trigger()


func _set_running(on: bool) -> void:
	_running = on
	get_tree().paused = not on
	overlay.visible = not on
	if on:
		weapons.block_trigger()

这段代码还处理了两种异常情况:

  • 锁定失败。比如浏览器窗口没有焦点、刚按完 Esc 立刻又点(Chrome 有大约 1 秒的冷却),或者在无头浏览器里自动测试。这时游戏照常运行,只是在底部提示"点一下画面",下一次点击会再请求锁定(这次点击不会开枪)。
  • 不留控制台错误。Godot 自带的实现直接调用 canvas.requestPointerLock(),失败时浏览器会抛出一个没人接住的 Promise 错误。示例在 Web 上改用 JavaScriptBridge.eval 自己调用并加上 .catch()。Godot 读取鼠标模式时查询的是浏览器的真实状态(document.pointerLockElement),所以锁定成功后 Input.mouse_mode 照样是 CAPTURED。之所以可以在 GDScript 里发起请求,是因为 Godot Web 版的输入事件是在浏览器的事件回调里同步处理的,仍然算作"用户手势"。
暂停用的是 get_tree().paused = true。被暂停的节点不再执行 _process / _physics_process,也收不到输入。所以主场景 Main 设成 process_mode = ALWAYS(暂停时也要检查鼠标、响应点击),而玩家、敌人、关卡这些子节点显式设成 PAUSABLE。如果忘了这一步,子节点会从父节点继承 ALWAYS,暂停就形同虚设。
实测记录:在 Chrome 里点击画面后鼠标被锁定,按 Esc 释放后出现"已暂停"提示层,再点击可以重新锁定,控制台没有错误。用 Playwright 自动化启动的 Chrome 窗口,在收到第一次键盘事件之前,指针锁定请求会被拒绝(错误信息是 "The root document of this element is not valid for pointer lock"),这是自动化环境下窗口没有被激活造成的,正常打开的浏览器没有这个问题。无头模式下锁定可能失败,示例会退化成"只能用键盘"的状态继续运行。

地面移动:加速度和摩擦

player/player.gd第 107–130 行把输入变成世界方向,再朝目标速度加速在工程中查看 ↗
func _move(delta: float) -> void:
	var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
	# input 是"相对自己"的方向:x 左右,y 前后(前 = -1)。乘上身体的 basis 就变成世界方向。
	# Godot 里 -Z 是前方,所以"前"对应 Vector3(0, 0, -1)。
	var wish_dir := (transform.basis * Vector3(input.x, 0.0, input.y)).normalized()
	_sprinting = Input.is_action_pressed("sprint") and input.y < -0.1 and not _crouching
	var speed := walk_speed
	if _sprinting:
		speed *= sprint_mult
	elif _crouching:
		speed *= crouch_mult

	var horizontal := Vector3(velocity.x, 0.0, velocity.z)
	var rate: float
	if is_on_floor():
		rate = acceleration if wish_dir != Vector3.ZERO else friction
	else:
		# 空中:只有一部分操控力,没有输入时保持惯性(没有地面摩擦)
		rate = acceleration * air_control if wish_dir != Vector3.ZERO else 0.0
	# move_toward:每帧朝目标速度最多改变 rate·delta,相当于恒定加速度
	horizontal = horizontal.move_toward(wish_dir * speed, rate * delta)
	velocity.x = horizontal.x
	velocity.z = horizontal.z
	_tilt = -input.x

Input.get_vector() 返回一个"相对自己"的二维方向:x 是左右,y 是前后(按 W 时 y = -1)。把它放进 Vector3(x, 0, y),正好对应 Godot 里"右是 +X、前是 -Z",再乘以身体的 basis,就变成了世界方向。这就是第 15 章说的"局部 → 世界"的变换。

速度不是直接设成目标值,而是用 move_toward 每帧最多改变 rate × delta,也就是一个恒定的加速度:

  • 有输入时用 acceleration(45 m/s²):从静止到 5 m/s 需要 5 / 45 ≈ 0.11 秒。
  • 没有输入时用 friction(40 m/s²):从 5 m/s 停下需要 0.125 秒,滑行距离 v² / 2a ≈ 0.31 米。
  • 空中只用 acceleration × air_control,没有输入时完全保持惯性。

和平台跳跃(第 6 章)相比,FPS 的加速度通常大得多:玩家需要精确地在掩体之间横移、急停瞄准,"滑"的感觉会直接影响射击。

把加速度拖到 5:起步要将近一秒,转身时会画出一道弧,像在冰上。拖到 150:起停都是瞬间完成,非常"硬",但对射击最友好。然后把空中操控拖到 0,起跳后按 AD 完全改不了方向;拖到 1,空中和地面一样灵活(很多竞技 FPS 选择较高的空中操控)。

跳跃和重力

player/player.gd第 135–140 行在工程中查看 ↗
func _apply_gravity_and_jump(delta: float) -> void:
	if not is_on_floor():
		velocity.y -= gravity * delta
	elif Input.is_action_just_pressed("jump") and not _crouching:
		# 跳跃高度 h = v² / (2g):默认 5.2² / 28 ≈ 0.97 米,跳不上 1.2 米高的箱子,但能跳上 0.9 米的台子
		velocity.y = jump_velocity

跳跃很简单:在地面上按下跳跃键时,把竖直速度设成 jump_velocity;不在地面上时,每帧减去 gravity × delta。由匀减速运动公式,能跳的最大高度是 h = v² / (2g),到达最高点的时间是 t = v / g。默认 5.2 m/s 和 14 m/s² 算出来 h ≈ 0.97 米、t ≈ 0.37 秒。

现实的重力是 9.8 m/s²,为什么用 14?因为同样的跳跃高度,重力越大,滞空时间越短,跳起来越"干脆"。大多数动作游戏的重力都比现实大。

关卡里的箱子高 1.2 米。默认参数跳不上去(0.97 米)。根据 h = v² / 2g,把起跳速度拖到多少才够?算一下再试:需要 v ≥ √(2 × 14 × 1.2) ≈ 5.8 m/s,再加上胶囊底部是圆的,6.2 比较稳。或者保持速度不变,把重力拖小。

蹲下与头顶检测

player/player.gd第 145–166 行改胶囊高度,站起前先检查头顶在工程中查看 ↗
func _update_crouch(delta: float) -> void:
	var want := Input.is_action_pressed("crouch")
	if want and not _crouching:
		_set_body_height(CROUCH_HEIGHT)
		_crouching = true
	elif not want and _crouching and _can_stand_up():
		_set_body_height(STAND_HEIGHT)
		_crouching = false
	# 碰撞体是瞬间变矮的,眼睛高度则平滑地跟过去
	var eye := (CROUCH_HEIGHT if _crouching else STAND_HEIGHT) - EYE_BELOW_TOP
	head.position.y = lerpf(head.position.y, eye, 1.0 - exp(-12.0 * delta))


## 头顶检测:拿当前(蹲着的)碰撞体,"假装"往上挪一段,看会不会撞到东西。
## test_move 不会真的移动角色,只返回会不会发生碰撞。
func _can_stand_up() -> bool:
	return not test_move(global_transform, Vector3.UP * (STAND_HEIGHT - CROUCH_HEIGHT))


func _set_body_height(h: float) -> void:
	capsule.height = h
	collision.position.y = h / 2.0     # 脚底保持在原点,胶囊从上面缩短

蹲下就是把胶囊从 1.8 米改成 1.05 米,同时把碰撞形状的中心下移一半,保持脚底不动。站起来时有个坑:如果头顶有东西(比如在拱门、平台下方),直接把胶囊恢复成 1.8 米,角色会嵌进天花板,被物理引擎弹出去或者卡住。

检查方法是 test_move(当前变换, 向上的位移):用当前(蹲着的)碰撞体"假装"往上移动 0.75 米,看会不会撞到东西。它不会真的移动角色,只返回会不会发生碰撞。也可以用一个朝上的 ShapeCast3D 节点实现同样的效果,test_move 的好处是不用额外的节点,而且检测形状和角色完全一致。

另外注意:碰撞体是瞬间变矮的(这样蹲下躲子弹立刻生效),而眼睛高度是平滑过渡过去的(不然镜头会"咔"地一跳)。

胶囊形状是在 .tscn 里定义的子资源,默认被所有实例共享。示例里只有一个玩家所以没有问题;如果要做多个会蹲下的角色,记得勾选形状资源的 resource_local_to_scene,或者在 _ready 里 shape = shape.duplicate(),否则一个蹲下,所有人一起变矮。

镜头晃动(head bob)

人走路时头部会有规律地上下左右摆动。镜头晃动模拟的就是这个,让玩家"感觉"到自己在走路。它的数学很简单:

  • 维护一个相位 φ,每帧增加 水平速度 × bob_freq × delta。相位按走过的距离增长,所以走得快晃得快,停下时相位不变(下次起步从原来的地方接着晃)。
  • 横向偏移 x = sin(φ) × A × 0.6,纵向偏移 y = sin(2φ) × A。纵向的频率是横向的两倍:横向走一个周期(左、右各一次重心转移),纵向就上下两次(左脚、右脚各落地一次)。这样的 1:2 李萨如曲线是一个横放的 8 字。
  • 幅度随速度变化,最终偏移再用平滑插值过渡,停下时镜头慢慢回正,而不是瞬间归零。
player/player.gd第 192–202 行在工程中查看 ↗
func _update_head_bob(delta: float) -> void:
	var speed := Vector2(velocity.x, velocity.z).length()
	var target := Vector3.ZERO
	if is_on_floor() and speed > 0.5:
		# 相位按"走过的距离"增长:走得越快,晃得越快;停下时相位不变
		_bob_phase += speed * bob_freq * delta
		var amp := bob_amount * minf(speed / walk_speed, 1.6)
		# 横向走 1 个周期,纵向走 2 个周期(左右脚各落地一次)→ 轨迹是横放的 8 字
		target = Vector3(sin(_bob_phase) * amp * 0.6, sin(_bob_phase * 2.0) * amp, 0.0)
	# 帧率无关的平滑:系数 1 - exp(-k·dt),不管 30 帧还是 144 帧,过渡快慢都一样
	_bob_offset = _bob_offset.lerp(target, 1.0 - exp(-10.0 * delta))
把晃动幅度拖到 0.15,冲刺几步:晃得很明显,有的人会觉得晕(这是很多游戏把 head bob 做成可关闭选项的原因)。拖到 0 关掉,走路像坐在轮椅上平移。把晃动频率拖到 3,像小碎步。

冲刺时的 FOV 变化

player/player.gd第 207–210 行在工程中查看 ↗
func _update_fov(delta: float) -> void:
	var moving_fast := Vector2(velocity.x, velocity.z).length() > walk_speed * 1.1
	var target := sprint_fov if (_sprinting and moving_fast) else fov
	camera.fov = lerpf(camera.fov, target, 1.0 - exp(-8.0 * delta))

冲刺时把 FOV 从 75° 平滑地加到 86°,画面边缘的东西向外"冲",速度感明显增强。条件里加了"实际速度超过行走速度的 1.1 倍":只按住 Shift 但撞着墙,FOV 不会变。

帧率无关的平滑插值

上面的代码里反复出现 lerp(当前, 目标, 1.0 - exp(-k * delta))。为什么不直接写 lerp(当前, 目标, 0.1)?

lerp(a, b, 0.1) 的意思是"每帧走剩余距离的 10%"。n 帧后剩下的距离是 0.9ⁿ。问题在于"每帧":30 帧的机器一秒后剩 0.9³⁰ ≈ 4%,144 帧的机器一秒后剩 0.9¹⁴⁴ ≈ 0.00003%。同一段代码,在不同的电脑上过渡速度差了好几倍,高刷新率的玩家看到的镜头"更跟手",低帧率的玩家觉得"很肉"。

想要"经过时间 T 后剩下 e^(-kT)",与帧率无关,每帧的剩余比例就应该是 e^(-k·dt),因为 e^(-k·dt₁) × e^(-k·dt₂) × … = e^(-k·(dt₁+dt₂+…))。所以插值系数取 1 - e^(-k·dt)。k 的含义也很直观:大约 3 / k 秒后完成 95%(k = 10 时约 0.3 秒)。

player/player.gd第 171–187 行落地下沉和后坐力的两级平滑在工程中查看 ↗
func _on_landed(fall_speed: float) -> void:
	# 落得越快,镜头往下沉得越多(最多 0.3 米)
	_land_target = -clampf((fall_speed - 2.0) * 0.035, 0.0, 0.3)


func _update_camera(delta: float) -> void:
	_update_head_bob(delta)
	# 两级平滑:目标值先"弹"出去再慢慢回零,实际值快速追目标。这样有冲击感又不生硬。
	_land_offset = lerpf(_land_offset, _land_target, 1.0 - exp(-25.0 * delta))
	_land_target = lerpf(_land_target, 0.0, 1.0 - exp(-7.0 * delta))
	_recoil = _recoil.lerp(_recoil_target, 1.0 - exp(-30.0 * delta))
	_recoil_target = _recoil_target.lerp(Vector2.ZERO, 1.0 - exp(-6.0 * delta))
	camera.position = _bob_offset + Vector3(0.0, _land_offset, 0.0)
	camera.rotation.x = _recoil.x
	camera.rotation.y = _recoil.y
	camera.rotation.z = lerpf(camera.rotation.z, _tilt * deg_to_rad(1.5), 1.0 - exp(-8.0 * delta))
	_update_fov(delta)

落地下沉和后坐力用的是一个小技巧:两级平滑。落地时把"目标偏移"瞬间设成 -0.2 米,然后目标值以较慢的速度(k = 7)回到 0,实际值以很快的速度(k = 25)追赶目标。结果是镜头先快速往下一沉,再缓缓回来,比单纯的一级插值更有"冲击感"。下一章的后坐力是同样的结构。

只有在 _process 里(帧时间不固定)才必须这样写。_physics_process 的 delta 是固定的(默认 1/60 秒),写 lerp(a, b, 0.1) 结果也是稳定的,但一旦有人改了 physics_ticks_per_second,手感就全变了。养成习惯,统一用 1 - exp(-k * delta)。另外,Godot 3 里的 move_and_slide(velocity, Vector3.UP) 在 Godot 4 里不带参数:速度写进 velocity 属性,地面方向是 up_direction 属性。

为什么不把鼠标的上下移动也转在 Player(CharacterBody3D)上?

  1. CharacterBody3D 不能绕 X 轴旋转
  2. 身体朝向决定移动方向,低头时按 W 会往地里走,胶囊也会倾斜
  3. 这样会增加绘制调用
  4. _unhandled_input 收不到竖直方向的鼠标移动

移动方向是用身体的 basis 换算的。身体如果跟着低头,"前方"就指向地下了。所以身体只负责偏航,俯仰交给 Head。

在 Web 版里,为什么不能在 _ready() 里直接设置 MOUSE_MODE_CAPTURED?

  1. Web 版不支持鼠标锁定
  2. _ready 时画布还不存在
  3. 浏览器只接受在用户点击或按键的处理过程中发出的锁定请求
  4. 需要先调用 Input.warp_mouse()

Pointer Lock API 要求"用户手势"。游戏启动时没有用户操作,请求会被拒绝。所以要做一个"点击画面开始"的提示层。

pos = lerp(pos, target, 0.2) 写在 _process 里。游戏从 60 fps 掉到 30 fps 时,过渡会怎样?

  1. 变慢:每秒的插值次数减半,过渡时间变长
  2. 变快
  3. 不变
  4. 直接跳到目标

每帧走剩余距离的 20%,帧数少了,单位时间内走的总比例就少了。60 fps 下 0.25 秒剩 0.8¹⁵ ≈ 3.5%,30 fps 下剩 0.8^7.5 ≈ 19%。用 1 - exp(-k·dt) 就不会受帧率影响。

小结

  • 身体(CharacterBody3D)只转偏航,头(Node3D)只转俯仰,相机层做表演效果。
  • 鼠标视角用 _unhandled_input + screen_relative,俯仰角要限制。
  • Web 上鼠标锁定必须由点击触发,Esc 由浏览器处理,所以要每帧检查锁定状态来决定暂停。
  • 地面移动用 move_toward 实现恒定加速度和摩擦,空中按比例削弱。
  • 跳跃高度 h = v² / 2g;蹲下改胶囊高度,站起前用 test_move 检查头顶。
  • head bob 是横向 sin(φ)、纵向 sin(2φ) 的 8 字轨迹,相位按距离增长。
  • 平滑插值用 lerp(a, b, 1 - exp(-k·dt)),结果与帧率无关。

下一章给这个控制器装上武器,再放出会寻路、会开枪的敌人。