反馈:让世界回应你
你砍了一刀,敌人掉了 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 敌人恢复正常颜色 ← 收尾
预告(telegraph)是最容易被省略、也最重要的一环。
攻击动画的起手、挥击的声音、蓄力的光效 —— 这些发生在命中之前。它们的作用是让玩家在结果出现前就预期到结果。
而「预期 → 兑现」这个结构,正是所有快感的来源。没有预告的命中,即使反馈再华丽,也只是一个突然出现的事件,缺少那个「来了!」的期待。
这条规律对敌人的攻击同样成立 —— 敌人抬手的那一下预告,既是公平性(玩家有机会反应),也是紧张感的来源。
一个反面例子
如果你把所有反馈都塞在命中的同一帧:闪白、粒子、震动、数字、音效一起爆发 —— 玩家会感觉到一团混乱而不是一次打击。
因为所有信息同时到达,大脑没法分辨哪个是原因哪个是结果。错开 30–80 毫秒,同样的素材会清晰十倍。
不要把这六层散在各个敌人的脚本里。做一个统一的反馈发射器:
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)—— 一个和命中音明确不同的声音。这一个音效对射击游戏爽感的贡献,超过任何视觉特效。