Камера, параллакс и «джус» (Game Feel): как оживить плоскую сцену в 2D-игре

Коротко

2D-сцена кажется живой, когда камера следует за героем плавно и смотрит чуть вперёд, фон разделён на слои с разной скоростью движения (параллакс), а каждое действие игрока получает мгновенный отклик: звук, частицы, короткое сжатие спрайта и редкую дозированную тряску. Плавность камеры считается по формуле 1 - exp(-k * dt), чтобы она не зависела от частоты кадров, а тряска — через убывающую величину trauma, возведённую в квадрат. В статье — формулы, таблицы коэффициентов и проверенный в Node.js код камеры, параллакса, сжатия спрайта и тряски.

Содержание

  1. Почему жёстко привязанная камера утомляет
  2. Плавное следование камеры, не зависящее от частоты кадров
  3. Мёртвая зона и упреждение взгляда
  4. Параллакс: коэффициенты слоёв и формула смещения
  5. Game Feel: шесть приёмов отклика и их дозировка
  6. Тряска камеры через trauma
  7. Практический сценарий: камера, параллакс и отклик в коде на JavaScript
  8. Когда вам это не подойдёт

Почему жёстко привязанная камера утомляет

Камера, жёстко прикреплённая к центру персонажа, повторяет каждый пиксель его движения, включая дрожание при приземлении и разворотах, и при этом показывает одинаково мало пространства впереди и позади. Итай Керен в докладе «Scroll Back: The Theory and Practice of Cameras in Side-Scrollers» (GDC 2015, источник: GDC Vault, проверено: октябрь 2026) разобрал десятки классических игр и показал, что почти все удачные платформеры используют комбинацию мёртвой зоны, упреждения и сглаживания вместо жёсткой привязки.

Плавное следование камеры, не зависящее от частоты кадров

Плавная камера на каждом кадре сдвигается на долю расстояния до цели, и эту долю нужно считать от шага времени. Частая ошибка — cam += (target - cam) * 0.1 в каждом кадре: при 60 к/с камера догоняет цель вдвое быстрее, чем при 30 к/с. Правильная формула:

cam = cam + (target - cam) * (1 - exp(-k * dt))

Параметр k задаёт жёсткость. Время, за которое камера проходит половину оставшегося пути, равно t_1/2 = ln(2) / k. При k = 8 это 87 мс, при k = 4 — 173 мс.

Доля пути, проходимая камерой за кадр при k = 8
ЧастотаШаг dtДоля за кадр 1 - exp(-8 * dt)Доля за 1 секунду
60 к/с16,67 мс12,5%99,97%
30 к/с33,33 мс23,4%99,97%

За секунду результат одинаков, значит ощущение камеры не меняется при просадке частоты.

Мёртвая зона и упреждение взгляда

Мёртвая зона — прямоугольник в центре экрана, внутри которого герой ходит, а камера стоит; упреждение — смещение точки цели камеры вперёд по направлению движения. Для окна 1280×720 рабочие значения: мёртвая зона 160×120 пикселей, упреждение по X — 96–160 пикселей (3–5 клеток по 32), переход упреждения при развороте — за 300–500 мс, чтобы экран не дёргался при каждом шаге назад.

Цель камеры с упреждением:

target_x = x_героя + dir * L   (где dir = -1 или +1, L = 128 пикс)

По вертикали в платформерах камеру обычно двигают только после приземления на новую высоту (platform snapping), иначе экран качается вверх-вниз на каждом прыжке.

Параллакс: коэффициенты слоёв и формула смещения

Параллакс — эффект глубины, при котором дальние слои фона смещаются медленнее ближних. Смещение слоя на экране — это смещение камеры, умноженное на коэффициент слоя p:

x_экран = x_мир - cam_x * p

При p = 0 слой неподвижен (небо), при p = 1 движется вместе с игровым миром, при p > 1 — быстрее (передний план). Многослойный параллакс в аркадах стал узнаваемым с Moon Patrol (Irem, 1982 год, источник: каталог The International Arcade Museum / KLOV, проверено: октябрь 2026), где три слоя фона двигались с разной скоростью.

Рабочая схема коэффициентов параллакса
СлойКоэффициент pСмещение при сдвиге камеры на 1000 пиксКонтраст и насыщенность
Небо0,0–0,10–100 пиксМинимальные
Дальние горы0,2–0,3200–300 пиксНизкие, с цветом неба
Средний план0,5500 пиксСредние
Игровой слой1,01000 пиксМаксимальные, чёткий контур
Передний план1,1–1,31100–1300 пиксТёмные силуэты, не закрывают героя

Правило читаемости: чем дальше слой, тем ближе его цвет к цвету неба. Тогда игрок никогда не спутает фоновую скалу с платформой.

Game Feel: шесть приёмов отклика и их дозировка

Game Feel — это ощущение прямого управления виртуальным объектом, складывающееся из отклика в реальном времени и визуальной полировки (определение Стива Свинка, книга «Game Feel: A Game Designer's Guide to Virtual Sensation», Morgan Kaufmann, 2008 год, проверено: октябрь 2026). Доклад Мартина Йонассона и Петри Пурхо «Juice it or lose it» (2012 год) показал, как одна и та же игра-арканоид меняется, если по очереди добавлять к ней звук, частицы, анимацию и тряску.

Приёмы отклика и безопасная дозировка
ПриёмЧто делаетРабочая дозировка
ЗвукПодтверждает действие на слухКаждое значимое действие, вариация высоты тона ±5–10%
ЧастицыПоказывают точку события6–20 частиц, жизнь 0,2–0,5 с
Сжатие и растяжениеПередают вес и упругостьПриземление: масштаб Y 0,8, X 1,25, возврат за 80–120 мс
Пауза при ударе (hit stop)Подчёркивает силу удара2–6 кадров при 60 к/с (33–100 мс)
ВспышкаОтмечает получение урона1–2 кадра белого цвета
Тряска камерыСообщает о сильном событииАмплитуда 8–16 пикс, 0,2–0,4 с, только для редких событий

Сжатие и растяжение — один из двенадцати принципов анимации студии Disney (Ф. Томас и О. Джонстон, «The Illusion of Life: Disney Animation», 1981 год). Чтобы спрайт не «надувался», произведение масштабов держат равным единице:

sx * sy = 1   →   при sy = 0,8 получаем sx = 1,25

Тряска камеры через trauma

Тряска через trauma — это способ, при котором событие добавляет к переменной trauma значение от 0 до 1, она равномерно убывает со временем, а сила тряски пропорциональна её квадрату. Метод описал Сквиррел Айзерло в докладе «Math for Game Programmers: Juicing Your Cameras With Math» (GDC 2016, источник: GDC Vault, проверено: октябрь 2026).

shake    = trauma * trauma
offset_x = A_max * shake * noise(t)
trauma   = max(0, trauma - r * dt)

Здесь A_max = 12 пикселей, r = 2,5 в секунду (полное затухание от 1 до 0 за 400 мс), noise(t) — шум в диапазоне [-1, 1]. Квадрат нужен, чтобы слабые события давали почти незаметную тряску, а сильные — выразительную: trauma = 0,3 даёт всего 9% амплитуды, trauma = 1 — 100%.

Практический сценарий: камера, параллакс и отклик в коде на JavaScript

Плавную камеру, слои параллакса, сжатие при приземлении и отключаемую тряску можно проверить без графики: каждая функция возвращает числа, которые легко сравнить с таблицами выше. Код ниже проверен в Node.js 24; комментарии показывают его фактический вывод.

// 1. Плавная камера, не зависящая от частоты кадров
function follow(cam, target, k, dt) {
  return cam + (target - cam) * (1 - Math.exp(-k * dt));
}
function runCamera(fps) {
  let cam = 0;
  for (let i = 0; i < fps; i++) cam = follow(cam, 1000, 8, 1 / fps); // 1 секунда
  return cam.toFixed(2);
}
console.log("за 1 с при 60 и 30 к/с:", runCamera(60), runCamera(30)); // 999.66 999.66

// 2. Параллакс: экранная координата слоя с коэффициентом p
const layers = { sky: 0.1, mountains: 0.3, middle: 0.5, game: 1.0 };
function screenX(worldX, camX, p) { return worldX - camX * p; }
for (const name in layers) console.log(name, screenX(0, 1000, layers[name])); // -100 -300 -500 -1000

// 3. Сжатие при приземлении с сохранением площади: sx * sy = 1
function squash(t, duration) {                 // t — время с момента приземления, с
  const k = Math.min(1, t / duration);
  const sy = 0.8 + (1 - 0.8) * k;              // от 0,8 обратно к 1,0
  return { sx: 1 / sy, sy: sy };
}
console.log(squash(0, 0.1), squash(0.1, 0.1)); // { sx: 1.25, sy: 0.8 } { sx: 1, sy: 1 }

// 4. Тряска через trauma: амплитуда 12 пикс, затухание 2,5 в секунду
let trauma = 0;
const shakeEnabled = true;                     // настройка игрока
function addTrauma(v) { if (shakeEnabled) trauma = Math.min(1, trauma + v); }
function shakeOffset(dt, noise) {
  const offset = 12 * trauma * trauma * noise;
  trauma = Math.max(0, trauma - 2.5 * dt);
  return offset;
}
addTrauma(1);
let frames = 0;
while (trauma > 0) { shakeOffset(1 / 60, 1); frames++; }
console.log("тряска затухла за кадров:", frames); // 25 — около 0,4 с при 60 к/с

Дальние горы с p = 0,3 смещаются втрое медленнее игрового слоя, а сжатие возвращается к 1,0 через 0,1 с. Флаг shakeEnabled стоит вынести в настройки игры: при false функция addTrauma ничего не делает, и игрок, чувствительный к движению экрана, отключает тряску одним переключателем.

Когда вам это не подойдёт

Отклик и параллакс мешают там, где важна точность чтения экрана или спокойствие.

  • Головоломки и игры на точное прицеливание: тряска и сжатие мешают считать клетки и траектории; в пошаговых головоломках достаточно звука и подсветки.
  • Игроки, чувствительные к движению экрана: частая тряска, вспышки и быстрый параллакс могут вызывать дискомфорт — их нужно дозировать и давать отключать.
  • Игры на одном неподвижном экране: если уровень целиком помещается в окно, как в аркадах конца 1970-х, слежение камеры и параллакс не нужны, а отклик сводится к звуку, частицам и вспышке.

Мы разрабатываем J2DRU — браузерный 2D игровой движок; он в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).

Читайте также:

Частые вопросы

Какие коэффициенты параллакса ставить для фоновых слоёв?

Типичная схема — небо 0,0–0,1, дальние горы 0,2–0,3, средний план 0,5, игровой слой 1,0 и передний план 1,1–1,3. Коэффициент показывает, какую долю смещения камеры повторяет слой.

Что такое мёртвая зона камеры?

Мёртвая зона (deadzone) — прямоугольник в центре экрана, внутри которого персонаж двигается, а камера стоит. Камера сдвигается, только когда герой выходит за край зоны; это убирает дрожание экрана при мелких шагах.

Не будет ли тряска камеры мешать игрокам?

Сильная и частая тряска утомляет и может вызвать дискомфорт. Ограничивайте амплитуду 8–16 пикселями, длительность 0,2–0,4 с и давайте игроку возможность отключить тряску и вспышки отдельной настройкой.

Как проверить, что сглаживание камеры не зависит от частоты кадров?

Прогоните одну секунду движения камеры к цели с шагом 1/60 и 1/30 с. С формулой 1 − exp(−k × dt) камера в обоих случаях пройдёт одинаковую долю пути (99,97% при k = 8); если доли расходятся, в коде стоит постоянный множитель вместо экспоненты.