Chancy game dev notes · 全栈工程师

打击停顿不该用秒来计时

手感在代码层就是三件事:谁在计时、冻在哪一帧、什么排在什么之后。附一份我自己用的 hitstop 写法。

· 5 分钟读完

#game-feel #architecture

上一篇写手感是一堆有参数的功能。这篇写它们在代码里长什么样 —— 因为我看过的多数"手感丢失"bug,最后都不是设计问题,是计时问题。

别把动画和秒绑在一起

Jan Willem Nijman(《Fistful of Momentum》那篇随笔)有条我觉得最朴素也最容易违反的规则:

如果动画是 10 帧、播放时长 0.5 秒,那么所有和它绑定的特效、逻辑、声音,都应该依赖这个动画本身,而不是依赖秒。

违反的典型症状我在自己代码里见过,Martin Jonasson 和 Petri Purho 的《Juice it or lose it》(2012)里也描述过同一个坑:并行的两套计时器一旦不同步就会鬼畜 —— 你的粒子计时器写 1 秒,动画实际 0.95 秒,几次循环之后闪光就开始在动作之前的帧上炸开,玩家看到的是"闪错了地方"。

我现在把这两类时钟明确分开:game_time(受 hitstop / slow-mo 影响,驱动世界和动画)和 ui_time(永不受影响,驱动菜单、HUD、结算动画)。慢动作把敌人拖成 0.25 倍速时,UI 绝不能跟着慢 —— 这条一旦混了就很难拆回来。

打击停顿:一个数字,两种实现

hitstop(受击/命中瞬间把世界钉住几帧)是格斗和动作游戏里最常用的"让打击有分量"的手法。可用的数字:

  • Nijman 给的通用起点是 100ms。
  • Michael Lutz 的量化更贴近帧:60~90ms、大约 10 帧,"按接触强度取大小",而且他明确警告一次只能停一个对象 —— 同时冻多个对象会把帧时间吃掉,产生卡顿感。

真正的坑是实现方式。最省事的写法是让角色空转一个"暂停计数":

// 常见写法:命中时 HitStop = 0.08f
void Update() {
    if (hitStop > 0f) { hitStop -= Time.unscaledDeltaTime; return; }   // ← 动画也停了
    animator.Update(Time.deltaTime);
}

它的问题很隐蔽:动画被冻在了那一帧上。如果这一帧不是接触帧,玩家看到的就是"拳头停在脸前面 80ms" —— 停顿反而破坏了它想强调的那一下。

正确的写法是让暂停只作用在位移和逻辑上,而把动画快进到接触帧:

void Hit(string contactAnim, float seconds) {
    animator.Play(contactAnim);          // 直接切到接触那一帧
    animator.Update(0f);                 // 冻住它,但姿态是对的
    hitStopRemaining = seconds;          // 只对位移/逻辑生效
    Scheduling.Ease(() => hitStopRemaining = 0f, seconds, Ease.OutQuad);
}

要点:hitstop 期间位移速度为零,但精灵仍然被渲染 —— 玩家看到的是"卡在一个正确的姿势上",这才叫打击感。

冻结帧的演化:为什么现在更流行"把怪打飞 + 慢动作"

早期做法是命中瞬间整块屏幕停住。现在更多游戏改成:生成一个受击对象的副本、把它按击退向量甩出去、同时全场慢动作,视觉冲击一样强,但不打断玩家的连续输入。Juice it or lose it 把这个转变讲成"juice 的代际差异"。

下面这组是我自己项目里在用的起点值(不是我从某篇文章抄来的,是我调完之后固定下来的默认):

手法我的起点值
慢动作全局 timeScale 0.2~0.5,持续 0.05~0.35s,结束前缓动回 1.0
死亡表现不冻结整屏,改为"副本被击退 + 慢动作"
精灵闪光白色覆盖,衰减别短于 0.2s,否则像一次不自然的眨眼(这条 Karunaratne 与 Lutz 给的是同一量级)

受击位移的规格也是同一类数字:把 sprite 沿击退方向偏移约 8 像素、200ms 缓出衰减(Karunaratne 的综述里给的就是这个量级)。还有两条是顺序相关的:

  • 挤压变形的峰值要落在接触那一帧,不是接触之后 —— squash & stretch 是"强调接触时刻"的手法,不是"接触后的反应"。竖直挤压不超过 30%,否则玩家认不出自己的角色。
  • 确认性反馈(粒子、爆浆、飘字)排在击杀事件之后,不要排在命中之前 —— Lutz 的排序原则就是"先确认击杀,再喷溅"。命中反馈和击杀反馈混在一个时机里,玩家就永远读不出"这一下打死了吗"。

时间缩放是设计工具,不只是效果工具

Karunaratne 的综述里提了一类我没怎么想过的用法:hitstop 和全局慢动作可以做成难度参数。他举的例子是把《Bomb Jack》的 hitstop 拉长到 1 秒 —— 停顿变长意味着玩家的输入窗口被压缩,同一个机制立刻变难。也就是说手感层可以承担一部分难度曲线,而不需要动数值表。

(顺手一条容易写错的:hitstop 不要乘在 Time.deltaTime 上,要乘在"该实体自己的速度"上。前者的副作用范围是整个游戏,后者才只影响被打的那个东西。)

最后:手感会掩盖机制空洞

所有 juice 手法共同的副作用 —— Jonasson 和 Purho 那篇演讲的核心论点恰恰是"juice 能让平庸的机制感觉很好",这是把刀。我现在的做法是把"手感演示"和"机制演示"分开评审:去掉所有特效、音效、停顿之后,这个技能还成立吗?如果不成立,那 100ms 的 hitstop 只是止痛药。

Karunaratne 引的 Swink 有个说法我拿来当检查项:反馈必须及时、自洽、且超出玩家预期。三条里"超出预期"最难,因为它意味着你加的东西在玩家按下按键之前就已经排好队了。

参考来源