FPS(上):第一人称控制器
用 CharacterBody3D 写一个完整的第一人称控制器:身体和头分开转、鼠标视角与 Web 上的鼠标锁定、加速度和摩擦、跳跃和重力、蹲下与头顶检测、镜头晃动、冲刺 FOV,以及帧率无关的平滑插值。
第一人称游戏里,玩家看到的一切都是从"眼睛"出发的,控制器的手感直接决定了游戏好不好玩:鼠标是不是跟手、停下来会不会滑、跳起来是不是太飘、走路时镜头晃得舒不舒服。本章逐个拆开这些细节,每一项都对应示例里的一个滑块。
节点结构:身体管左右,头管上下
[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 的原点上:原点就是脚底,这样改胶囊高度(蹲下)时脚不会离地。
鼠标视角
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 轴只是把符号反过来。
鼠标锁定与 Web 的限制
FPS 需要把鼠标"锁"在窗口里:光标隐藏,鼠标可以无限地往一个方向移动。Godot 里一行代码就够了:Input.mouse_mode = Input.MOUSE_MODE_CAPTURED。在桌面版上这样就行,但在浏览器里有两条额外的规则:
- 必须由用户的操作触发。浏览器的 Pointer Lock API 只接受在点击或按键的处理过程中发出的请求,游戏一启动就自动锁定会被拒绝。所以示例先显示一层"点击画面开始"的提示,在点击事件里请求锁定。
- 按 Esc 由浏览器解除锁定,游戏通常收不到这个 Esc 按键。所以不能靠"监听 Esc 来暂停",而要每帧检查锁定状态:锁过、现在又不是锁定状态,就说明玩家按了 Esc,进入暂停。
## 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,暂停就形同虚设。地面移动:加速度和摩擦
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.xInput.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 的加速度通常大得多:玩家需要精确地在掩体之间横移、急停瞄准,"滑"的感觉会直接影响射击。
跳跃和重力
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?因为同样的跳跃高度,重力越大,滞空时间越短,跳起来越"干脆"。大多数动作游戏的重力都比现实大。
蹲下与头顶检测
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 的好处是不用额外的节点,而且检测形状和角色完全一致。
另外注意:碰撞体是瞬间变矮的(这样蹲下躲子弹立刻生效),而眼睛高度是平滑过渡过去的(不然镜头会"咔"地一跳)。
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 字。 - 幅度随速度变化,最终偏移再用平滑插值过渡,停下时镜头慢慢回正,而不是瞬间归零。
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))冲刺时的 FOV 变化
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 秒)。
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)上?
- CharacterBody3D 不能绕 X 轴旋转
- 身体朝向决定移动方向,低头时按 W 会往地里走,胶囊也会倾斜
- 这样会增加绘制调用
- _unhandled_input 收不到竖直方向的鼠标移动
移动方向是用身体的 basis 换算的。身体如果跟着低头,"前方"就指向地下了。所以身体只负责偏航,俯仰交给 Head。
在 Web 版里,为什么不能在 _ready() 里直接设置 MOUSE_MODE_CAPTURED?
- Web 版不支持鼠标锁定
- _ready 时画布还不存在
- 浏览器只接受在用户点击或按键的处理过程中发出的锁定请求
- 需要先调用 Input.warp_mouse()
Pointer Lock API 要求"用户手势"。游戏启动时没有用户操作,请求会被拒绝。所以要做一个"点击画面开始"的提示层。
pos = lerp(pos, target, 0.2) 写在 _process 里。游戏从 60 fps 掉到 30 fps 时,过渡会怎样?
- 变慢:每秒的插值次数减半,过渡时间变长
- 变快
- 不变
- 直接跳到目标
每帧走剩余距离的 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)),结果与帧率无关。
下一章给这个控制器装上武器,再放出会寻路、会开枪的敌人。