Зачем хорошие платформеры прощают маленькие ошибки
08 Окт 2026
Игрок нажал кнопку прыжка у самого края платформы, а персонаж вместо прыжка падает вниз. Или нажал прыжок чуть раньше приземления, но прыжок не сработал. Персонаж погибает, хотя опасный объект задел его буквально одним пикселем.
Формально игра во всех этих случаях может быть права. Проблема в том, что игроку от этого не легче.
Чтобы управление ощущалось отзывчивым, разработчики дают игроку больше контроля над прыжком и немного увеличивают допустимые окна нажатий.
Управление прыжком
Переменная высота прыжка
Самый простой прыжок работает, если игрок нажал кнопку, пока персонаж стоит на земле:
if (jumpPressed && grounded) {
velocityY = -jumpSpeed;
}
В прмере координата Y увеличивается вниз, поэтому отрицательная скорость двигает персонажа вверх.
Нажали кнопку — получили вертикальную скорость. Всё работает, но высота прыжка всегда одна и та же. Игрок выбирает момент старта, а саму дугу изменить уже не может.
Гораздо удобнее, когда короткое нажатие даёт небольшой прыжок, а удержание кнопки — высокий. Один из самых простых способов — уменьшить вертикальную скорость при отпускании кнопки:
if (jumpReleased && velocityY < 0) {
velocityY *= 0.5;
}
Если отпустить кнопку во время подъёма, скорость уменьшится, вершина траектории наступит раньше и прыжок получится короче. Коэффициент 0.5 определяет, насколько резко обрезается подъём: при слишком маленьком значении персонаж будет почти сразу останавливаться в воздухе.
Другой вариант — увеличивать гравитацию после отпускания кнопки:
let gravityScale = 1;
if (velocityY < 0 && !jumpHeld) {
gravityScale = 2;
}
velocityY += gravity * gravityScale * dt;
Здесь dt — время между обновлениями в секундах. Скорость меняется постепенно, поэтому можно получить более плавный короткий прыжок.
Важна не только максимальная высота
jumpSpeed задаёт начальную скорость подъёма, gravity — насколько быстро меняется вертикальная скорость, а коэффициент обрезания или гравитация после отпускания кнопки — разницу между коротким и длинным прыжком. Скорость падения ограничивает maxFallSpeed.
В опубликованном Player.cs Celeste для прыжка тоже отдельно заданы начальная скорость и параметры гравитации. Ещё одна настройка задаёт, как долго удержание кнопки поддерживает скорость подъёма.
Если увеличить jumpSpeed, не меняя гравитацию, прыжок станет выше и дольше. Персонаж допрыгивает до нужной высоты, но слишком долго возвращается на землю. Если просто уменьшать гравитацию, замедлятся и подъём, и падение, а прыжок станет ватным.
Поэтому эти части траектории часто настраивают отдельно. Например, при падении применяют более сильную гравитацию, чтобы персонаж быстрее возвращался на платформу. Проверять результат удобно на двух соседних препятствиях: низком проходе для короткого прыжка и высокой платформе для максимального. Оба должны проходиться уверенно одной и той же кнопкой.
Ослабление гравитации у вершины прыжка
У вершины дуги гравитацию можно ненадолго уменьшить, пока вертикальная скорость близка к нулю и игрок держит кнопку:
const nearApex = Math.abs(velocityY) < apexThreshold;
const gravityScale = nearApex && jumpHeld ? 0.5 : 1;
velocityY += gravity * gravityScale * dt;
Персонаж проводит около вершины немного больше времени. Игрок успевает поправить горизонтальное положение перед приземлением, особенно на небольшой платформе.
В Celeste при удержании кнопки около вершины применяется половинная гравитация. Мэдди Торсон описывает этот приём в Celeste & Forgiveness.
Если apexThreshold слишком велик, замедление затронет заметную часть подъёма и падения: вместо небольшой помощи получится зависание. При сочетании с коротким прыжком отпускание кнопки должно прекращать замедление.
Но даже хорошо настроенная траектория не поможет, если игра отвергает нажатие из-за одного кадра разницы.
Прощение ошибок во времени
Coyote time
Проверка jumpPressed && grounded разрешает прыжок, только пока под персонажем есть опора. Если игрок нажал кнопку через один кадр после схода с края, grounded уже будет false.
При быстром движении такой отказ особенно заметен: игрок только что видел персонажа на платформе и рассчитывал успеть. Coyote time позволяет прыгнуть ещё некоторое время после схода с края:
if (grounded) {
coyoteTimer = coyoteTime;
} else {
coyoteTimer = Math.max(0, coyoteTimer - dt);
}
if (jumpPressed && coyoteTimer > 0) {
velocityY = -jumpSpeed;
grounded = false;
coyoteTimer = 0;
}
Название отсылает к мультяшному койоту, который успевает немного пробежать по воздуху, прежде чем замечает отсутствие земли.
После успешного прыжка таймер обязательно сбрасывается. Иначе следующее нажатие до его истечения может дать второй прыжок в воздухе. При этом grounded нужно сбросить, чтобы устаревшее состояние не открыло окно заново.
В опубликованном коде Celeste это окно называется JumpGraceTime и составляет 0.1 секунды. При прыжке jumpGraceTimer обнуляется.
Для настройки полезно перевести время в расстояние. При постоянной горизонтальной скорости:
расстояние после края = скорость × длительность coyote time
При 400 px/s окно в 0.1 s разрешает прыгнуть примерно в 40 пикселях от точки схода. Если герой успевает далеко отлететь от платформы, такая помощь уже будет заметна. Поэтому проверять окно стоит и на обычной, и на максимальной скорости.
Буфер ввода
Coyote time помогает при позднем нажатии. Есть и обратная ситуация: игрок нажал прыжок немного раньше приземления.
В момент нажатия персонаж ещё в воздухе, поэтому прыгать нельзя. На следующем кадре он уже стоит на платформе, но событие нажатия прошло. Если команда нигде не сохраняется, игроку придётся нажать ещё раз.
Буфер ввода хранит нажатие на короткое время и выполняет команду, когда действие становится доступным:
if (jumpPressed) {
jumpBufferTimer = jumpBufferTime;
} else {
jumpBufferTimer = Math.max(0, jumpBufferTimer - dt);
}
if (jumpBufferTimer > 0 && canJump) {
jump();
jumpBufferTimer = 0;
}
Здесь canJump означает, что персонаж стоит на земле или ещё не истекло окно coyote time. Если персонаж приземлится до истечения таймера, сохранённый прыжок сработает. После выполнения команда удаляется.
Буфер запускается по событию jumpPressed, а не по удержанию jumpHeld. Иначе удерживаемая кнопка будет постоянно обновлять таймер, и персонаж начнёт автоматически прыгать после каждого приземления. Такое поведение можно сделать намеренно, но оно требует отдельного решения.
При переменной высоте прыжка есть ещё одна тонкость: игрок может отпустить кнопку до выполнения сохранённого прыжка. Здесь нужно решить, будет ли это короткий прыжок или отпускание отменит команду. Проверка только текущего jumpReleased не увидит уже прошедшее событие.
Тот же буфер подходит для атаки, рывка или переката. Например, нажатие во время завершения предыдущей анимации может дождаться первого доступного момента. В небольшой игре достаточно отдельных таймеров — общая очередь команд понадобится, если появятся правила приоритета и замены действий.
Coyote time и буфер работают вместе
Coyote time ненадолго сохраняет право на прыжок после схода с платформы, а буфер — нажатие перед приземлением.
После обновления обоих таймеров проверяем, осталось ли право на прыжок и сохранено ли нажатие:
if (coyoteTimer > 0 && jumpBufferTimer > 0) {
velocityY = -jumpSpeed;
grounded = false;
coyoteTimer = 0;
jumpBufferTimer = 0;
}
При приземлении окно coyote time снова открывается, и ожидающее нажатие может выполниться. При сходе с края ещё не истёкший таймер позволяет выполнить новый прыжок в воздухе.
Импульс движущихся платформ
Представим платформу, которая быстро едет вправо. Персонаж стоит на ней, перемещается вместе с ней и прыгает. Если после отрыва его горизонтальная скорость внезапно перестаёт учитывать движение платформы, траектория может выглядеть неожиданно.
При прыжке можно передать персонажу скорость опоры. Если velocityX уже включает скорость платформы, прибавлять её повторно нельзя. В примере ниже платформа переносит персонажа отдельно, а его собственная скорость хранится в velocityX и velocityY.
Можно также ненадолго сохранить скорость опоры, чтобы прыжок получил ту же добавку даже сразу после её остановки:
liftMomentumTimer = Math.max(0, liftMomentumTimer - dt);
if (grounded && movingPlatform) {
const vx = movingPlatform.velocityX;
const vy = movingPlatform.velocityY;
const platformIsMoving =
Math.abs(vx) > liftSpeedEpsilon ||
Math.abs(vy) > liftSpeedEpsilon;
if (platformIsMoving) {
liftVelocityX = vx;
liftVelocityY = vy;
liftMomentumTimer = liftMomentumTime;
}
}
Пока персонаж стоит на движущейся платформе, запоминаем её скорость и заново запускаем таймер. После остановки платформы скорость не перезаписывается нулём, а таймер отсчитывает оставшееся время. Небольшой порог liftSpeedEpsilon позволяет не считать движением погрешность вычислений.
В момент прыжка используем сохранённую скорость:
velocityY = -jumpSpeed;
if (liftMomentumTimer > 0) {
velocityX += liftVelocityX;
velocityY += Math.min(liftVelocityY, 0);
}
liftMomentumTimer = 0;
В этом варианте вертикальная добавка учитывается только при движении платформы вверх: она усиливает прыжок, а движение вниз его не ослабляет. В Celeste сохранение скорости после остановки описано как Lift Momentum Storage.
Сохранённую скорость привязывают к конкретной платформе и очищают при смене опоры. Проверять настройку удобно прыжками непосредственно до и после остановки. Если усиление остаётся доступным спустя заметную паузу, время хранения слишком велико.
Прощающая геометрия
Collider, hurtbox и hitbox
Частая ошибка — использовать одну геометрию для всех взаимодействий персонажа. Есть спрайт персонажа размером 32×48, значит, берём прямоугольник 32×48 и проверяем им стены, шипы и атаки.
Но у спрайта могут быть волосы, плащ, оружие и анимированные конечности. Если каждая выступающая деталь участвует в столкновениях, игрок начинает получать урон там, где по картинке почти увернулся.
Удобнее разделить области по назначению:
| Область | За что отвечает |
|---|---|
| Collider | Физическое взаимодействие с землёй, стенами и платформами |
| Hurtbox | Получение урона персонажем |
| Hitbox атаки | Область, которой оружие, снаряд или противник наносит удар |
Collider должен задавать предсказуемую форму персонажа. Прямоугольник или капсула часто удобнее точного контура спрайта: анимация рук не должна менять то, как персонаж стоит у стены.
Hurtbox можно сделать немного меньше, чтобы пограничное касание опасности не приводило к урону. Тогда атаки проверяются отдельно:
if (overlaps(attack.hitbox, player.hurtbox)) {
damage(player);
}
Физическое движение продолжает использовать collider. Так можно простить касание шипа, сохранив привычное поведение у стен и пола.
Где именно прощать столкновение
Можно уменьшить hurtbox игрока, опасную область шипа или и то и то. Настраивать их лучше относительно того, что игрок воспринимает как тело персонажа: одинаковые по размеру спрайты могут сильно отличаться количеством волос, одежды и других деталей.
Поэтому условные “70% от спрайта” мало говорят о результате. Гораздо полезнее включить отображение collider, hurtbox и атак, а затем посмотреть пограничные касания покадрово.
Если персонаж едва задел шип кончиком плаща, отсутствие урона выглядит естественно. Если шип уже глубоко внутри тела, а герой продолжает движение, границы уменьшены слишком сильно. Хорошая проверка: можно ли по остановленному кадру уверенно сказать “здесь персонаж должен получить урон”?
Corner correction
Похожая пограничная ситуация возникает с обычной геометрией уровня. Персонаж прыгает рядом с платформой и парой пикселей головы цепляет нижний угол.
Столкновение остановит подъём. Corner correction позволяет сначала попробовать небольшой сдвиг в сторону:
function tryCornerCorrection(maxOffset: number): boolean {
for (let offset = 1; offset <= maxOffset; offset++) {
if (canSlideAroundCorner(-offset)) {
x -= offset;
return true;
}
if (canSlideAroundCorner(offset)) {
x += offset;
return true;
}
}
return false;
}
Здесь canSlideAroundCorner() проверяет, свободны ли путь бокового смещения и позиция для продолжения подъёма. Проверять нужно весь collider, чтобы коррекция не протащила персонажа через соседнюю стену.
Если обход найден, персонаж смещается и продолжает движение вверх. Только после неудачной коррекции столкновение обрабатывается как обычный удар о потолок.
В Celeste & Forgiveness отдельно показаны коррекция угла при прыжке и коррекция при горизонтальном рывке, которая помогает попасть на край платформы.
Сдвиг должен быть небольшим относительно размера персонажа. Если для обхода потолка нужно передвинуть героя на половину его ширины, результат уже выглядит как телепортация. Проверять коррекцию стоит у одиночного угла и в узком проходе, где рядом есть вторая стена.
Ещё несколько поблажек
Прыжок от стены (wall jump) можно разрешать на небольшом расстоянии от неё, чтобы буквально несколько пикселей не мешали оттолкнуться. При горизонтальном рывке можно слегка поднять персонажа на край платформы, если он почти попал на неё. Обе возможности также описаны в разборе Celeste.
Другой вариант — ненадолго сохранять горизонтальную скорость после столкновения со стеной. Например, персонаж прыгает вверх и вправо, задевает боком небольшой выступ и продолжает подниматься. Если он успеет подняться выше выступа, пока сохранённая скорость ещё доступна, движение вправо возобновится с прежней скоростью. В опубликованном Player.cs для такого поведения есть отдельные поля wallSpeedRetentionTimer и wallSpeedRetained.
Порядок обновления тоже имеет значение
Когда таймеры и столкновения работают вместе, результат зависит от порядка обработки. Если сначала проверить прыжок, а потом определить приземление, сохранённая команда сможет выполниться только при следующем обновлении физики.
Но и переносить все прыжки в конец обновления неудобно: если персонаж уже стоит на земле, нажатие должно влиять на текущее движение. Один из вариантов — проверять готовую команду до перемещения, а после столкновений дать ей ещё одну возможность выполниться при приземлении:
function update(dt: number): void {
readInput();
updateJumpBuffer(dt);
updateCoyoteTimer(dt);
tryConsumeBufferedJump();
simulateMovement(dt);
resolveCollisions();
updateGroundedState();
if (justLanded) {
coyoteTimer = coyoteTime;
tryConsumeBufferedJump();
}
}
tryConsumeBufferedJump() проверяет оба таймера и при успехе сбрасывает их, задаёт скорость прыжка и снимает состояние опоры. Поэтому повторный вызов сам по себе не даёт второго прыжка.
При таком порядке приземление запускает сохранённый прыжок в том же обновлении физики, но движение вверх начнётся на следующем шаге. Чтобы обработать отрыв за оставшееся время текущего шага, нужно отдельно рассчитать этот участок движения. Конкретный порядок зависит от движка и точности симуляции.
Если ввод и физика обновляются в разных циклах, события нажатия нужно сохранять между ними. Иначе короткое нажатие может целиком пройти между двумя физическими обновлениями.
Настраивать нужно систему целиком
Даже правильно работающие механики могут давать неожиданный результат, если окна прощения слишком велики. Большой буфер может заставить персонажа прыгнуть после нажатия, о котором игрок уже забыл. Длинный coyote time позволит оттолкнуться далеко от края.
Есть и менее очевидные сочетания. Сильное обрезание прыжка может мешать попасть на маленькую платформу. Слишком широкий диапазон ослабленной гравитации затянет падение. А коррекция угла и переданный импульс вместе могут сдвинуть персонажа дальше, чем ожидается. Если в игре есть подбросы и рывки, для них правила изменения скорости стоит настраивать отдельно: отпускание кнопки прыжка не должно случайно менять их траекторию.
Для проверки удобно собрать небольшую тестовую комнату:
- край платформы для прыжков на обычной и максимальной скорости;
- площадку для нажатий непосредственно до приземления, включая короткое нажатие с отпусканием в воздухе;
- низкий проход и высокую платформу для короткого и максимального прыжка;
- узкий промежуток между опасностями для проверки hurtbox;
- угол потолка и узкий проход для corner correction;
- движущуюся платформу с остановкой для прыжков до остановки, сразу после неё и после истечения окна хранения скорости.
Полезно также повторить несколько быстрых прыжков подряд. Так легче заметить неочищенный буфер, повторно открывшееся право на прыжок или случайное повторное использование скорости платформы.
В одной комнате каждый случай можно воспроизвести десяток раз, меняя по одному параметру. И проверить результат при разной частоте отрисовки: окна, заданные в секундах, не должны превращаться в разную по длительности помощь из-за FPS.
Большинство этих приёмов занимает немного кода. Основная работа — подобрать границы, при которых они снимают спорные отказы, а движение остаётся понятным. Прыжок у самого края срабатывает, раннее нажатие дожидается земли, едва задетый угол не обрывает подъём. Когда всё настроено удачно, игрок просто двигается так, как рассчитывал.