综合 / 后端 · 2026-11-03 08:34 · 9 阅读 · 0 赞
游戏手感是怎么算出来的:加速度、跳跃弧线与宽容窗口
从2D平台角色的移动、跳跃、宽容窗口到打击感,拆解手感背后的具体参数与公式,并给出帧率无关的实现方案和调参建议。
“这游戏手感真好。”玩家说这句话时,很少能讲清楚好在哪里。也许是起步利落,也许是跳跃宽容,也许是刀砍中敌人时画面轻轻卡了一下。这些感受落到代码里,都是一串具体的数字:加速度、跳跃高度、土狼时间、命中停顿帧数。
这篇文章跟着一个2D平台角色走一遍:先让它跑起来,再让它跳起来,然后替玩家接住那些“差一点就对了”的操作,最后给每一次命中加上分量。每一步都有公式、参数和调参时容易踩的坑。文中长度单位用“格”,帧率以60帧为参照。

左右两个方块跳得一样高,到顶时间也一样。差别只在下落快慢和身体形变,右边落地却干脆得多。
一、起步与刹车:速度不该是一个开关
最省事的移动写法是velocity.x = input * maxSpeed;,按下去就是全速,松开就是静止。它确实跟手,但角色像一个被拨动的开关,没有起步和刹车,转身在一帧内完成,很难做出“轻轻点一下,往前挪半步”的精细操作。
把加速度调得很小,角色又像踩在冰面上,按下去要等一会儿才跑起来,松手还要滑出去老远。
好的移动落在两者之间。更关键的是,起步、刹车、转身往往是三套不同的加速度:
[SerializeField] float maxSpeed = 8f; // 格/秒
[SerializeField] float acceleration = 80f; // 起步
[SerializeField] float deceleration = 120f; // 松手刹车
[SerializeField] float turnAcceleration = 160f; // 反向转身
[SerializeField] float airControl = 0.65f; // 空中打折
void UpdateHorizontal(float input, float dt)
{
float target = input * maxSpeed;
float rate;
if (Mathf.Approximately(input, 0f))
rate = deceleration;
else if (velocity.x != 0f && Mathf.Sign(input) != Mathf.Sign(velocity.x))
rate = turnAcceleration;
else
rate = acceleration;
if (!grounded) rate *= airControl;
velocity.x = Mathf.MoveTowards(velocity.x, target, rate * dt);
}
Mathf.MoveTowards每帧把当前速度朝目标推进至多rate * dt,到了就停,不会冲过头,相当于恒定加速度。

代入参数算一算:
- 从静止到最高速:8 ÷ 80 = 0.1秒,60帧下6帧。
- 松手到完全停住:8 ÷ 120 ≈ 0.067秒,约4帧。
- 刹车距离:v² ÷ 2a = 64 ÷ 240 ≈ 0.27格,约四分之一个身位。
- 全速反向推摇杆:速度先以160的加速度在0.05秒内降到零,然后按起步的80用0.1秒加满。
为什么刹车要比起步快?因为玩家松手时心里想的是“就停在这儿”,多滑出去的距离会被读成“角色不听话”。起步稍慢反而让角色有重量。转身更快,是因为反向输入多发生在躲闪和修正时,每一帧延迟都可能变成挨打。空中打折让玩家能在半空微调落点,却不能随意折返,这正是跳跃该有的样子。
二、跳跃:先想好跳多高,再去算重力
调跳跃时,很多人先把重力设成9.8,再一点点加起跳速度,直到“看着差不多”。这条路很难走到头,因为每改一次,高度和滞空时间都会一起变。
不如反过来:先决定跳多高、多久到顶,再让公式告诉你重力和起跳速度。
起跳速度记作v₀,重力加速度记作g,起跳后t秒的高度是:
y(t) = v₀·t − ½·g·t²
到达最高点时速度归零,于是v₀ = g·t;代回去,最高点高度h = ½·g·t²。联立得:
g = 2h / t²,v₀ = 2h / t
假设想让角色跳3格高,0.4秒到顶:
- g = 2 × 3 ÷ 0.16 = 37.5 格/秒²
- v₀ = 2 × 3 ÷ 0.4 = 15 格/秒
这个重力差不多是9.8的四倍。别担心,这很正常。如果重力真取9.8,跳3格要将近0.8秒才到顶,角色会像慢慢升空的气球。几乎所有平台游戏的重力都比现实大得多。
[SerializeField] float jumpHeight = 3f;
[SerializeField] float timeToApex = 0.4f;
[SerializeField] float fallMultiplier = 1.8f;
[SerializeField] float jumpCutMultiplier = 0.5f;
[SerializeField] float maxFallSpeed = 20f;
float gravity, jumpVelocity;
void Awake() => Recalculate();
void OnValidate() => Recalculate(); // 在Inspector里改参数,立刻生效
void Recalculate()
{
gravity = 2f * jumpHeight / (timeToApex * timeToApex);
jumpVelocity = 2f * jumpHeight / timeToApex;
}
这样一来,策划在Inspector里拖动的就是“3格”“0.4秒”这种一眼能懂的数字,而不是一个谁也说不清含义的重力值。
上去要稳,下来要快
对称的抛物线还有一个毛病:落下来和升上去一样慢,整个人轻飘飘的。很多平台游戏会在下落阶段悄悄加大重力:
void UpdateVertical(bool jumpReleased, float dt)
{
// 还在上升时松开跳跃键:削掉一部分上升速度,轻按就是小跳
if (jumpReleased && velocity.y > 0f)
velocity.y *= jumpCutMultiplier;
float g = velocity.y < 0f ? gravity * fallMultiplier : gravity;
velocity.y = Mathf.Max(velocity.y - g * dt, -maxFallSpeed);
}
下落重力乘以k之后,从同样高度落回地面,用时变成原来的1/√k。取k = 1.8,下落从0.4秒缩到约0.30秒,整个跳跃从0.8秒缩到约0.70秒。上升的高度和节奏分毫未动,落地却一下子干脆了。

jumpReleased只应在松键的那一帧为真,比如Input.GetButtonUp("Jump"),这样上升速度只会被削一次。
再算一个轻按的例子:按下0.1秒就松手,这时速度是15 − 37.5 × 0.1 = 11.25,已经升到约1.31格;速度减半后还能再往上走5.625² ÷ 75 ≈ 0.42格,最终停在约1.7格。一直按住,就是完整的3格。跳多高,由手指按多久来决定。
最后那个maxFallSpeed给下落速度封了顶。下落重力是37.5 × 1.8 = 67.5,从3格高处落下,触地时速度约为√(2 × 67.5 × 3) ≈ 20.1。上限定在20,恰好只修剪掉从高处坠落时的那一截尾巴,普通跳跃完全感觉不到它。
三、同一段代码,换个帧率就跳不上去了
上面的UpdateVertical先更新速度,再用新速度移动位置,这种做法叫半隐式欧拉积分。它简单、稳定,但并不精确。
用同一组参数逐帧模拟,最高点分别落在:
- 30帧:2.75格
- 60帧:2.875格
- 144帧:约2.95格
目标明明是3格。帧率越低,角色跳得越矮。放到帧率忽高忽低的手机上,玩家就会碰到这样的怪事:刚才还跳得上去的台子,这次怎么也够不着,而你在开发机上永远复现不出来。
修它有两个办法,通常一起用。
**第一,把移动放进固定步长里。**Unity的FixedUpdate按固定的Time.fixedDeltaTime执行,不管画面跑多少帧,每一步的dt都相同,误差至少是确定的。输入仍然在Update里读,先记下来,留给下一次FixedUpdate处理,否则一次短促的按键可能恰好落在两次物理步之间,被悄悄吞掉。
**第二,让位置更新带上加速度项。**只要一帧之内重力不变,下面的写法能让每一帧都精确落在理论抛物线上:
float g = velocity.y < 0f ? gravity * fallMultiplier : gravity;
position.y += velocity.y * dt - 0.5f * g * dt * dt;
velocity.y -= g * dt;
换成这种写法再模拟一遍,30帧和60帧的最高点都是整整3.0格;144帧是2.9999格,只因为顶点恰好落在了两帧之间。
四、宽容:把“差一点”算作“做到了”
角色跑到平台边缘,玩家按下跳跃。可在程序看来,角色一两帧前就已经离开了地面,这次跳跃不算数。玩家不会觉得“是我按晚了”,只会觉得“我明明按了,它就是不跳”。
反过来也一样。角色眼看就要落地,玩家提前按下跳跃,程序却判定“人在空中,不能跳”,这一下就被扔掉了。
两种情况在60帧下只差那么几帧,却是平台游戏里“手感差”最常见的来源。对付它们,有两个专门的设计:
- 土狼时间(Coyote Time):脚离开平台后的一小段时间里,仍然允许起跳。名字来自动画片里那只冲出悬崖、在半空中愣了一下才往下掉的土狼。
- 输入缓冲(Jump Buffer):落地前一小段时间里按下的跳跃会被记住,脚一沾地就自动跳起。
《蔚蓝》(Celeste)以难著称,玩家却很少抱怨它不公平,原因之一就是它在暗处藏了大量这样的宽容设计,这两项都在其中。

实现起来,只需要两个计时器:
[SerializeField] float coyoteTime = 0.10f;
[SerializeField] float jumpBufferTime = 0.12f;
float coyoteTimer, bufferTimer;
void Update()
{
float dt = Time.deltaTime;
// 刚起跳的几帧里,地面检测可能还碰得到地面,
// 所以只在没有上升时才算真正站在地上
grounded = groundCheck.IsTouching() && velocity.y <= 0f;
coyoteTimer = grounded ? coyoteTime : coyoteTimer - dt;
bufferTimer = Input.GetButtonDown("Jump") ? jumpBufferTime : bufferTimer - dt;
if (bufferTimer > 0f && coyoteTimer > 0f)
{
velocity.y = jumpVelocity;
bufferTimer = 0f; // 这次按键已经用掉
coyoteTimer = 0f; // 不能在空中再借一次地面
}
}
最后两行清零千万别漏。漏了coyoteTimer = 0f,玩家起跳后0.1秒内再按一下,就会凭空多出一个二段跳;漏了bufferTimer = 0f,一次按键可能触发两次跳跃。
0.1秒和0.12秒,在60帧下大约是6到7帧。窗口并不是越大越好:土狼时间太长,角色会明显地在空中“踩空气”;缓冲太长,玩家又会觉得角色在自作主张地乱跳。一般从4到8帧开始试起。
同样的思路几乎可以用在任何地方:攻击收招时提前按下的下一刀,贴墙前一瞬按下的蹬墙跳,头顶差一两个像素蹭到砖角时把角色轻轻推开。它们背后是同一句话:判定是写给程序的,宽容是留给人的。
五、打击感:让画面替你停顿一下
刀砍中敌人的那一瞬,很多动作游戏会让画面停住几十毫秒,这叫命中停顿(Hit Stop)。它短到玩家几乎意识不到,可一旦拿掉,每一刀都像劈在空气里,轻飘飘的没有阻力。那一下停顿,就是手上传来的“砍实了”的感觉。
最直接的做法,是把时间缩放暂时归零:
public class HitStop : MonoBehaviour
{
Coroutine running;
float savedScale = 1f;
public void Play(float duration = 0.06f)
{
if (running != null) StopCoroutine(running);
else savedScale = Time.timeScale;
running = StartCoroutine(Freeze(duration));
}
IEnumerator Freeze(float duration)
{
Time.timeScale = 0f;
yield return new WaitForSecondsRealtime(duration);
Time.timeScale = savedScale;
running = null;
}
}
这段代码里藏着几个细节:
Time.timeScale为0时,Time.deltaTime也是0,FixedUpdate干脆不再执行,所以计时必须用WaitForSecondsRealtime。- 结束时要还原成停顿之前的值,而不是写死成1,否则游戏里的子弹时间或暂停菜单会被一刀冲掉。
- 连续命中时重新计时,而不是把时长累加,免得一套连招把画面冻住半天。
- 0.06秒约等于4帧。轻攻击2到4帧就够,重击或终结技可以拉到6到10帧。
全局暂停的代价是整个世界都停了。讲究一点的做法,是只冻住攻击者和被击者的动画,其余一切照常,效果更自然,实现也更费事。
命中停顿很少单独出场。被击者闪一帧白,身体被往后推一小段,镜头再跟着晃一下,几样小东西叠在一起,才拼出一记扎实的打击。
六、镜头震动:强度要取平方
说到镜头震动,最常见的两个错误是:每帧随机取一个偏移,以及让偏移量和伤害成正比。
前一种会让画面高频地发抖,像信号不好的电视,而不是被什么东西撞了一下。后一种会让一点小擦伤也晃得人眼花,玩上半小时就开始头晕。
更好的模型分三步:
- 维护一个0到1之间的创伤值(trauma),每次受击加一点,随时间匀速衰减。
- 真正的震动强度取trauma²,想更克制一点可以取三次方。
- 偏移量 = 最大偏移 × 震动强度 × 平滑噪声。

平方带来的效果很直观:trauma是0.3时,强度只有0.09,几乎察觉不到;trauma到了0.9,强度是0.81。小事轻轻晃,大事狠狠晃,连续受击时强度也是一点点往上攀,不会一下子顶满。
public class CameraShake : MonoBehaviour
{
[SerializeField] float maxOffset = 0.5f; // 格
[SerializeField] float maxAngle = 4f; // 度
[SerializeField] float frequency = 25f;
[SerializeField] float decayPerSecond = 1f;
float trauma, seed;
Vector3 basePosition;
Quaternion baseRotation;
void Awake()
{
seed = Random.value * 100f;
basePosition = transform.localPosition;
baseRotation = transform.localRotation;
}
public void AddTrauma(float amount) => trauma = Mathf.Clamp01(trauma + amount);
void LateUpdate()
{
float shake = trauma * trauma;
float t = Time.unscaledTime * frequency;
float nx = Mathf.PerlinNoise(seed, t) * 2f - 1f;
float ny = Mathf.PerlinNoise(seed + 1f, t) * 2f - 1f;
float nr = Mathf.PerlinNoise(seed + 2f, t) * 2f - 1f;
transform.localPosition = basePosition + new Vector3(nx, ny, 0f) * (maxOffset * shake);
transform.localRotation = baseRotation * Quaternion.Euler(0f, 0f, nr * maxAngle * shake);
trauma = Mathf.Max(0f, trauma - decayPerSecond * Time.unscaledDeltaTime);
}
}
这个组件要挂在跟随镜头的子物体上:父物体负责跟着角色走,子物体只管在局部晃动,两件事互不打扰。
Mathf.PerlinNoise对相邻的输入给出相近的输出,所以镜头是连贯地摆动,而不是每帧乱跳。X、Y和旋转各用一个种子,三者就不会整齐划一地一起摆。这里用unscaledTime,是想让镜头在命中停顿期间继续晃;如果你更喜欢停顿时连镜头一起定格,换回Time.time就行。
在2D游戏里,一点点旋转往往比平移更有冲击力。另外,记得在设置里给震动留一个开关,有些玩家对它格外敏感。
七、缓动:同样的0.3秒,可以走得很不一样
面板弹出、角色挤压拉伸、镜头缓缓推近,这些动画都有起点、终点和时长,真正决定气质的,是中间怎么走。
缓动函数只做一件事:把时间进度t(0到1)换算成位移进度。
static float EaseOutCubic(float t) => 1f - Mathf.Pow(1f - t, 3f);
static float EaseOutBack(float t)
{
const float c1 = 1.70158f;
const float c3 = c1 + 1f;
float u = t - 1f;
return 1f + c3 * u * u * u + c1 * u * u;
}
// 使用
float p = EaseOutBack(Mathf.Clamp01(elapsed / duration));
panel.localScale = Vector3.LerpUnclamped(Vector3.zero, Vector3.one, p);

- 线性:匀速前进,最机械。
- smoothstep:两头慢、中间快,适合镜头平移。之前那篇《从一个圆到技能冲击波》里,柔和的光圈边缘也是它画出来的。
- easeOutCubic:一出手就很快,然后从容地停稳。UI弹出用它,响应感最强。
- easeOutBack:冲过终点再弹回来,最远冲到1.10,也就是越过终点一成,按钮和图标用它显得俏皮。注意要配
LerpUnclamped,普通的Lerp会把超过1的那一截直接截掉。
开头动图里右边那个方块的“挤压拉伸”,也是同一个思路:上升和下落时按速度把身体拉长,落地瞬间压扁,再用easeOutCubic弹回原形。
float s = 1f + Mathf.Min(Mathf.Abs(velocity.y) / jumpVelocity, 1f) * 0.3f;
sprite.localScale = new Vector3(1f / s, s, 1f);
宽度取高度的倒数,让面积大致不变。只拉高不收窄,角色看起来就不是在弹跳,而是在膨胀。
八、镜头跟随:每帧Lerp 0.1埋下的坑
让镜头平滑地跟着角色,几乎每个人都写过这一行:
cam.position = Vector3.Lerp(cam.position, target.position, 0.1f);
每一帧把剩下的距离缩短一成。在60帧下,它看起来恰到好处。问题在于这个0.1是“每帧”的比例,而不是“每秒”的。
每帧保留九成,n帧以后剩下0.9ⁿ。要追上90%的距离,需要n = ln 0.1 ÷ ln 0.9 ≈ 21.85帧:
- 60帧:约0.36秒
- 30帧:约0.73秒,慢了整整一倍
- 144帧:约0.15秒,又快了一倍多
同一份代码,在高刷手机上镜头紧紧咬着角色,到了掉帧的旧机器上,却拖着一条长长的尾巴。
解决办法,是把“每帧保留多少”改成“每秒保留多少”。设每秒的衰减率为λ,经过dt秒后剩下e^(−λ·dt):
[SerializeField] float lambda = 6.32f; // 与60帧下的0.1等效
cam.position = Vector3.Lerp(cam.position, target.position,
1f - Mathf.Exp(-lambda * Time.deltaTime));
λ可以从原来的参数换算出来:λ = −ln(1 − 0.1) × 60 ≈ 6.32。换算之后,不管帧率高低,追上90%都是ln 10 ÷ λ ≈ 0.36秒,三条曲线严丝合缝地叠在一起。

如果不想自己推公式,Vector3.SmoothDamp也是个好选择。它模拟一根带阻尼的弹簧,内部已经处理好了deltaTime,参数smoothTime直接以秒为单位。
九、一张调参表
把前面的参数收拢到一起,顺便换算成60帧下的帧数。它们不是标准答案,只是一个不错的起点:
| 参数 | 值 | 60帧下 | 调大以后 |
|---|---|---|---|
| 最高速 | 8 格/秒 | — | 更快,落点更难控制 |
| 起步加速 | 80 格/秒² | 6帧跑满 | 更跟手,也更像开关 |
| 刹车减速 | 120 格/秒² | 4帧停稳 | 停得更准,也更生硬 |
| 跳跃高度 | 3 格 | — | — |
| 到顶时间 | 0.4 秒 | 24帧 | 更飘 |
| 下落重力倍数 | 1.8 | 下落约18帧 | 落得更快、更沉 |
| 松键速度倍数 | 0.5 | — | 越小,小跳越矮 |
| 土狼时间 | 0.10 秒 | 6帧 | 更宽容,太大会踩空气 |
| 输入缓冲 | 0.12 秒 | 约7帧 | 更宽容,太大像自动跳 |
| 命中停顿 | 0.06 秒 | 约4帧 | 更沉重,太长会卡顿 |
| 镜头λ | 6.32 | 0.36秒追上90% | 镜头咬得更紧 |
调这些数字时,有两个习惯特别值得养成。
把参数暴露出来,边玩边调。[SerializeField]配上OnValidate,在Play模式下拖动滑条,变化立刻就能看到。手感几乎不可能靠读代码读出来,只能靠手去试。
录下来,一帧一帧地看。“刹车有点滑”是一种感觉;放进逐帧录像里,它就变成了“松手以后又走了7帧”。只有把感觉翻译成帧数,你才知道该去动哪一个参数。
写在最后
说到底,手感衡量的是一件事:玩家心里想做的,被游戏理解了多少。
加速度让角色既听话又有分量;从高度和时间反推重力,让跳跃从“试出来”变成“设计出来”;土狼时间和输入缓冲,悄悄把“差一点”算成“做到了”;命中停顿和镜头震动,替画面说出“这一下打实了”;与帧率无关的写法,则保证这一切在每一台设备上都是同一种感觉。
拆开来看,每一招都不过几行代码、一两个公式。难的是把它们放在一起,然后对着屏幕,一帧一帧地磨。