18.10.2026 16 мин чтения Просмотров:0

Coyote time и буфер прыжка в Unity: отзывчивый прыжок в платформере

Разбираем три приёма, после которых прыжок в платформере перестаёт «глотать» нажатия: coyote time, буфер прыжка и прыжок переменной высоты. Готовый контроллер на Rigidbody2D и новой Input System, расчёт скорости по высоте и unit-тесты логики.

Coyote time и буфер прыжка в Unity: отзывчивый прыжок в платформере

После этой статьи у вас будет контроллер прыжка для 2D-платформера, который не «глотает» нажатия: игрок может прыгнуть чуть позже, чем сошёл с края платформы (coyote time), нажать прыжок чуть раньше приземления (буфер прыжка) и регулировать высоту прыжка длительностью нажатия. Всё это — на Rigidbody2D и новой Input System, с расчётом начальной скорости по желаемой высоте, без магических чисел и с логикой, которую можно покрыть unit-тестами.

Почему «честный» прыжок ощущается плохо

Самая прямолинейная реализация прыжка выглядит так: если персонаж стоит на земле и кнопка нажата в этом кадре — задать вертикальную скорость. С точки зрения физики всё верно, но игроки жалуются на три вещи.

  • «Я нажал, а он не прыгнул». Игрок бежит к краю и нажимает прыжок, когда персонаж уже на пару пикселей вылетел за край. Проверка земли говорит «в воздухе», прыжок отклонён, персонаж падает в яму.
  • «Я нажал перед приземлением, а он просто приземлился». В серии прыжков игрок жмёт кнопку за 50–100 мс до касания земли. В момент нажатия персонаж ещё в воздухе, а в момент касания кнопка уже не «нажата в этом кадре». Нажатие потеряно.
  • «Прыжок всегда одной высоты». Без управления высотой нельзя аккуратно запрыгнуть на низкую ступеньку, не стукнувшись о потолок, — приходится делать прыжок слабым, и тогда он скучный.

Человек реагирует с задержкой, а глаз не различает разницу в пару пикселей. Игра, которая требует кадровой точности там, где игрок не может её обеспечить, кажется «глухой». Решение — дать небольшие окна прощения. Игрок их не замечает, он просто чувствует, что управление «слушается».

Coyote time и буфер прыжка: идея в двух предложениях

Coyote time — короткое окно после того, как персонаж перестал касаться земли, в течение которого прыжок всё ещё разрешён. Название — отсылка к мультфильмам, где койот, выбежав за край обрыва, успевает повисеть в воздухе, прежде чем упасть.

Буфер прыжка (jump buffer) — короткое окно после нажатия кнопки, в течение которого нажатие «помнится». Если персонаж коснулся земли, пока окно открыто, прыжок срабатывает в момент приземления.

Обе техники — одна и та же идея с разных сторон: вместо проверки «земля и нажатие в один и тот же кадр» мы спрашиваем «были ли земля и нажатие достаточно близко друг к другу во времени». Поэтому реализуются они одинаково — через отметки времени.

Отметки времени вместо счётчиков

Частый вариант из туториалов — счётчик, который каждый кадр уменьшается на Time.deltaTime:

coyoteCounter -= Time.deltaTime;
if (isGrounded) coyoteCounter = coyoteTime;

Он работает, но у него есть неприятные свойства: его нужно уменьшать в правильном методе (в Update или в FixedUpdate, не в обоих), легко забыть уменьшение в одной из веток кода, а значение в отладчике не говорит, когда событие произошло. Удобнее хранить момент события и сравнивать с текущим временем:

float lastGroundedTime;
// ...
bool coyoteOk = Time.time - lastGroundedTime <= coyoteTime;

Здесь ничего не нужно «тикать»: время идёт само. Окна задаются в секундах, а не в кадрах, поэтому поведение одинаково при 30 и при 144 FPS. Окно в «6 кадров», подобранное на мощном ПК, на слабом телефоне превращается в 200 мс и делает управление ватным; окно в 0,1 секунды остаётся окном в 0,1 секунды.

Логика отдельно от MonoBehaviour

Вся «умная» часть прыжка — это три числа и одно правило. Её удобно вынести в обычный C#-класс без зависимостей от Unity: так её проще читать, переиспользовать в 3D-контроллере и тестировать.

public sealed class JumpAssist
{
    readonly float coyoteTime;
    readonly float bufferTime;

    float lastGroundedTime = float.NegativeInfinity;
    float lastPressedTime = float.NegativeInfinity;

    public JumpAssist(float coyoteTime, float bufferTime)
    {
        this.coyoteTime = coyoteTime;
        this.bufferTime = bufferTime;
    }

    // Персонаж стоит на земле в момент now.
    public void SetGrounded(float now) => lastGroundedTime = now;

    // Игрок нажал кнопку прыжка в момент now.
    public void PressJump(float now) => lastPressedTime = now;

    // Можно ли прыгнуть прямо сейчас. Если да — оба события «расходуются».
    public bool TryConsume(float now)
    {
        bool buffered = now - lastPressedTime <= bufferTime;
        bool coyote = now - lastGroundedTime <= coyoteTime;
        if (!buffered || !coyote)
            return false;

        lastPressedTime = float.NegativeInfinity;
        lastGroundedTime = float.NegativeInfinity;
        return true;
    }
}

Обратите внимание на две детали.

Первая — float.NegativeInfinity как начальное значение. Если начать с нуля, то на старте сцены (когда Time.time близко к нулю) логика решит, что персонаж только что стоял на земле и только что нажал прыжок, — и он подпрыгнет сам по себе. Бесконечность в прошлом гарантирует, что событий «не было».

Вторая — расход обоих событий после успешного прыжка. Если не сбросить lastGroundedTime, то в течение coyote-окна после прыжка второе нажатие снова пройдёт проверку: получится незапланированный двойной прыжок. Если не сбросить lastPressedTime, одно нажатие может вызвать два прыжка подряд — второй при следующем касании земли.

Update, FixedUpdate и потерянные нажатия

Классическая ошибка в контроллерах на Rigidbody2D — читать нажатие прямо в FixedUpdate. Методы вроде WasPressedThisFrame() относятся к кадру, а FixedUpdate вызывается с фиксированным шагом физики: при высоком FPS в некоторых кадрах он не вызывается вовсе, при низком — вызывается несколько раз за кадр. В первом случае нажатие теряется, во втором — может обработаться дважды.

Стандартное лечение — флаг: в Update ставим jumpRequested = true, в FixedUpdate его читаем и сбрасываем. Но буфер прыжка решает эту проблему сам. В Update мы только записываем время нажатия, а решение о прыжке принимаем в FixedUpdate, где проверяем землю и меняем скорость тела. Нажатие «живёт» столько, сколько длится окно буфера, и не зависит от того, сколько шагов физики пришлось на кадр.

Есть тонкость со временем. Внутри FixedUpdate свойство Time.time возвращает Time.fixedTime — время текущего шага физики, а в Update — время кадра. Смешивать их можно: шаги физики догоняют время кадра, поэтому следующий шаг после кадра с нажатием всегда наступает позже этого нажатия, а погрешность не превышает одного шага физики (0,02 с при настройках по умолчанию). Для окон в 0,1 секунды этого достаточно. Если нужна полная точность, записывайте время нажатия из контекста ввода (InputAction.CallbackContext.time), но тогда и всё остальное придётся считать в той же шкале — на практике это редко оправдано.

Ещё одно условие: у Input System есть настройка режима обновления. По умолчанию ввод обрабатывается раз в кадр (Process Events In Dynamic Update), и чтение в Update корректно. Если в проекте выбран режим обработки в фиксированном шаге, то WasPressedThisFrame() нужно читать в FixedUpdate, иначе снова будут пропуски.

Высота прыжка вместо «силы прыжка»

Во многих примерах прыжок задаётся числом jumpForce = 12. Геймдизайнеру такое число ничего не говорит: какая получится высота, зависит от гравитации проекта и gravityScale тела. Гораздо удобнее задавать высоту в юнитах и выводить из неё начальную скорость по школьной формуле равноускоренного движения:

// h = v² / (2g)  →  v = √(2gh)
float g = Mathf.Abs(Physics2D.gravity.y) * gravityScale;
float jumpVelocity = Mathf.Sqrt(2f * g * jumpHeight);

Время подъёма до вершины тоже считается сразу: t = v / g. При стандартной гравитации 9,81, gravityScale = 3 и высоте 3 юнита получаем скорость около 13,3 и подъём примерно за 0,45 секунды. Это удобно, когда нужно синхронизировать прыжок с анимацией или звуком.

Ещё одна важная деталь: скорость прыжка нужно присваивать, а не прибавлять через AddForce. В coyote-окне персонаж уже начал падать, и его вертикальная скорость отрицательная. Импульс, добавленный к ней, даст прыжок ниже обычного — ровно в тот момент, когда игрок и так прыгнул «в последний момент». Присвоение linearVelocity.y = jumpVelocity даёт одинаковую высоту в любом случае.

В Unity 6 свойство Rigidbody2D.velocity переименовано в linearVelocity, а drag — в linearDamping. Старые имена помечены устаревшими. Если вы на Unity 2022 LTS или более ранней, замените linearVelocity в примерах на velocity.

Прыжок переменной высоты

Есть два распространённых способа сделать прыжок тем выше, чем дольше удерживается кнопка.

Срез скорости при отпускании

Когда игрок отпускает кнопку во время подъёма, вертикальная скорость умножается на коэффициент, например 0,5. Это просто и даёт резкий, «аркадный» отклик. Минус — по траектории видно излом: персонаж будто натыкается на невидимый потолок. Ещё один минус — это событие (отпускание), а не состояние, и его снова нужно аккуратно переносить из Update в FixedUpdate.

Повышенная гравитация без удержания

Пока кнопка удерживается и персонаж поднимается, гравитация обычная. Если кнопку отпустили, а персонаж ещё летит вверх, гравитация умножается на lowJumpMultiplier. Траектория остаётся гладкой параболой, просто более короткой. Читается не событие, а состояние «кнопка зажата», которое безопасно передавать между Update и FixedUpdate обычным полем.

У этого способа есть приятное свойство: минимальная высота прыжка выражается формулой. При мгновенном отпускании подъём идёт с гравитацией в k раз сильнее, а высота при той же начальной скорости обратно пропорциональна гравитации: hMin = jumpHeight / k. Хотите прыжок от 1 до 3 юнитов — ставьте множитель 3. Реальный минимум будет чуть выше, потому что пару шагов физики до отпускания кнопки гравитация ещё обычная.

Заодно стоит увеличить гравитацию при падении (fallMultiplier). Симметричная парабола выглядит «лунной»: персонаж слишком долго висит в воздухе на спуске. Более быстрое падение делает прыжок весомым. И обязательно ограничьте максимальную скорость падения: без этого при падении с большой высоты персонаж разгоняется так, что проскакивает тонкие коллайдеры, а игрок не успевает среагировать.

Проверка земли

Для проверки земли подойдёт Physics2D.OverlapBox — небольшой прямоугольник под ногами персонажа, который ищет коллайдеры на слое земли. Прямоугольник чуть уже коллайдера персонажа, чтобы стена сбоку не считалась полом.

Здесь прячется баг, который всплывает именно вместе с coyote time. Сразу после прыжка, в первые один-два шага физики, прямоугольник под ногами ещё касается земли. Если считать персонажа стоящим на земле в этот момент, он обновит lastGroundedTime, сбросит флаг «в прыжке» и, если игрок успел нажать кнопку ещё раз, выполнит второй прыжок. Простое решение — не считать персонажа стоящим на земле, пока он летит вверх:

bool touching = Physics2D.OverlapBox(groundCheck.position, groundCheckSize, 0f, groundMask);
isGrounded = touching && rb.linearVelocity.y <= 0.01f;

Это же условие помогает с односторонними платформами (PlatformEffector2D): когда персонаж пролетает такую платформу снизу вверх, прямоугольник её задевает, но вертикальная скорость положительная, и ложного приземления нет.

Контроллер целиком

Собираем всё вместе. Компонент рассчитан на Rigidbody2D с режимом Dynamic и заморозкой вращения по Z, а горизонтальное движение оставлено за рамками, чтобы не размывать пример.

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D))]
public sealed class PlatformerJump : MonoBehaviour
{
    [Header("Ввод")]
    [SerializeField] InputActionReference jumpAction;

    [Header("Прыжок")]
    [SerializeField] float jumpHeight = 3f;
    [SerializeField] float coyoteTime = 0.1f;
    [SerializeField] float jumpBufferTime = 0.12f;

    [Header("Гравитация")]
    [SerializeField] float baseGravityScale = 3f;
    [SerializeField] float lowJumpMultiplier = 3f;
    [SerializeField] float fallMultiplier = 1.8f;
    [SerializeField] float maxFallSpeed = 20f;

    [Header("Проверка земли")]
    [SerializeField] Transform groundCheck;
    [SerializeField] Vector2 groundCheckSize = new Vector2(0.8f, 0.1f);
    [SerializeField] LayerMask groundMask;

    Rigidbody2D rb;
    JumpAssist assist;
    bool jumpHeld;
    bool isGrounded;
    bool isJumping;

    public bool IsGrounded => isGrounded;

    void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        rb.gravityScale = baseGravityScale;
        assist = new JumpAssist(coyoteTime, jumpBufferTime);
    }

    void OnEnable() => jumpAction.action.Enable();
    void OnDisable() => jumpAction.action.Disable();

    void Update()
    {
        InputAction action = jumpAction.action;
        if (action.WasPressedThisFrame())
            assist.PressJump(Time.time);

        jumpHeld = action.IsPressed();
    }

    void FixedUpdate()
    {
        float now = Time.time; // внутри FixedUpdate это Time.fixedTime

        UpdateGround(now);

        if (assist.TryConsume(now))
            Jump();

        ApplyGravity();
    }

    void UpdateGround(float now)
    {
        bool touching = Physics2D.OverlapBox(
            groundCheck.position, groundCheckSize, 0f, groundMask);

        isGrounded = touching && rb.linearVelocity.y <= 0.01f;
        if (isGrounded)
        {
            assist.SetGrounded(now);
            isJumping = false;
        }
    }

    void Jump()
    {
        float g = Mathf.Abs(Physics2D.gravity.y) * baseGravityScale;
        Vector2 velocity = rb.linearVelocity;
        velocity.y = Mathf.Sqrt(2f * g * jumpHeight);
        rb.linearVelocity = velocity;

        isJumping = true;
        isGrounded = false;
    }

    void ApplyGravity()
    {
        Vector2 velocity = rb.linearVelocity;
        float scale = baseGravityScale;

        if (velocity.y < 0f)
            scale *= fallMultiplier;
        else if (velocity.y > 0f && isJumping && !jumpHeld)
            scale *= lowJumpMultiplier;

        rb.gravityScale = scale;

        if (velocity.y < -maxFallSpeed)
        {
            velocity.y = -maxFallSpeed;
            rb.linearVelocity = velocity;
        }
    }

    void OnDrawGizmosSelected()
    {
        if (groundCheck == null) return;
        Gizmos.color = isGrounded ? Color.green : Color.red;
        Gizmos.DrawWireCube(groundCheck.position, groundCheckSize);
    }
}

Несколько пояснений к порядку операций в FixedUpdate. Сначала проверяем землю: если персонаж только что приземлился, это должно попасть в lastGroundedTime до проверки прыжка — так буферизованное нажатие сработает в том же шаге физики, что и касание. Потом решаем, прыгать ли. Последней выставляем гравитацию: она учитывает только что заданную скорость прыжка, поэтому на первом шаге подъёма уже действует правильный множитель.

Почему для расчёта скорости используется baseGravityScale, а не текущий rb.gravityScale? В coyote-окне персонаж падает, и текущий масштаб гравитации уже умножен на fallMultiplier. Если взять его, прыжок с края окажется выше обычного. Высота прыжка должна рассчитываться от гравитации подъёма с зажатой кнопкой — она и равна базовой.

Флаг isJumping нужен, чтобы повышенная гравитация «без удержания» не срабатывала, когда персонажа подбросило чем-то другим: батутом, взрывом, движущейся платформой. Если игрок в этот момент не держит кнопку, полёт не должен обрезаться.

Подбор значений

Универсальных чисел нет, но есть разумные диапазоны, от которых удобно отталкиваться:

  • Coyote time: 0,08–0,15 с. Меньше — эффект не ощущается. Больше — игрок замечает, что прыгает «с воздуха», особенно на быстром беге, когда за 0,2 секунды персонаж успевает отлететь от края на заметное расстояние.
  • Буфер прыжка: 0,1–0,15 с. Слишком длинный буфер даёт обратный эффект: игрок нажал прыжок, передумал, а персонаж всё равно прыгнул при приземлении.
  • lowJumpMultiplier: 2–3. Задаёт отношение максимальной высоты прыжка к минимальной.
  • fallMultiplier: 1,5–2,5. Чем больше, тем «тяжелее» персонаж.

Лучший способ подбора — играть, а не считать. Сделайте значения доступными в инспекторе во время Play Mode: JumpAssist создаётся в Awake, поэтому если хотите менять окна на лету, пересоздавайте его в OnValidate или читайте поля напрямую. Полезно временно вывести в консоль, сколько миллисекунд прошло между событиями при каждом «спасённом» прыжке: так видно, насколько часто окна действительно срабатывают.

public bool TryConsume(float now, out float pressAge, out float groundAge)
{
    pressAge = now - lastPressedTime;
    groundAge = now - lastGroundedTime;
    // ... та же логика, что и выше
}

Если в логе почти все прыжки идут с groundAge около нуля, а с pressAge около нуля — почти ни одного, значит, игроки активно пользуются буфером, и сокращать его опасно.

Unit-тесты логики

Поскольку JumpAssist не зависит от Unity и принимает время параметром, его можно тестировать в Edit Mode без сцены, кадров и ожиданий. Тестам нужна сборка с ссылкой на Unity Test Framework; сам класс должен лежать в сборке, которую видят тесты.

using NUnit.Framework;

public class JumpAssistTests
{
    const float Coyote = 0.1f;
    const float Buffer = 0.12f;

    [Test]
    public void PressBeforeLanding_WithinBuffer_Jumps()
    {
        var assist = new JumpAssist(Coyote, Buffer);
        assist.PressJump(1.00f);
        assist.SetGrounded(1.08f);

        Assert.IsTrue(assist.TryConsume(1.08f));
    }

    [Test]
    public void PressTooEarly_IsIgnored()
    {
        var assist = new JumpAssist(Coyote, Buffer);
        assist.PressJump(1.00f);
        assist.SetGrounded(1.30f);

        Assert.IsFalse(assist.TryConsume(1.30f));
    }

    [Test]
    public void LatePress_WithinCoyote_Jumps()
    {
        var assist = new JumpAssist(Coyote, Buffer);
        assist.SetGrounded(2.00f);
        assist.PressJump(2.07f);

        Assert.IsTrue(assist.TryConsume(2.07f));
    }

    [Test]
    public void ConsumedJump_DoesNotRepeat()
    {
        var assist = new JumpAssist(Coyote, Buffer);
        assist.SetGrounded(3.00f);
        assist.PressJump(3.00f);

        Assert.IsTrue(assist.TryConsume(3.00f));
        assist.PressJump(3.05f);
        Assert.IsFalse(assist.TryConsume(3.05f));
    }

    [Test]
    public void NoEvents_AtSceneStart_NoJump()
    {
        var assist = new JumpAssist(Coyote, Buffer);
        Assert.IsFalse(assist.TryConsume(0f));
    }
}

Тест ConsumedJump_DoesNotRepeat ловит самый неприятный баг этой механики — двойной прыжок в coyote-окне. А NoEvents_AtSceneStart_NoJump проверяет, что начальные значения не превращаются в «прыжок на старте» — ту самую ошибку с нулём вместо бесконечности.

Подводные камни и расширения

Платформы, движущиеся вверх

Условие linearVelocity.y <= 0.01f ломается на лифте: персонаж, стоящий на поднимающейся платформе, имеет положительную вертикальную скорость и никогда не считается стоящим на земле. Есть два выхода. Первый — сравнивать скорость персонажа со скоростью платформы, которую можно получить из найденного коллайдера через attachedRigidbody. Второй — заменить условие на короткую блокировку проверки земли сразу после прыжка:

float ignoreGroundUntil;

void Jump()
{
    // ...
    ignoreGroundUntil = Time.time + 0.1f;
}

void UpdateGround(float now)
{
    bool touching = Physics2D.OverlapBox(
        groundCheck.position, groundCheckSize, 0f, groundMask);
    isGrounded = touching && now >= ignoreGroundUntil;
    // ...
}

Блокировка проще и работает с любыми платформами, но с односторонними платформами снова придётся разбираться отдельно, например фильтровать их по слою.

Удар головой о потолок

Когда персонаж бьётся головой, физика гасит вертикальную скорость до нуля, и дальше работает обычная гравитация падения. Специально обрабатывать это не нужно, но если хочется большей отзывчивости, можно сдвигать персонажа вбок на несколько сотых юнита, когда он задевает угол потолка краем коллайдера (corner correction). Это делается двумя короткими рейкастами вверх от левого и правого края головы.

Прыжок от стены и двойной прыжок

Прыжок от стены — тот же coyote time, только для стены: заведите lastWallTime и сторону стены, и разрешайте прыжок от стены в течение короткого окна после того, как персонаж от неё оторвался. Двойной прыжок — это счётчик доступных прыжков в воздухе, который сбрасывается при приземлении. Важно, чтобы наземный прыжок через coyote time не расходовал прыжок в воздухе: сначала проверяйте TryConsume, и только если он вернул false и буфер ещё свеж, тратьте воздушный прыжок.

Зависание в верхней точке

Многие платформеры слегка ослабляют гравитацию у вершины прыжка, когда вертикальная скорость близка к нулю и кнопка зажата. Персонаж на долю секунды «зависает», и игроку проще прицелиться в платформу. В ApplyGravity это одна дополнительная ветка: если Mathf.Abs(velocity.y) < apexThreshold и jumpHeld, умножить масштаб гравитации на 0,5. Порог порядка 1–2 юнитов в секунду.

3D и CharacterController

JumpAssist переносится в 3D без изменений. С CharacterController всё даже проще: вся логика живёт в Update, вертикальную скорость вы считаете сами, а землю берёте из controller.isGrounded после вызова Move. Свойство isGrounded известно тем, что мигает на неровной поверхности и на спусках, — и coyote time сглаживает это мигание бесплатно: короткие «ложные» отрывы от земли просто не влияют на прыжок.

void Update()
{
    float now = Time.time;
    if (jump.action.WasPressedThisFrame())
        assist.PressJump(now);

    if (controller.isGrounded && verticalVelocity <= 0f)
    {
        assist.SetGrounded(now);
        verticalVelocity = -2f; // прижимаем к земле, чтобы isGrounded не мигал
    }

    if (assist.TryConsume(now))
        verticalVelocity = Mathf.Sqrt(2f * gravity * jumpHeight);

    verticalVelocity -= gravity * Time.deltaTime;
    controller.Move(new Vector3(0f, verticalVelocity, 0f) * Time.deltaTime);
}

Чек-лист

  1. Нажатия записываются в Update как отметки времени, решение о прыжке принимается там, где меняется скорость тела.
  2. Окна coyote time и буфера заданы в секундах, а не в кадрах.
  3. Начальные отметки времени — float.NegativeInfinity, а не ноль.
  4. Успешный прыжок расходует и нажатие, и «землю».
  5. Проверка земли не срабатывает сразу после прыжка: по вертикальной скорости или по короткой блокировке.
  6. Скорость прыжка присваивается, а не прибавляется, и считается из высоты и базовой гравитации.
  7. Переменная высота сделана через состояние «кнопка зажата», а не через событие отпускания.
  8. Скорость падения ограничена сверху.
  9. Логика вынесена в отдельный класс и покрыта Edit Mode-тестами.

Ни одна из этих техник не видна игроку напрямую, и в этом их смысл. Игрок не знает, что прыгнул через 80 мс после края платформы или что его нажатие пролежало в буфере 100 мс. Он просто чувствует, что персонаж делает то, что он хотел.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также