土狼时间与输入缓冲
basic_movement 是 Unity 2022.3 的 2D 平台跳跃 demo。这篇记录土狼时间(Coyote Time)与输入缓冲(Input Buffer)的来历、原理、实现,以及实现后暴露出的三个动画问题。
来历
土狼时间的名字来自动画《乐一通》(Looney Tunes)里土狼 Wile E. Coyote 的经典桥段:它冲出悬崖,脚已经悬空,却不会立刻掉下去,而是在空中悬停一瞬,直到”意识到自己离地了”才开始坠落。这个桥段是卡通物理里最出名的梗,游戏开发者借它来命名”离开地面后仍可起跳的极短窗口”。
输入缓冲的源头其实在格斗游戏。格斗游戏里很多招式需要按指令输入(比如 ↓↘→ + O),玩家不可能做到逐帧精确。格斗游戏很早就引入了系统级的”输入宽容”:当角色处于暂时无法出招的状态时,先把玩家的输入暂存,等角色一恢复就自动执行。后来 2D 平台跳跃把同样的思路用在了跳跃上——落地前按跳,落地瞬间补上这次起跳。
这两个机制近年被大量独立平台跳跃游戏当成了标配,**蔚蓝(Celeste)**是公认把这类宽容机制做得最好的代表之一。它难度很高,但玩家很少觉得”被系统坑了”,这种手感很大程度来自宽容窗口。
原理:为什么需要两个窗口
平台跳跃的跳跃判定,依据的是”角色此刻是否在地面上”这个物理状态。玩家的按键时机和这个判定之间,存在两个方向的误差:
- 按晚了:角色已经离开地面,但玩家觉得”还在边上”,按键落在”已离地”的判定区间。
- 按早了:角色还没落地,玩家提前按了跳,落地时按键已经松开,系统什么都没收到。
两个窗口正好各管一边:土狼时间管”按晚”(离地后保留一段可起跳时间),输入缓冲管”按早”(落地前暂存按键)。只做其中一个,另一边的挫败感依然存在,所以必须成对出现。
更深一层,这些机制解决的是”玩家意图”和”游戏状态”不同步的问题。人的反应速度加上操作误差,不可能把按键精确到某一帧。如果游戏要求逐帧精确却不给宽容,玩家体验到的不是”难”,而是”不公平”——按键明明有效,却没被系统收到。宽容窗口的作用是把两件事解耦:“输入是否被接受” 和 “物理状态是否满足”,让玩家意图在条件满足的瞬间自动实现。
两个窗口还有一层互补:缓冲记录的是”意图”,土狼延长的是”条件成立的时间”。落地前一瞬,缓冲里存着一次跳跃,土狼让起跳条件多维持一小段时间,两者重叠,恰好覆盖了边界上所有可能的时序。
原理:为什么用秒,不用帧
两个计时器都按 Time.deltaTime 递减,即按真实时间,而不是按帧数。
如果按帧数做,同样的窗口长度在 60fps 和 144fps 下对应完全不同的物理时长(60fps 下一帧约 16.7ms,144fps 下约 6.9ms)。按帧数 = 窗口长度随帧率变化,手感在不同机器上不一致。按秒做,任何帧率下都是同样的 0.1 秒,这才是真正的”帧率无关(framerate independent)”。
0.1 秒这个量级也有讲究:0.1s 大约是 60fps 下的 6 帧,刚好能容纳一次”误判带来的挫败”,又不足以让玩家产生”按键延迟了”的感觉。太短等于没有宽容,太长则操作迟钝,甚至出现”按了跳,过了半天才跳”的怪手感。
实现
状态
public float coyoteTime = 0.1f; // 土狼时间窗口
public float jumpBufferTime = 0.1f; // 输入缓冲窗口
private float coyoteTimer;
private float jumpBufferTimer;
两个窗口都设成公开参数,在 Inspector 中调整。
输入缓冲(Update)
void Update()
{
if (Input.GetButtonDown("Jump"))
{
jumpBufferTimer = jumpBufferTime; // 记录跳跃意图,不管是否在空中
}
jumpBufferTimer -= Time.deltaTime;
jumpHeld = Input.GetButton("Jump");
// ... 移动输入、朝向、冲刺 ...
}
关键点是跳跃一次性的指令,先记录为意图,再判断能否执行。宽容窗口由此而来。 若是按照最基础的按下跳跃键再将isJumpable布尔值改为1就实现不了这一缓冲空间
土狼时间与起跳判定(FixedUpdate)
void FixedUpdate()
{
// ... 重力计算、移动赋值 ...
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
// 在地面时充满,离开地面开始递减
if (isGrounded) coyoteTimer = coyoteTime;
else coyoteTimer -= Time.deltaTime;
// 起跳判定
if (jumpBufferTimer > 0 && (isGrounded || coyoteTimer > 0f) && !isDashing)
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
jumpBufferTimer = 0f; // 消耗缓冲
coyoteTimer = 0f; // 消耗土狼时间
}
}
两点注意:
- 两个计时器都按
Time.deltaTime递减,窗口长度与帧率无关。 - 起跳后立即清零两个计时器,避免后续帧重复触发。未被消耗的缓冲计时器也会随时间递减归零,不会出现”按一次跳多次”的情况。
适用范围:为什么只加在跳跃上
判断标准是操作是否跨过物理状态的边界。跳跃依赖”是否在地面”,这是每帧都在变的物理状态,玩家无法精确掐帧——需要宽容。冲刺只依赖”冷却是否结束”,这是玩家自己可控的节奏,按即执行,没有等待物理状态的窗口,缓冲毫无意义。所以目前只有跳跃需要这一对宽容窗。
自己遇到的问题
问题 1:缓冲起跳后动画卡在 Idle / Fall
现象:落地前按键,角色正常起跳,但动画停在 Idle 或 Fall,跳跃动画不播放。
原因:状态机只设计了”站地状态 → Jump”的过渡。缓冲和土狼使跳跃可以在空中状态触发:落地前一帧角色在动画机上仍处于 Fall,而状态机没有 Fall → Jump,动画切不过去。
处理:补全三条过渡:Fall → Jump、Idle → Fall、Run → Fall。
这里得到一个结论:输入宽容机制扩大了跳跃的合法触发状态,会暴露动画状态机中”只能从站地状态起跳”的旧假设。增加宽容机制时,应同步检查状态机的可达性。
问题 2:上升时闪 Idle;高处按住方向键下落一直显示 Run
现象一:缓冲起跳上升途中偶尔闪一下 Idle。
原因:动画参数在 FixedUpdate 中先于物理赋值更新。动画机读取到的是上一帧的”下落负速度”,刚切进 Jump 就被链式判定切回 Fall。
处理:把 IsGrounded / VelocityY 的参数更新移到 FixedUpdate 末尾,让动画机看到的状态与这一帧的物理结果一致。
现象二:从高处按住 A / D 下落,一直保持跑步动画,松开才变为 Fall。
原因:状态机缺少 Run → Fall 过渡,角色在下落时无法从 Run 切出。
处理:补上 Run → Fall(VelocityY < -0.1)。
问题 3:用速度驱动替代 Jump trigger
现象:用 trigger 驱动跳跃动画时,trigger 在状态切换瞬间可能被消耗或丢失;状态和过渡增多后,每次都要确认所有可达路径,维护成本高。
原因:trigger 是”一次性”的参数,在这一帧内没有被动画机消费就会丢失;而动画是否播 Jump 本应由”角色当前的垂直运动”决定,用一次性事件去驱动一个持续状态,本身就错配。
处理:改为速度驱动。每帧把 IsGrounded 和 VelocityY 写入动画机,不再使用 trigger:
anim.SetBool("IsGrounded", isGrounded);
anim.SetFloat("VelocityY", rb.velocity.y);
状态机中的条件改为:
VelocityY > 0.1→ JumpVelocityY < -0.1→ Fall
动画由物理结果直接推导,不依赖一次性 trigger,不存在”trigger 未被收到”的问题。这也为后续把 flag 式控制器重构为正式状态机做了准备——动画和物理最终使用同一套状态。
小结
土狼时间与输入缓冲不改变任何物理行为,只改变输入被接受的时间窗。两者成对:一个处理按晚,一个处理按早。实现要点是计时器按真实时间递减、起跳后清零。实际开发中成本最大的部分不在实现本身,而在动画状态机的同步调整。