卷 III · 感CH 09深度 09/24 · 招牌

「手感」是可以拆开的

「手感」是游戏圈最玄的一个词。玩家说不清,评测说不清,连很多从业者都只能说「多调调就有了」。这一章要把它拆开:十二个有名字的零件,每一个三五行代码,每一个都能被单独打开和关闭。然后你会看到一个数字 —— 十二项全关,跳跃高度 37.1 像素;十二项全开,37.3 像素。手感全变了,而跳多高几乎没动。

★ 招牌 Demo十二个零件宽容非对称重力表现层

先玩,再读

下面这台东西是真的 —— 一台完整的 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
✕ 一个常见的连带 bug

加了非对称重力之后,很多人发现跳跃手感变好了,但角色从平台上走下去时变得很奇怪 —— 掉得太快,像被吸下去。

原因:走下平台时 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 毫秒,全部花在了最高点附近 —— 也就是玩家最需要看清落点、最需要微调水平位置的那一瞬间

玩家永远不会说「这个游戏的顶点悬停做得真好」。他只会说「跳得很准」。

◆ C 组的共同逻辑

三个零件都在做同一件事:把时间重新分配给玩家真正需要它的那些瞬间。

  • 可变跳跃高度 → 把「跳多高」的决定权交还给按键时长
  • 非对称重力 → 把时间从「等落地」挪到「决定跳多高」
  • 顶点悬停 → 把时间集中到「看清落点」的那一刻

抛物线本来是自然界的形状。你要把它掰成玩家注意力的形状

为什么玩家察觉不到这些

这是手感设计最迷人也最反直觉的性质:做得好的手感是隐形的。

玩家不会说「这游戏有土狼时间」,他会说「这游戏很跟手」。他不会说「这个非对称重力比例调得好」,他会说「跳起来很舒服」。

而反过来,做得差的手感也是隐形的 —— 玩家不会说「你缺了输入缓冲」,他会说「这游戏有点难受」,然后关掉,再也不回来。他甚至可能觉得是自己菜。

◆ 这对你意味着什么

你永远不能指望玩家告诉你手感问题出在哪。他们只能告诉你「难受」。

所以这十二个零件的清单,本质上是一份排查表:当有人说你的游戏「不跟手」,你不需要猜,你打开这张表一条一条对。

这就是把玄学变成工作的全部意义 —— 玄学没法排查,清单可以。

把它们装到一起:一个完整的实现

下面是把 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 章会把这件事做到底 —— 让整套手感参数变成一个可以热重载的配置文件。手感是调出来的,而调的次数取决于每次调有多快。

一个建议的调参顺序

面对十二个开关,从哪开始?这个顺序是有讲究的:

  1. 先定基础三个数:最大速度、跳跃高度(用格数想,比如「能跳 2.5 格」)、重力。调到「跳跃高度和关卡格子对得上」为止。
  2. 加 A 组(重量)。这时角色应该已经有了基本的质感。如果这一步不对,后面加什么都救不回来。
  3. 加 C 组(曲线)。跳跃开始「有性格」。
  4. 加 B 组(宽容)。你自己可能感觉不到差别 —— 这很正常,因为你已经是这个游戏的高手了。相信数据(下一章会给你数据)。
  5. 最后加 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 的响应延迟。这类游戏的手感问题最容易被忽略,因为它不「刺激」—— 但玩家一天要操作几千次。
  • 叙事 / 视觉小说:手感住在文字的推进节奏里。逐字显示的速度、点击跳过的响应、自动播放的停顿时长。一个反直觉的点:这类游戏也需要「输入缓冲」 —— 玩家在文字还没显示完时点了一下,该是「立刻显示全部」还是「跳到下一句」?选错会让人非常烦躁。

通用规律:找到玩家重复得最多的那个操作,然后问「它的十二个零件是什么」。