Chancy game dev notes · 全栈工程师

定帧是一门算术,不是一种感觉

《大乱斗》把 hitstop 写成了带钳制的整数公式;而多数项目把它写成"卡一帧",然后在 time_scale 上踩坑。

· 5 分钟读完

#game-feel #engine

我上一篇写打击停顿时给了"100ms / 60~90ms"这组经验值。这次想写得更狠一点:hitstop(命中定帧)在很多成熟项目里根本不是美术决定,而是被查表和钳制出来的整数。

社区维护的 SmashWiki: Hitlag 把《大乱斗》系列历代公式列了出来,这是我见过最能说明"手感可以被算术化"的一页。Ultimate 的式子长这样(帧为单位,⌊⌋ 是向下取整):

⌊ ⌊ ⌊(d × 0.65 + 6) × h × e × s⌋ × p ⌋ × c ⌋

变量含义:d 是伤害,e 是 electric 属性时的 1.5×,s 是打盾的 0.67×,h 是按招式部位自定义的倍率(Marth 剑尖 1.25×、非剑尖 0.7×,设成 0 就是直接关闭这一招的定帧),p 是人数缩放(2 人 1.0 递减到 8 人 0.75),c 是 crouch cancel 的 0.666667×。

三个我觉得最值钱的细节:

  1. 有硬上限。 该页给的系列上限是 Melee 20 帧、Brawl 之后 30 帧(crouch cancel 时降到 20),只有极特殊情形(打 Kirby 的石头形态)能叠到 36 帧。也就是说这不是"越爽越好",是被帧预算约束的资源。
  2. 同样的伤害在不同代里给出不同定帧:15% 伤害在 64 / Melee / Brawl / Ultimate 分别产生 10 / 8 / 10 / 15 帧。定帧时长是一个被逐代调过、并且会改变对战节奏的参数,不是常数。
  3. 每一代都有独立的公式(64 日版是 d/3+4,国际版 +5,Melee 是 ⌊⌊⌊d/3+3⌋×e⌋×c⌋)。改动都是 +1 帧这个量级 —— 而这个量级在格斗语境里就是一整个确反窗口。

计量单位也得钉死:SmashWiki 的 Frame data 把 1 frame 定义为 1/60 秒(约 16.7ms),并区分 start-up lag、active frames(hitbox 存在的那些帧)、ending lag(动作结束后到能再次输入之间的间隔)。同一页给了不同作品里 hitstun-cancel 的窗口帧数(13 / 25 / 40 / 45),以及 Ultimate 里 glide toss 只有 2 帧 的窗口。

"我们的定帧大概是 0.1 秒"和"是 6 帧、上限 20 帧、这一招设 0" 是两种完全不同级别的讨论。前者只能靠感觉收敛,后者能被回归测试。

定帧的实现坑不在公式,在"谁在走时间"

这是真正会炸的地方。Godot 的 Tween 文档里那条默认值非常关键:set_ignore_time_scale 默认为 true。

想反了就是 bug:hitstop 通常靠改 Engine.time_scale 实现,如果你的震动、闪白、飘字这些 juice 动画跟随 time_scale,那它们会在定帧期间一起被冻住 —— 于是你看到的效果是"卡住了一张完全静止的画面",而不是"卡在一张正在闪光、正在弹开的画面上"。前者像死机,后者才像打击。

同一页还有两条容易撞上的:set_speed_scale 只缩放这条补间、不影响其他逻辑(所以它适合做"只让特效变慢");以及duration 为 0 的重复补间会在少量次数后终止,还有"累加帧 delta 会让实际完成时间略大于声明值" —— 这条对所有手写计时器都成立,包含我自己写的那些。

Godot 的枚举本身也值得看一眼:12 种 TRANS_* × 4 种 EASE_*,默认是 TRANS_LINEAR + EASE_IN_OUT。Robert Penner 二十年前那批 easing 方程(原发布页现在基本只剩导航和实现仓库链接)今天变成了引擎里的一个 enum。我觉得这本身就是个论据:手感参数的实现成本已经低到没有理由不建表了。

并发定帧要覆盖,不要叠加

saltmire 那个 Godot 4 的开源 juice 套件(saltmire-juice,MIT,要求 Godot 4.6+)把 juice 收敛成 5 个原语:shake(camera, amount)、hitstop(duration)、flash(node, color)、pop(node)、damage_number(parent, pos, value, options)。

它的两个实现决定我认为是对的:

  • 并发触发时"最新的一次优先",而不是叠加。 三条子弹同帧命中,如果 hitstop 时长相加,你就把世界冻了 0.3 秒 —— 那是掉帧,不是打击感。这个策略和 SmashWiki 那个"硬上限 20/30 帧"是同一件事的两种表达:定帧必须有预算。
  • 伤害飘字用对象池。 高频刷屏时不池化就会在 GC 上塌陷(和我另一篇写每帧 1KB 分配的账是同一笔)。

它文档示例里的定帧量级是 0.125 秒(1/8 秒),也就是 60FPS 下 7~8 帧 —— 和上面那组数字对得上,可以作为起点。

另外,同一套 juice 的"由状态推导"这件事,DOOM 早在 1993 年就做了。id Software 释放的 GPL 源码里 p_user.c 的视角摆动是这样的思路(几行摘录,出处为 id Software / DOOM,GPL):

player->bob = FixedMul(player->momx, player->momx) + FixedMul(player->momy, player->momy);
player->bob >>= 2;
if (player->bob > MAXBOB) player->bob = MAXBOB;

用横向速度的平方当摆动强度、右移归一化、再硬钳制上限。没有"跑动时播一个 bob 动画",只有"从游戏状态算出一个量、并且给它封顶"。我觉得这一页比大多数手感教程都值得读 —— 它证明了反馈量由状态推导不是新发明,是三十年前的默认做法。

(MAXBOB 的具体数值我没能在同源文件里核到,所以这里不写数。)

我会落成表的东西

参数我的取值方式
定帧基准时长帧为单位(如 6~8 帧),不用秒
定帧上限全局硬顶,防止多源叠加
并发的合并策略覆盖,不相加
每招倍率数据表字段,允许设 0 关闭
属性修正electric 1.5× 这类做成乘数列表
juice 动画的时间源明确忽略 time_scale,与逻辑解耦

前四行可以直接进断言。这样"手感变差了"就能被差分定位到某个倍率,而不是变成一次全量重调。

参考来源

  • Hitlag 与 Frame data — SmashWiki(社区维护,公式与帧数出自这两页)
  • Tween class — Godot 官方文档
  • saltmire-juice — saltmire(MIT)
  • p_user.c — id Software《DOOM》GPL 源码
  • Easing — Robert Penner(原发布页)
  • The Art of Screenshake — Jan Willem Nijman(Vlambeer,2014 演讲。网上流传的转载版本视频 ID 不一致,我无法确认原始上传地址,故不附链接;本文也没有引用该演讲内部的细节)