卷 III · 感CH 11深度 11/24

反馈:让世界回应你

你砍了一刀,敌人掉了 12 点血。这是事实。但玩家感受到的不是事实,是这一刀砍进去的实感 —— 而实感是六层东西叠出来的。这一章把这六层拆开,一层一层地加,你会亲眼看到同一个 12 点伤害,从「像在打空气」变成「砍进去了」。

打击感命中停顿反馈的三个职责时序音效

先叠一遍

下面这个 demo 里,六个开关全部关掉时,攻击只做一件事:敌人的血条变短。这在逻辑上完全正确 —— 伤害确实结算了。

点几下「攻击」感受一下,然后一层一层打开。

建议顺序:先只开命中停顿,攻击几次。你会立刻感到不一样 —— 只有 60 毫秒的静止,但那一下突然「砍进去了」。

然后依次加震动、击退、闪白、粒子、数字。最后全开,再全关,来回切几次。

◆ 这里发生的事

伤害数字从头到尾都是 12。敌人的血从头到尾都掉一样多。游戏逻辑一行都没变

变的只有一样东西:你有多相信自己刚才打中了它。

这就是反馈层的全部工作 —— 它不改变发生了什么,它改变玩家知不知道发生了什么、以及有多确信

反馈的三个职责

在开始拆六层之前,先分清楚反馈到底在干什么。它同时承担三件事,而这三件事的优先级是有顺序的:

职责回答玩家的问题缺了会怎样
① 确认 「我刚才那下生效了吗?」 玩家会重复操作、会怀疑游戏卡了。最致命。
② 信息 「效果好不好?离目标还有多远?」 玩家学不会变强,所有打法感觉都一样
③ 情绪 (不是问题,是感受:爽 / 危险 / 满足) 能玩,但不上瘾

顺序不能颠倒。很多人一上来就做第三层(华丽的特效),却没做好第一层(玩家不确定自己打中没有)。结果是一个很漂亮但让人困惑的游戏。

◆ 一个自检

把你游戏的音效全部关掉、特效全部关掉,只留下最基础的画面。

玩家还能分辨「打中了」和「没打中」吗?

不能 → 你的第 ① 层是靠特效撑着的,这很危险。特效应该是加强确认,不是提供确认。

能 → 很好,那你加的所有特效都是净收益。

六层,逐层拆

第一层:命中停顿(Hitstop / Hit Freeze)

六层里性价比最高的一层,也是最多人不知道的一层。

做法:命中的那一瞬间,让整个游戏冻结几十毫秒。攻击者和被攻击者都不动,粒子不动,时间停止。

# 最简单的实现:改时间缩放,然后用一个不受时间缩放影响的计时恢复
func hitstop(duration: float) -> void:
	Engine.time_scale = 0.0
	# 注意:SceneTreeTimer 默认受 time_scale 影响,第三个参数
	# process_always / ignore_time_scale 要设对,否则永远不会触发
	await get_tree().create_timer(duration, true, false, true).timeout
	Engine.time_scale = 1.0
✕ 那个把人卡死的坑

上面那行 create_timer 的第四个参数(ignore_time_scale)如果不设成 true,会发生什么?

time_scale = 0 之后,计时器也停了 —— 它永远不会触发,你的游戏就此冻结

这是新手实现命中停顿时几乎必踩的坑,而且症状(游戏直接卡死)看起来像个严重 bug,很多人因此放弃了这个机制。

更稳妥的做法是不用 Engine.time_scale,而是自己维护一个 freeze_left 变量,在各个 _physics_process 开头检查并跳过 —— 本书那台引擎就是这么做的,可控得多。

时长参考:

场景停顿时长感觉
轻击 / 普通攻击30 – 60 ms清脆
重击 / 暴击80 – 150 ms沉重
击杀 / 处决150 – 300 ms仪式感
超过 400 ms玩家会以为游戏卡了
◆ 为什么停顿会让人觉得「有力」

因为它模拟了阻力

现实里你用棍子打一个沙袋,棍子会被挡一下。这个「被挡一下」是重量和力量的直接证据。游戏里没有物理阻力,命中停顿就是它的替代品。

更妙的是:停顿的时长可以编码信息(第 ② 层职责)。打到弱点停久一点,打到护甲停短一点 —— 玩家不需要看任何数字,用手就能感觉到「这一下打得好」。

第二层:屏幕震动

被滥用得最严重的一层。用对了是重量,用过了是晕。

几条硬规则:

  • 幅度要小。2–4 像素通常就够了。新手常常做到 15 像素,那是地震不是打击。
  • 时间要短。100–200 毫秒,而且要快速衰减,不能是恒定幅度。
  • 要有方向。沿着攻击方向震,比随机方向震高级得多。
  • 要能被关掉。有相当一部分玩家会晕,这是无障碍问题,不是偏好问题。
# 带衰减的震动,比恒定幅度自然得多
var shake_amount: float = 0.0
var shake_decay: float = 8.0

func add_shake(amount: float) -> void:
	shake_amount = maxf(shake_amount, amount)   # 取最大值,不要累加

func _process(delta: float) -> void:
	if shake_amount > 0.0:
		offset = Vector2(
			randf_range(-1.0, 1.0),
			randf_range(-1.0, 1.0)
		) * shake_amount
		shake_amount = move_toward(shake_amount, 0.0, shake_decay * delta)
	else:
		offset = Vector2.ZERO

注意 add_shake 里用的是 maxf 而不是 +=震动一定不能累加 —— 否则同时打中五个敌人时,屏幕会飞出去。

第三层:击退与位移

敌人被打中时往后退一点,攻击者往前冲一点。

这一层的价值在于它是唯一能表达「力的方向」的反馈。震动和闪白都没有方向感,击退有。

关键细节:击退要有回弹。被打退之后要平滑地回来(或者保持),不能瞬移。用指数趋近:

knockback = knockback.move_toward(Vector2.ZERO, KNOCKBACK_DECAY * delta)
global_position += knockback * delta

第四层:受击闪白

最便宜的一层,一个 shader 就够了,而且它专门解决第 ① 层职责(确认)。

在拥挤的画面里 —— 一堆敌人、一堆特效 —— 闪白是最容易被眼睛捕捉到的信号,因为它改变的是整个轮廓的亮度,而不是加了一个小东西。

// 一个最小的受击闪白 shader(Godot 4,CanvasItem)
shader_type canvas_item;

uniform float flash_amount : hint_range(0.0, 1.0) = 0.0;
uniform vec4 flash_color : source_color = vec4(1.0);

void fragment() {
	vec4 tex = texture(TEXTURE, UV);
	// 只改颜色,保留原有的 alpha —— 否则透明区域也会被涂白
	COLOR.rgb = mix(tex.rgb, flash_color.rgb, flash_amount);
	COLOR.a = tex.a;
}

时长:1–3 帧(16–50 毫秒)。比想象中短得多。超过 100 毫秒会显得像在闪烁而不是被打了。

第五层:粒子

最直觉、也最容易做过头的一层(下一章整章讲这件事)。

两条建议:

  • 方向要对:粒子应该朝着攻击的反方向迸溅,这样它就同时携带了方向信息(第 ② 层职责)。乱飞的粒子只有装饰价值。
  • 数量要少:8–15 个通常就够。50 个粒子不会比 12 个爽两倍,只会遮住画面。

第六层:飘伤害数字

纯信息层。它的价值完全取决于玩家需不需要这个精确数值

  • 需要(RPG、构筑类、需要计算的游戏)→ 做,而且要清晰可读
  • 不需要(动作、平台、纯手感游戏)→ 不要做。它会抢走玩家看画面的注意力

《只狼》没有伤害数字,因为它要你看敌人的动作。《暗黑破坏神》满屏伤害数字,因为它要你比较装备。两者都是对的,因为它们要玩家看的东西不同。

被严重低估的一层:音效

上面六层全是视觉。而在实践中,音效对打击感的贡献大概占一半,成本却低得多。

◆ 一个自己就能做的实验

找一段你觉得打击感很好的游戏视频,把声音关掉再看一遍。

你会发现打击感掉了一大半 —— 那些华丽的特效突然变得很轻飘。

然后反过来:只听声音,不看画面。你仍然能清楚地分辨出「这一下打中了没有」「这一下重不重」。

音效独自就能完成第 ① 层和第 ② 层职责。而它是所有反馈层里最便宜的 —— 一个音效文件几百 KB,不占性能,不遮画面。

音效的三个层次

一次攻击命中,理想情况下有三个声音在不同时间点:

t = 0 ms      挥击声(whoosh)     ← 攻击动作开始时,不等命中
t = 命中帧    撞击声(impact)     ← 短、有攻击性、频率高
t = +50 ms    尾音(残响/惨叫)    ← 长、渲染余韵

大多数个人项目只做了中间那一个。加上第一个(挥击声)的收益特别大,因为它在命中之前就开始,让玩家感觉到「攻击正在发生」而不是「攻击结算了」。

两条实用技巧

  • 随机音高。同一个音效连续播放会显得很假。每次播放时把 pitch_scale 在 0.9–1.1 之间随机一下,立刻自然很多。一行代码,效果惊人。
  • 限制同时播放数。同一帧命中十个敌人时播十个相同音效,会叠出一个刺耳的爆音。设一个「同一音效 50 毫秒内最多播一次」的限流。
func play_hit_sound() -> void:
	var now := Time.get_ticks_msec()
	if now - _last_hit_sound < 50:
		return                       # 限流,防止爆音
	_last_hit_sound = now
	$HitSound.pitch_scale = randf_range(0.9, 1.1)
	$HitSound.play()

时序:反馈的顺序比强度更重要

这一节是本章最容易被忽略、但差别最大的部分。

六层反馈不该同时触发。它们应该有一个精心安排的时间顺序,因为玩家的大脑是按顺序解读事件的。

−100 ms   挥击音效 + 攻击动画起手      ← 预告:有事要发生了
   0 ms   命中判定 + 撞击音效 + 闪白   ← 确认:打中了
   0 ms   命中停顿开始(60 ms)        ← 强调:这一下有分量
 +10 ms   粒子迸溅(在停顿期间就出现)
 +60 ms   停顿结束,击退开始           ← 后果:它被打退了
 +60 ms   屏幕震动开始(140 ms 衰减)
 +80 ms   伤害数字飘出                 ← 信息:具体多少
+200 ms   敌人恢复正常颜色             ← 收尾
◆ 关键在那个 −100 ms

预告(telegraph)是最容易被省略、也最重要的一环。

攻击动画的起手、挥击的声音、蓄力的光效 —— 这些发生在命中之前。它们的作用是让玩家在结果出现前就预期到结果。

而「预期 → 兑现」这个结构,正是所有快感的来源。没有预告的命中,即使反馈再华丽,也只是一个突然出现的事件,缺少那个「来了!」的期待。

这条规律对敌人的攻击同样成立 —— 敌人抬手的那一下预告,既是公平性(玩家有机会反应),也是紧张感的来源。

一个反面例子

如果你把所有反馈都塞在命中的同一帧:闪白、粒子、震动、数字、音效一起爆发 —— 玩家会感觉到一团混乱而不是一次打击

因为所有信息同时到达,大脑没法分辨哪个是原因哪个是结果。错开 30–80 毫秒,同样的素材会清晰十倍。

⚙ Godot 落地:把反馈打包成一个可复用的东西

不要把这六层散在各个敌人的脚本里。做一个统一的反馈发射器:

class_name HitFeedback
extends Node

@export var hitstop: float = 0.06
@export var shake: float = 2.5
@export var knockback: float = 90.0
@export var flash_time: float = 0.05
@export var particle_count: int = 12
@export var show_number: bool = true

func play(target: Node2D, from: Vector2, damage: int) -> void:
	var dir := (target.global_position - from).normalized()

	# 0 ms:确认层 —— 最先,也最重要
	Audio.play_hit(target.global_position)
	target.flash(flash_time)
	Time.freeze(hitstop)              # 自己维护的 freeze,别用 time_scale

	# +10 ms:粒子,方向朝着攻击的反方向
	Particles.burst(target.global_position, dir, particle_count)

	# 停顿结束后:后果层
	await Time.freeze_finished
	target.apply_knockback(dir * knockback)
	Camera.add_shake(shake)

	# +80 ms:信息层
	if show_number:
		await get_tree().create_timer(0.02).timeout
		DamageNumbers.spawn(target.global_position, damage)

这样做的好处:所有的时序参数集中在一个地方。你想调整打击感,改的是一个节点的 @export,而不是翻遍二十个敌人脚本。

而且不同的攻击可以配不同的 HitFeedback 实例:轻击一个、重击一个、暴击一个。这是第 20 章「数据驱动」的一次预演。

本章小结

  • 反馈有三个职责,优先级不可颠倒:① 确认 → ② 信息 → ③ 情绪
  • 自检:关掉所有音效和特效,玩家还分得出打中没有吗?特效应加强确认而非提供确认。
  • 六层:命中停顿、屏幕震动、击退、闪白、粒子、伤害数字。性价比最高的是命中停顿(30–60ms)。
  • 震动:幅度小(2–4px)、时间短、要衰减、取 max 不要累加、能关掉
  • 音效贡献了约一半的打击感,成本却最低。随机音高 + 限流,两行代码。
  • 时序比强度更重要。错开 30–80ms,同样的素材清晰十倍。
  • 最容易漏掉的是 −100ms 的预告 —— 「预期 → 兑现」才是快感的结构。
⇄ 换个类型
  • 卡牌:反馈的六层在这里变成了结算动画的编排。牌飞出去 → 目标闪白 → 数字跳 → 血条流失(血条要用动画流失,不要瞬间跳变 —— 这是这类游戏最重要的一条反馈规则)。命中停顿的等价物:结算前的那一下短暂停顿,让玩家看清即将发生什么。
  • 塔防:反馈的难点是同时发生几十次命中。此时六层全上会变成灾难。做法:降级 —— 单个小兵死亡只给最轻的反馈,Boss 死亡才给全套。把反馈强度和事件重要性绑定,是这类游戏的核心技巧。
  • 模拟经营:反馈往往是延迟的(今天播种,明天才发芽)。所以必须补一个即时的确认反馈:点下去的那一刻要有音效、有动画、有「已安排」的标记。否则玩家会重复点击。
  • 叙事解谜:反馈的职责几乎全在 ① 和 ②。玩家试对了一半时,要给部分反馈(一声轻响、一个零件亮了),而不是沉默。沉默会让玩家以为自己完全错了,从而放弃正确的思路。
  • 射击:命中确认的黄金标准是击杀确认音(kill confirm)—— 一个和命中音明确不同的声音。这一个音效对射击游戏爽感的贡献,超过任何视觉特效。