摄像机是你的第二个玩家
玩家只能对他看得见的东西做决策。所以决定他看得见什么的那个东西 —— 摄像机 —— 不是一个技术细节,它是玩法的一部分。一个跟得太紧的镜头会让人晕,一个看不见前方的镜头会让人掉进坑里然后骂你的关卡设计,而那关卡其实没问题。
四种跟随,同一段移动
下面这个 demo 里,角色的运动轨迹在四种模式下一模一样 —— 它是一段脚本控制的自动来回跑跳,完全不受你影响。变的只有镜头怎么跟。
依次切换四种模式,重点注意统计条里那个「镜头抖动」的数字。
① 硬跟随:角色永远在正中央
camera.position = player.position
一行代码,而且看起来完全合理 —— 角色永远在画面中心,不会跑出去。
问题在于:角色的每一次微小抖动,都被镜头忠实地放大成了整个世界的抖动。
看那个「镜头抖动」的数字,硬跟随模式下它一直在剧烈跳动。角色跳一下,整个世界跳一下;角色落地时被地面挡了一帧,整个世界也顿了一下。
玩久了会晕。而且玩家永远不会说「你的摄像机有问题」,他会说「玩久了有点难受」,然后归因到别的地方。
② 平滑跟随:追,但慢一点
# 帧率无关的指数趋近(第 12 章那个式子) camera.position = camera.position.lerp( player.position, 1.0 - exp(-SMOOTH_SPEED * delta) )
立刻好很多。角色的高频抖动被过滤掉了,镜头只跟随大的位移。
但它引入了一个新问题:角色永远不在正中央 —— 移动时它总是落在中心稍后一点的位置,因为镜头在追。快速移动时角色会贴到画面边缘。
SMOOTH_SPEED 这个参数控制这个取舍:调大 → 接近硬跟随(跟得紧但抖);调小 → 更平滑但延迟感强。
③ 死区:中间那块区域,镜头不动
这是大多数 2D 游戏实际在用的方案,也是第一个真正「设计过」的方案。
在画面中央划一个矩形。角色在这个矩形里面移动时,镜头完全不动;角色顶到矩形边缘时,才推着镜头走。
const DEADZONE := Vector2(34, 24) func _process(_delta: float) -> void: var rel := player.global_position - global_position if rel.x > DEADZONE.x: global_position.x += rel.x - DEADZONE.x elif rel.x < -DEADZONE.x: global_position.x += rel.x + DEADZONE.x # 纵向同理,但通常死区要大得多 —— 见下
它把「角色的小动作」和「镜头的移动」解耦了。
- 角色跳一下、微调一步、被击退一点 —— 镜头纹丝不动,画面稳定
- 角色真的要去别的地方 —— 镜头跟上
看 demo 里的镜头抖动数字:死区模式下它大部分时间是 0。这就是「稳」的来源。
纵向死区要比横向大得多
这是一条非常实用、也很少被讲的经验:
在平台游戏里,纵向死区应该大到能容纳一次完整的跳跃。
原因:玩家跳跃是高频行为。如果纵向死区太小,每跳一次镜头就上下动一次 —— 那是最快的晕吐方式。
标准做法:纵向死区 ≥ 跳跃高度,这样普通跳跃完全不影响镜头。只有当角色真的上到了新的一层(或者掉下去了),镜头才跟着换层。
更讲究的做法是只在落地时才调整纵向镜头 —— 角色在空中时纵向镜头完全锁死,落地后再平滑地对齐到新高度。《空洞骑士》和大多数银河城都是这么做的。
④ 前瞻:往你要去的方向多看一点
最后一层,也是最能提升「玩得准」的一层。
思路:把镜头中心朝着角色移动的方向偏移一段距离。玩家往右跑,镜头就往右多推 40 像素 —— 于是他能看见更多前方的东西。
const LOOK_AHEAD := 44.0 func _process(delta: float) -> void: var dir := signf(player.velocity.x) # 目标偏移量。注意要平滑过渡,否则转身时镜头会猛地甩过去 look_offset = lerpf(look_offset, dir * LOOK_AHEAD, 1.0 - exp(-2.6 * delta)) # 然后把 look_offset 加到死区计算的结果上
看 demo 里「前方可见」那一栏的数字变化。开启前瞻后,玩家能看到的前方距离明显变长。
这意味着:他有更多时间对即将出现的东西做出反应。
而这直接影响你能设计多快的关卡 —— 玩家的反应时间预算,一半是由摄像机给的。
如果你的游戏里玩家总是「突然撞到东西」,第一个该检查的不是关卡,是摄像机的前瞻距离。
如果 look_offset 直接跟着方向跳变,那么玩家来回小幅移动时,镜头会疯狂左右甩 —— 比不加前瞻还难受。
两条对策:
- 平滑过渡(上面代码里的
lerp),而且要比死区跟随更慢(2–3 的系数,不是 8) - 加一个速度阈值:只有当水平速度超过最大速度的 60% 时才更新前瞻方向。小幅移动不触发。
摄像机是关卡设计的一部分
上面四层是通用的基础。但摄像机真正有意思的地方在于:它可以为具体的场景服务。
| 技巧 | 做法 | 用来 |
|---|---|---|
| 镜头区域 | 关卡里放置矩形区域,角色进入时镜头被约束在这个矩形内 | 房间感、防止看到关卡外的空白 |
| 兴趣点吸引 | 在重要物体附近放一个「吸引点」,镜头朝它偏移 | 无字引导(第 21 章)—— 不用箭头就能告诉玩家往哪走 |
| 拉远 | 危险 / Boss 出现时视野拉远 | 让玩家看见全局,同时制造压迫感 |
| 锁定 | Boss 战时镜头固定不动 | 让战斗场地成为一个明确的舞台 |
| 预览摇镜 | 进入新房间时镜头先扫一遍再回到角色 | 免费的信息传递(「这里有三个机关」) |
这个技巧几乎是免费的,但它能取代大量的 UI 提示。
做法:在关键物体(下一个目标、隐藏通道的入口、需要注意的机关)上挂一个吸引组件,当玩家靠近时,镜头中心朝它偏移一小段(10–30 像素)。
玩家完全不会意识到镜头动了。他只会觉得「我好像自然而然就注意到那边了」。
这是引导玩家注意力最不着痕迹的方法 —— 比箭头、比闪光、比文字提示都高级,而且不会打断沉浸感。
关于晕动症:这是无障碍问题
有相当一部分人(估计在 5%–15% 之间,2D 游戏比 3D 低但确实存在)会因为镜头运动感到不适。他们通常不会给你反馈,只会默默关掉游戏。
成本很低的几条:
- 屏幕震动开关(必做)。设置里给一个滑块,从 0% 到 100%。
- 镜头晃动 / 呼吸效果开关。有些游戏会给镜头加一个持续的微小晃动来表现「呼吸」—— 这是晕动症的头号来源。
- 过渡动画速度。快速的推拉镜头是第二号来源。
大多数「第一个游戏不要做」的建议在本书里出现了很多次(不做难度档位、不做设置菜单、不做存档)。震动开关是个例外,它值得做。
因为它的成本是十分钟(一个滑块乘以震动幅度),而它决定了一部分人能不能玩你的游戏。这个投入产出比不可能更好了。
把四层装到一起
一个完整的 2D 平台摄像机,四层的组合顺序是:
extends Camera2D @export var deadzone := Vector2(34, 40) @export var look_ahead := 44.0 @export var smooth_speed := 6.0 @export var shake_strength := 1.0 # 玩家可在设置里调到 0 var _look_offset: float = 0.0 var _shake: float = 0.0 func _process(delta: float) -> void: var target := _compute_target(delta) # 第 ② 层:整体再做一次平滑,吃掉剩余的高频抖动 global_position = global_position.lerp( target, 1.0 - exp(-smooth_speed * delta)) _apply_shake(delta) _clamp_to_room() # 镜头区域约束 func _compute_target(delta: float) -> Vector2: var t := global_position # 第 ④ 层:前瞻(带速度阈值,防止小幅移动导致甩镜) var vx := player.velocity.x if absf(vx) > player.max_run * 0.6: _look_offset = lerpf(_look_offset, signf(vx) * look_ahead, 1.0 - exp(-2.6 * delta)) # 第 ③ 层:死区 var focus := player.global_position + Vector2(_look_offset, 0) var rel := focus - t if absf(rel.x) > deadzone.x: t.x += rel.x - signf(rel.x) * deadzone.x # 纵向:只在落地时对齐,空中锁死 if player.is_on_floor() and absf(rel.y) > deadzone.y: t.y += rel.y - signf(rel.y) * deadzone.y return t func _apply_shake(delta: float) -> void: if _shake > 0.0: offset = Vector2(randf_range(-1, 1), randf_range(-1, 1)) \ * _shake * shake_strength _shake = move_toward(_shake, 0.0, 8.0 * delta) else: offset = Vector2.ZERO
- 摄像机不要挂在角色下面。做成场景的独立子节点,通过引用去跟随。挂在角色下面意味着角色的任何形变(挤压拉伸!)都会传给镜头。
- 用
offset做震动,用global_position做跟随。两者分开,互不干扰,逻辑清晰得多。 - 像素游戏要注意取整。如果镜头位置是小数,像素会出现抖动和撕裂。开启项目设置里的
snap_2d_transforms_to_pixel,或者手动global_position = global_position.round()。 - Godot 4 的
Camera2D自带position_smoothing和drag_margin,能覆盖第 ② 和第 ③ 层的基础需求。如果只要基础效果,直接用内置的就行 —— 但前瞻和落地对齐需要自己写。
本章小结
- 玩家只能对看得见的东西做决策,所以摄像机是玩法的一部分,不是技术细节。
- 四层,依次叠加:硬跟随 → 平滑 → 死区 → 前瞻。
- 死区解耦了「角色的小动作」和「镜头移动」,是「稳」的来源。
- 纵向死区要 ≥ 跳跃高度;更好的做法是空中锁死纵向,落地才对齐。
- 前瞻决定玩家的反应时间预算。玩家总是「突然撞到东西」时,先查前瞻而不是关卡。
- 前瞻必须平滑 + 加速度阈值,否则转身甩镜比不加还难受。
- 兴趣点吸引是最不着痕迹的注意力引导手段,几乎免费。
- 震动开关是唯一值得放进第一个游戏的设置项 —— 十分钟成本,无障碍收益。
- 塔防 / 策略:摄像机由玩家自己控制,设计重点转向缩放曲线和边缘滚动的手感。一条常被忽略的:缩放要以鼠标位置为中心,而不是屏幕中心 —— 否则玩家永远对不准想看的地方。
- 卡牌:几乎没有摄像机,但有等价物 —— 「视线焦点」。结算时要把玩家的注意力引到正在发生的地方(高亮、暗化其他区域、短暂放大)。这就是卡牌游戏的镜头语言。
- 模拟经营:核心问题是不同缩放级别下要显示不同信息(拉远看全局热力图,拉近看单个单位)。这是这类游戏最重要的镜头设计,也是《城市天际线》《缺氧》花最多力气的地方。
- 叙事解谜:镜头本身是叙事工具。缓慢的推近表达关注,拉远表达孤独,固定机位表达无力。这类游戏可以完全放弃「跟随」,改用固定机位 + 房间切换(初代生化危机、《Inside》的大量段落)。
- 横版动作 / 银河城:本章的四层全部适用,另加一条:「房间即镜头区域」。用镜头区域把关卡切成明确的房间,既解决了镜头约束,也白送了一个天然的检查点单位(第 7 章的失败成本)。