土狼时间与输入缓冲

2026-07-14 · unity

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;       // 消耗土狼时间
    }
}

两点注意:

适用范围:为什么只加在跳跃上

判断标准是操作是否跨过物理状态的边界。跳跃依赖”是否在地面”,这是每帧都在变的物理状态,玩家无法精确掐帧——需要宽容。冲刺只依赖”冷却是否结束”,这是玩家自己可控的节奏,按即执行,没有等待物理状态的窗口,缓冲毫无意义。所以目前只有跳跃需要这一对宽容窗。

自己遇到的问题

问题 1:缓冲起跳后动画卡在 Idle / Fall

现象:落地前按键,角色正常起跳,但动画停在 Idle 或 Fall,跳跃动画不播放。

原因:状态机只设计了”站地状态 → Jump”的过渡。缓冲和土狼使跳跃可以在空中状态触发:落地前一帧角色在动画机上仍处于 Fall,而状态机没有 Fall → Jump,动画切不过去。

处理:补全三条过渡:Fall → JumpIdle → FallRun → 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 本应由”角色当前的垂直运动”决定,用一次性事件去驱动一个持续状态,本身就错配。

处理:改为速度驱动。每帧把 IsGroundedVelocityY 写入动画机,不再使用 trigger:

anim.SetBool("IsGrounded", isGrounded);
anim.SetFloat("VelocityY", rb.velocity.y);

状态机中的条件改为:

动画由物理结果直接推导,不依赖一次性 trigger,不存在”trigger 未被收到”的问题。这也为后续把 flag 式控制器重构为正式状态机做了准备——动画和物理最终使用同一套状态。

小结

土狼时间与输入缓冲不改变任何物理行为,只改变输入被接受的时间窗。两者成对:一个处理按晚,一个处理按早。实现要点是计时器按真实时间递减、起跳后清零。实际开发中成本最大的部分不在实现本身,而在动画状态机的同步调整。