「手感」是可以拆开的
「手感」是游戏圈最玄的一个词。玩家说不清,评测说不清,连很多从业者都只能说「多调调就有了」。这一章要把它拆开:十二个有名字的零件,每一个三五行代码,每一个都能被单独打开和关闭。然后你会看到一个数字 —— 十二项全关,跳跃高度 37.1 像素;十二项全开,37.3 像素。手感全变了,而跳多高几乎没动。
先玩,再读
下面这台东西是真的 —— 一台完整的 2D 平台跳跃引擎,物理是真的在算,不是动画。点一下画面让它获得焦点,然后用方向键移动、空格跳。
建议这样试:先点「全部关掉」,跑两圈。记住那种感觉。
全关的时候,你大概会觉得这个角色像一个被推着走的纸箱:按左就瞬间往左,松手就瞬间停死,跳起来直上直下,落地悄无声息。它「能用」,每一行物理都是对的,但没有一处让人舒服。
现在点「全部打开」,再跑两圈。
同一段代码、同一个重力、同一个起跳初速度。但它突然变成了一个你愿意一直跳的东西。
看那个统计条里的「跳跃高度」:
- 十二项全关:37.1 像素,滞空 0.458 秒
- 十二项全开:37.3 像素,滞空 0.442 秒
差 0.2 个像素。这十二个零件,几乎没有一个改变了「跳多高」这件事。
它们改变的是别的东西 —— 那个东西没有物理量,只有一个名字:手感。
十二个零件,分成四组
把那十二个开关按它们在骗谁分组,会清楚很多:
| 组 | 零件 | 它在做什么 |
|---|---|---|
| A. 重量 | 加速度移动、松手滑行、转身制动 | 让角色有质量感,而不是一个坐标 |
| B. 宽容 | 土狼时间、输入缓冲 | 替玩家的手误买单(第 10 章整章讲这个) |
| C. 曲线 | 可变跳跃高度、下落更重、顶点悬停 | 把一条抛物线掰成「玩起来对」的形状 |
| D. 表现 | 挤压拉伸、起落烟尘、落地震屏、落地顿帧 | 让眼睛相信刚才发生了什么(第 11、12 章) |
这一章重点讲 A 和 C —— B 和 D 各有专门的一章。
A 组:重量 —— 为什么「瞬间」是错的
关掉「加速度移动」时,代码是这样的:
velocity.x = direction * MAX_SPEED # 按下即满速,松开即静止
这段代码完全正确,而且是很多教程里的写法。它的问题不在物理,在感知:
现实世界里没有任何东西能瞬间从 0 加速到全速。你的大脑一辈子都在观察有质量的物体,它对「瞬间到达全速」的反应是:这个东西没有重量,它不是一个物体,它是一个数字。
| 参数 | 调大会怎样 | 调小会怎样 |
|---|---|---|
| 地面加速度 | 响应快、跟手,偏「街机」 | 笨重、有惯性,偏「写实」 |
| 地面摩擦 | 停得干脆,精准落点容易 | 会滑,像踩在冰上 |
| 空中加速度 | 空中能大幅修正,容错高 | 起跳前就要想好,硬核 |
第三项是类型的分水岭:空中控制力强 = 蔚蓝、马力欧(容错,鼓励尝试);空中控制力弱 = 初代波斯王子、《I Wanna Be The Guy》(起跳即定终点,硬核)。
这不是好坏问题,是你想让玩家在什么时候做决定 —— 起跳前,还是起跳后。
转身制动:那个你从没注意过的零件
「转身制动」是十二个里最不起眼的一个,但关掉它你会立刻不舒服。
问题场景:你在全速向右跑,突然想往左。如果左右用同一个加速度,那你要先花时间减速到 0,再花时间加速到左边的全速 —— 中间那段感觉像角色不听话。
解法是三行:
var accel := ACCEL # 方向和当前速度相反 = 正在掉头,给它更大的加速度 if direction * velocity.x < 0: accel *= TURN_MULT # 通常 1.8 ~ 2.5 velocity.x = move_toward(velocity.x, direction * MAX_SPEED, accel * delta)
这在物理上完全说不通(凭什么掉头时摩擦力变大?)。但玩起来对,因为它匹配了玩家的意图:我按左的那一刻,我要的是「往左」,不是「减速」。
手感设计里,「物理正确」和「玩起来对」经常是矛盾的,而你永远该选后者。
你不是在做物理仿真。你是在做一个让玩家的意图能顺畅表达出来的界面 —— 而玩家的意图不遵守牛顿定律。
C 组:曲线 —— 把抛物线掰成对的形状
这一组是最反直觉、也是效果最立竿见影的一组。
可变跳跃高度:一个键,两种跳
关掉它时,跳跃是固定的:按一下,永远跳 37 像素。这意味着玩家只有一种跳。
打开它之后,松开跳键的瞬间会把上升速度砍掉:
func _physics_process(delta: float) -> void:
# ...
# 松开跳键 且 还在上升 → 砍掉一部分上升速度
if Input.is_action_just_released("jump") and velocity.y < 0:
velocity.y *= CUT_MULT # 0.4 ~ 0.5
三行代码,玩家立刻拥有了一整条连续的跳跃高度 —— 轻点是小跳,按住是满跳,中间随便取。而且这个技巧是玩家自己发现的,不需要教。
因为它是全书第 4 章那三个「加深度杠杆」里,成本最低的一次「加修饰维度」。
你没有加新按键、没有加新机制、没有增加玩家的学习负担。但你把一个「跳」变成了「跳多高由我决定的跳」—— 动词的表达力翻了几倍。
几乎所有 2D 平台游戏都有这三行。如果你的游戏没有,玩家会觉得「不跟手」,但说不出为什么。
非对称重力:上升和下落用不同的重力
这是最违反物理直觉的一条,也是效果最明显的一条。
看实验台里的数据(这些数字是引擎当场跑出来的):
十二项全关(对称重力)
上升 0.225 s 下落 0.225 s 滞空 0.458 s
只打开「下落更重」
上升 0.225 s 下落 0.175 s 滞空 0.408 s
↑ 下落时间缩短了 22%
为什么这样更好玩?因为上升和下落这两段,对玩家的意义完全不同:
- 上升段是你在表达意图的时间 —— 你决定跳多高、往哪飘。这段慢一点,玩家有时间思考和微调。
- 下落段是你在等结果的时间 —— 决定已经做完了,你只是在等着落地。这段快一点,减少无聊的等待。
一句话:让「有意义的时间」变长,让「没意义的时间」变短。
const GRAVITY_UP := 1500.0 const GRAVITY_DOWN := 2325.0 # ≈ 1500 × 1.55 func _physics_process(delta: float) -> void: var g := GRAVITY_UP if velocity.y < 0 else GRAVITY_DOWN velocity.y += g * delta
加了非对称重力之后,很多人发现跳跃手感变好了,但角色从平台上走下去时变得很奇怪 —— 掉得太快,像被吸下去。
原因:走下平台时 velocity.y 从 0 开始,立刻进入「下落」分支,吃到 1.55 倍重力。但玩家的心理预期是「我只是走了下去」,不是「我在做一次跳跃的下落段」。
修法:区分「主动跳跃后的下落」和「走下去的下落」,后者用正常重力;或者给下落重力一个渐进的过渡而不是突变。
顶点悬停:在最高点多留一瞬
最微妙的一个。当纵向速度接近 0(也就是快到最高点)时,把重力临时调低:
const APEX_BAND := 35.0 # |vy| 小于它就算「在顶点附近」 const APEX_MULT := 0.55 var g := GRAVITY_UP if velocity.y < 0 else GRAVITY_DOWN if absf(velocity.y) < APEX_BAND: g *= APEX_MULT velocity.y += g * delta
效果(引擎实测):滞空从 0.458 秒变成 0.492 秒,而跳跃高度只从 37.1 变成 37.3。
多出来的 34 毫秒,全部花在了最高点附近 —— 也就是玩家最需要看清落点、最需要微调水平位置的那一瞬间。
玩家永远不会说「这个游戏的顶点悬停做得真好」。他只会说「跳得很准」。
三个零件都在做同一件事:把时间重新分配给玩家真正需要它的那些瞬间。
- 可变跳跃高度 → 把「跳多高」的决定权交还给按键时长
- 非对称重力 → 把时间从「等落地」挪到「决定跳多高」
- 顶点悬停 → 把时间集中到「看清落点」的那一刻
抛物线本来是自然界的形状。你要把它掰成玩家注意力的形状。
为什么玩家察觉不到这些
这是手感设计最迷人也最反直觉的性质:做得好的手感是隐形的。
玩家不会说「这游戏有土狼时间」,他会说「这游戏很跟手」。他不会说「这个非对称重力比例调得好」,他会说「跳起来很舒服」。
而反过来,做得差的手感也是隐形的 —— 玩家不会说「你缺了输入缓冲」,他会说「这游戏有点难受」,然后关掉,再也不回来。他甚至可能觉得是自己菜。
你永远不能指望玩家告诉你手感问题出在哪。他们只能告诉你「难受」。
所以这十二个零件的清单,本质上是一份排查表:当有人说你的游戏「不跟手」,你不需要猜,你打开这张表一条一条对。
这就是把玄学变成工作的全部意义 —— 玄学没法排查,清单可以。
把它们装到一起:一个完整的实现
下面是把 A 组和 C 组全部装上之后的 CharacterBody2D。B 组(宽容)留到下一章,D 组(表现)留到第 11 章。
extends CharacterBody2D
# ── 基础物理 ──────────────────────────────
const MAX_RUN := 112.0
const JUMP_SPEED := 340.0
const GRAVITY := 1500.0
# ── A 组:重量 ────────────────────────────
const ACCEL := 900.0 # 地面加速度
const AIR_ACCEL := 600.0 # 空中加速度(比地面小 = 起跳前要想好)
const FRICTION := 1300.0 # 地面摩擦
const AIR_FRICTION := 380.0
const TURN_MULT := 2.2 # 掉头时的加速度倍率
# ── C 组:曲线 ────────────────────────────
const CUT_MULT := 0.45 # 松开跳键,上升速度乘以它
const FALL_MULT := 1.55 # 下落时重力倍率
const APEX_BAND := 35.0 # 顶点判定带
const APEX_MULT := 0.55 # 顶点重力倍率
func _physics_process(delta: float) -> void:
_apply_horizontal(delta)
_apply_gravity(delta)
_handle_jump()
move_and_slide()
func _apply_horizontal(delta: float) -> void:
var dir := Input.get_axis("move_left", "move_right")
if dir != 0.0:
var a := ACCEL if is_on_floor() else AIR_ACCEL
if dir * velocity.x < 0.0: # 正在掉头
a *= TURN_MULT
velocity.x = move_toward(velocity.x, dir * MAX_RUN, a * delta)
else:
var f := FRICTION if is_on_floor() else AIR_FRICTION
velocity.x = move_toward(velocity.x, 0.0, f * delta)
func _apply_gravity(delta: float) -> void:
var g := GRAVITY
if velocity.y > 0.0:
g *= FALL_MULT # 下落更重
if absf(velocity.y) < APEX_BAND:
g *= APEX_MULT # 顶点悬停
velocity.y += g * delta
func _handle_jump() -> void:
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = -JUMP_SPEED
# 可变跳跃高度
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= CUT_MULT
上面的常数是这本书这台引擎调出来的,能直接用,但它们和你的像素尺寸绑死了。如果你的角色比这个大三倍,全部要按比例放大。
更重要的是:这些数字必须能被快速调整。把它们写成 @export 变量,你就能在 Godot 编辑器里一边跑一边拖:
@export_group("手感")
@export var max_run: float = 112.0
@export var jump_speed: float = 340.0
@export_range(1.0, 3.0) var fall_mult: float = 1.55
@export_range(0.1, 1.0) var cut_mult: float = 0.45
第 20 章会把这件事做到底 —— 让整套手感参数变成一个可以热重载的配置文件。手感是调出来的,而调的次数取决于每次调有多快。
一个建议的调参顺序
面对十二个开关,从哪开始?这个顺序是有讲究的:
- 先定基础三个数:最大速度、跳跃高度(用格数想,比如「能跳 2.5 格」)、重力。调到「跳跃高度和关卡格子对得上」为止。
- 加 A 组(重量)。这时角色应该已经有了基本的质感。如果这一步不对,后面加什么都救不回来。
- 加 C 组(曲线)。跳跃开始「有性格」。
- 加 B 组(宽容)。你自己可能感觉不到差别 —— 这很正常,因为你已经是这个游戏的高手了。相信数据(下一章会给你数据)。
- 最后加 D 组(表现)。这是糖,不是饭。如果前四步不对,D 组只会让一个难玩的游戏变成一个花哨的难玩游戏。
先做 D 组。
因为 D 组最有成就感 —— 加个粒子、加个震屏,立刻看起来专业多了。于是很多人在手感还是一坨的时候,花两周做特效。
第 12 章会用一个 demo 让你看到 D 组过量的后果。这里先记住:D 组是放大器,它会把好手感放大,也会把坏手感放大。
本章小结
- 手感不是玄学,是十二个有名字、能单独开关的零件。
- 核心事实:这十二项几乎不改变跳跃高度(37.1 → 37.3 像素),只改变体验。
- 四组:A 重量、B 宽容、C 曲线、D 表现。
- A 组让角色有质量;转身制动物理上说不通,但匹配玩家意图。
- C 组的共同逻辑:把时间重新分配给玩家真正需要的瞬间。下落更重(−22% 下落时间)、顶点悬停(+34 毫秒在最高点)。
- 好手感是隐形的 —— 所以你永远不能指望玩家指出问题在哪。这份清单是你的排查表。
- 调参顺序:基础数值 → A → C → B → D。先做 D 是最常见的错误。
「手感」不是平台游戏专属的。每种类型都有它的十二个零件,只是名字不同:
- 卡牌:手感住在拖拽和落位里。卡牌跟手指的延迟、松手时的吸附、打出时的位移曲线、伤害数字的时序。《杀戮尖塔》和一个粗糙卡牌游戏的差距,八成在这里。对应的「宽容」是:拖到差不多的位置就吸附上去,而不是要求精确落点。
- 射击:零件包括后坐力曲线、准星扩散、命中判定的宽容(子弹判定球比视觉模型大)、击杀确认音。「土狼时间」的等价物是「近失判定」 —— 你差一点点没打中时,游戏悄悄算你打中了。
- 塔防 / 策略:手感住在放置上 —— 网格吸附、预览的即时性、放下时的确认反馈、误操作的可撤销性。这类游戏最重要的宽容是「撤销」:允许在 2 秒内反悔,玩家的操作意愿会显著提高。
- 模拟经营:手感住在选择和框选里。批量操作的顺滑度、相机的缩放曲线、UI 的响应延迟。这类游戏的手感问题最容易被忽略,因为它不「刺激」—— 但玩家一天要操作几千次。
- 叙事 / 视觉小说:手感住在文字的推进节奏里。逐字显示的速度、点击跳过的响应、自动播放的停顿时长。一个反直觉的点:这类游戏也需要「输入缓冲」 —— 玩家在文字还没显示完时点了一下,该是「立刻显示全部」还是「跳到下一句」?选错会让人非常烦躁。
通用规律:找到玩家重复得最多的那个操作,然后问「它的十二个零件是什么」。