Камера за героем

Учим уровень быть больше экрана: цыплёнок бежит вперёд, а мы двигаем за ним не его, а весь мир — это и есть камера.

Камера — сдвиг всей сцены так, чтобы герой оставался примерно в центре экрана, когда он идёт по уровню, который сильно больше холста.

В прошлый раз цыплёнок научился прыгать и приземляться на платформы — ты это сделал в уроке про платформы и приземление. Но есть проблема, которую ты, наверное, уже заметил: цыплёнок упирается в правый край холста и дальше бежать некуда. Уровень помещается в один экран, а это не платформер — это коробка. Сегодня мы сломаем стену экрана: уровень станет длинным, как в Mario или Geometry Dash, а холст превратится в окошко, которое едет за цыплёнком.

Зачем это вообще нужно

Открой в голове любой платформер, в который ты залипал. Марио бежит вправо — и мир будто прокручивается ему навстречу: трубы, гумбы, флажок в конце проплывают мимо, а сам Марио всё время остаётся где-то в центре экрана. Hollow Knight, Celeste, Geometry Dash — везде один и тот же фокус. Уровень огромный, а экран маленький, и кто-то постоянно решает, какой кусок уровня ты сейчас видишь.

Этот «кто-то» и есть камера. Без неё у тебя ровно два плохих варианта. Первый: уровень размером с экран — скучно, пробежал за две секунды. Второй: герой убегает за край холста, и ты управляешь невидимкой где-то справа за рамкой. Камера решает обе беды разом: уровень делаем сколько угодно длинным, а герой всегда в кадре.

К концу урока твой цыплёнок будет бежать по уровню шириной 2400 пикселей, хотя холст у нас всего 800. Пока цыплёнок слева — мир стоит на месте. Как только он добегает до середины экрана и хочет дальше — мир начинает ехать ему навстречу, платформы уплывают влево, а цыплёнок будто застывает в центре. А у краёв уровня камера упрётся и перестанет ехать, чтобы ты не увидел пустоту за обрывом. Поехали разбираться, как это устроено.

И ещё одна приятная деталь: камера — это не «новая большая тема», ради которой надо переписывать игру. Цыплёнок, его координаты, скорость, прыжок, платформы и проверка приземления из прошлого урока остаются ровно теми же. Мы добавляем буквально пару строк и один приём рисования — и плоская коробка превращается в полноценный уровень. Это тот случай, когда мало кода даёт огромный визуальный скачок, и именно за такие моменты геймдев и любят.

Главная идея: двигаем не героя, а мир

Вот мысль, которую важно поймать, потому что новички тут чаще всего ломают голову. Камера — это не объект, который летает над уровнем и снимает кино. Камера — это просто число (а точнее два: cameraX и cameraY), которое говорит: «прежде чем рисовать сцену, сдвинь всё на столько-то пикселей».

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

На холсте «прокрутить мир» — значит перед рисованием сдвинуть начало координат. У контекста для этого есть команда context.translate(dx, dy): она говорит «теперь точка (0, 0) находится вот здесь». Если цыплёнок стоит на уровне в точке x = 1000, а мы хотим видеть его в центре экрана (это 400 при ширине холста 800), мы сдвигаем весь мир влево на 1000 - 400 = 600 пикселей. То есть вызываем context.translate(-600, 0), и тогда объект с уровневой координатой 1000 нарисуется ровно в центре экрана.

Запомни формулу: смещение камеры = позиция героя − половина экрана. А рисуем потом со сдвигом translate(-cameraX, -cameraY). Минус — потому что мир едет в сторону, противоположную движению героя.

Чтобы окончательно поймать идею, разведём в голове два мира координат. Есть координаты уровня — это «честные» позиции, где объект живёт на огромной карте: цыплёнок на отметке 1000, флажок на 2300, дальняя платформа на 2000. Эти числа никогда не меняются от того, куда смотрит камера. И есть координаты экрана — где пиксель реально окажется на холсте шириной 800. Камера — это переводчик между ними: экранная позиция = уровневая позиция − cameraX. Пока ты держишь эти два мира раздельно в голове, с камерой не запутаешься: данные объектов всегда в координатах уровня, а translate один раз переводит весь кадр в координаты экрана.

Пример 1. Длинный уровень и герой, который упирается в край

Сначала вспомним, в каком состоянии у нас цыплёнок. С прошлых уроков у него есть объект chicken с координатами, скоростью и спрайтом — мы ничего не переписываем, просто пользуемся.

const canvas = document.getElementById('game');
const context = canvas.getContext('2d');
const VIEW_W = 800;   // ширина окошка-холста
const VIEW_H = 450;
const LEVEL_W = 2400; // а уровень в три раза длиннее!

// тот самый цыплёнок из прошлых уроков
const chicken = {
  x: 100, y: 300,
  vx: 0, vy: 0,
  w: 48, h: 48,
  speed: 4,
};

const chickenSprite = new Image();
chickenSprite.src = '/sprites/chicken.png';

// платформы расставлены по всей длине уровня
const platforms = [
  { x: 0,    y: 380, w: 2400, h: 70 }, // пол на всю длину
  { x: 500,  y: 300, w: 160, h: 20 },
  { x: 900,  y: 240, w: 160, h: 20 },
  { x: 1500, y: 300, w: 160, h: 20 },
  { x: 2000, y: 240, w: 160, h: 20 },
];

Результат: на экране пока ничего нового — мы только описали данные. Главное здесь: уровень (LEVEL_W = 2400) в три раза шире холста (VIEW_W = 800), а цыплёнок и платформы живут в координатах уровня, а не экрана. Платформа с x: 2000 сейчас вообще за правым краем холста — мы её просто не видим.

Если нарисовать всё это «в лоб», без камеры, то цыплёнок добежит до x = 800 и уедет за край экрана, а правую половину уровня ты никогда не увидишь. Чинить будем камерой.

Пример 2. Считаем смещение камеры и сдвигаем сцену

Теперь — сердце урока. Заводим переменную cameraX и каждый кадр пересчитываем её по позиции цыплёнка. Затем в функции рисования сдвигаем весь мир через translate.

let cameraX = 0;

function updateCamera() {
  // хотим видеть цыплёнка в центре экрана
  cameraX = chicken.x + chicken.w / 2 - VIEW_W / 2;
}

function draw() {
  // 1. чистим весь холст в его настоящих координатах
  context.clearRect(0, 0, VIEW_W, VIEW_H);

  // 2. запоминаем «чистое» состояние контекста
  context.save();

  // 3. сдвигаем весь мир на -cameraX (мир едет влево)
  context.translate(-cameraX, 0);

  // 4. рисуем уровень в УРОВНЕВЫХ координатах
  context.fillStyle = '#5b8c5a';
  for (const p of platforms) {
    context.fillRect(p.x, p.y, p.w, p.h);
  }
  context.drawImage(chickenSprite, chicken.x, chicken.y, chicken.w, chicken.h);

  // 5. возвращаем контекст как было — для UI без сдвига
  context.restore();
}

Результат: цыплёнок бежит вправо, но визуально остаётся в центре холста — вместо него едет мир. Платформы выплывают справа, проползают мимо и уходят за левый край. Та самая платформа с x: 2000, которую раньше было не видно, теперь честно появляется в кадре, когда цыплёнок к ней подбегает. Чувствуется настоящий платформер.

Разберём по шагам, что происходит в draw. Сначала мы чистим холст в его настоящих координатах — это важно, к этому ещё вернёмся в разделе про ошибки. Потом save ставит закладку на «чистое» состояние. Дальше translate(-cameraX, 0) сдвигает всю систему координат: после этой строки, когда мы говорим «нарисуй прямоугольник в точке 2000», холст сам отнимает cameraX и рисует его там, где надо на экране. Поэтому в цикле по платформам и в drawImage цыплёнка мы пишем чистые уровневые координаты p.x и chicken.x — никаких ручных вычитаний. А restore в конце откатывает сдвиг, чтобы следующий кадр снова стартовал с нуля.

Почему именно save и restore

Команда translate сдвигает координаты навсегда — точнее, до тех пор, пока ты их не вернёшь. Если бы мы не обернули рисование в context.save() и context.restore(), сдвиг накапливался бы каждый кадр: −600, потом ещё −600, и через секунду уровень уехал бы в бесконечность. save запоминает текущее состояние контекста, restore возвращает его обратно. Думай об этом как о кнопках «сохранить точку» и «откатиться к ней»: сдвинули мир, нарисовали, откатились — и в следующем кадре снова стартуем с чистого листа.

Бонус: всё, что ты рисуешь после restore(), рисуется без сдвига — в экранных координатах. Это идеально для интерфейса: счёт, полоска здоровья, таймер. Им сдвигаться вместе с миром не нужно, они должны висеть в углу экрана.

Пример 3. Камера, которая упирается в края уровня

Запусти Пример 2 и добеги цыплёнком до самого начала уровня. Заметишь некрасивое: слева от первой платформы появляется пустота — серый холст, за который мир «недокрутился». То же самое в конце уровня справа. Это потому, что камера тупо ставит цыплёнка в центр, даже когда центрировать уже нечего — мир кончился.

Лечится одной строчкой-ограничителем. Камера не должна уезжать левее нуля и не должна показывать то, что за правым краем уровня. Максимальное смещение — это LEVEL_W - VIEW_W: дальше уже видно пустоту за обрывом.

function updateCamera() {
  // 1. как и раньше — целимся в центр
  cameraX = chicken.x + chicken.w / 2 - VIEW_W / 2;

  // 2. не уезжаем левее начала уровня
  if (cameraX < 0) {
    cameraX = 0;
  }

  // 3. не уезжаем правее конца уровня
  const maxCamera = LEVEL_W - VIEW_W; // 2400 - 800 = 1600
  if (cameraX > maxCamera) {
    cameraX = maxCamera;
  }
}

Результат: теперь у начала уровня цыплёнок спокойно бежит к левому краю экрана, а камера стоит на месте (cameraX = 0) — никакой пустоты слева. В середине уровня всё едет как раньше, цыплёнок в центре. А у финиша камера снова замирает, цыплёнок добегает до правого края холста сам, и пустоты за обрывом не видно. Уровень ощущается цельным от начала до конца.

Эти три случая — «прижались к левому краю», «свободно едем по центру», «прижались к правому краю» — и есть вся работа камеры в 2D-платформере. То же самое можно сделать и по вертикали через cameraY и translate(-cameraX, -cameraY), если уровень высокий, как в Celeste.

Обрати внимание на маленький приятный момент: благодаря ограничителям цыплёнок у краёв уровня снова свободно бегает к самому краю холста — то есть управление в начале и в конце ощущается иначе, чем в середине. В середине ты как будто толкаешь мир, а у границ снова двигаешь самого героя. Так и должно быть: в Mario у первого экрана Марио бежит к левому краю сам, а мир стоит. Игрок этого не осознаёт, но чувствует, что всё «правильно». Хорошая камера — это та, которую не замечаешь.

Частые ошибки и подводные камни

  • Забыли restore (или save). Самый частый баг. Без context.restore() сдвиг translate копится кадр за кадром, и через пару секунд весь мир улетает в чёрную бездну, а ты видишь пустой холст. Правило простое: каждому save — свой restore, как открывающей скобке — закрывающая.

  • Чистят холст внутри сдвига. Если вызвать clearRect(0, 0, VIEW_W, VIEW_H) после translate, ты очистишь не тот кусок экрана — прямоугольник очистки тоже уедет. Чистить холст надо до save/translate, в настоящих экранных координатах.

  • Прибавляют cameraX к координатам объектов вручную. Новички пишут context.fillRect(p.x - cameraX, p.y, ...) для каждого объекта. Это работает, но громоздко и легко забыть где-нибудь вычесть. Гораздо чище один раз сдвинуть весь мир через translate(-cameraX, 0) и рисовать всё в честных уровневых координатах.

  • Путают знак. Если написать translate(cameraX, 0) без минуса, мир поедет в ту же сторону, что и герой, и цыплёнок мгновенно улетит за край вдвое быстрее. Запомни: герой вправо — мир влево, поэтому translate(-cameraX, 0).

  • Рисуют интерфейс внутри сдвига. Если нарисовать счёт или полоску здоровья до restore(), они будут ездить вместе с миром и уплывать за край экрана. UI рисуем строго после restore(), в экранных координатах.

Мини-проект: добей камеру сам

Базу ты собрал. Теперь три апгрейда, которые превратят учебную камеру в настоящую — делай по шагам, после каждого смотри, что изменилось на экране.

  1. Мёртвая зона. Сейчас камера дёргается за каждым пикселем цыплёнка — даже когда он чуть качнулся на месте. Сделай «мёртвую зону»: камера трогается, только если цыплёнок ушёл дальше, чем на 80 пикселей от центра экрана. Подсказка: сравни chicken.x + chicken.w / 2 с cameraX + VIEW_W / 2 и двигай камеру лишь на разницу сверх порога.

  2. Плавность. Жёсткая центровка выглядит резко. Сделай, чтобы камера догоняла цель: вместо cameraX = target напиши cameraX += (target - cameraX) * 0.1. Камера будет плавно подъезжать, как в хороших платформерах. Не забудь после этого всё равно прогонять ограничители краёв.

  3. Маркер финиша. Поставь в конце уровня (около x: 2300) яркий жёлтый флажок-прямоугольник и убедись, что камера у него останавливается ровно по формуле LEVEL_W - VIEW_W, не показывая пустоту за ним.

Если всё три пункта заработали — поздравляю, у тебя камера уровня настоящей инди-игры. Покрути цыплёнком туда-сюда и полюбуйся, как мир плавно едет за ним.

Итоги

Сегодня ты сломал стену экрана. Теперь твой уровень может быть сколько угодно длинным, а камера держит цыплёнка в кадре. Главное, что стоит унести с собой:

  • Камера — это число, а не объект. cameraX = позиция героя − половина экрана.

  • Двигаем не героя, а весь мир. context.translate(-cameraX, 0) сдвигает сцену, и герой будто застывает в центре.

  • save и restore оборачивают рисование мира, чтобы сдвиг не копился и чтобы интерфейс рисовался без сдвига.

  • Ограничители краёв (0 и LEVEL_W - VIEW_W) не дают камере показать пустоту за уровнем.

В следующем уроке мы добавим уровню глубину: дальние горы и облака будут ехать медленнее ближних платформ — это называется параллакс, и держится он ровно на той же cameraX, которую ты только что освоил. Один множитель скорости на слой — и плоский фон превратится в объёмный мир. До встречи, твой цыплёнок уже разминается!

Проверьте себя
1. Что на самом деле представляет собой камера в нашем 2D-платформере?
AОтдельный игровой объект, который летает над уровнем
BЧисло (cameraX), на которое мы сдвигаем всю сцену перед рисованием
CHTML-элемент <video> поверх холста
DВторой холст, наложенный на первый
2. По какой формуле мы считаем смещение камеры, чтобы цыплёнок был в центре экрана?
AcameraX = позиция героя + ширина экрана
BcameraX = ширина экрана − позиция героя
CcameraX = позиция героя − половина ширины экрана
DcameraX = половина уровня − позиция героя
3. Почему мы вызываем translate(-cameraX, 0), а не translate(cameraX, 0)?
AМинус ускоряет рисование
BМир должен ехать в сторону, противоположную движению героя
CБез минуса translate не работает
DЭто случайность, знак не важен
4. Зачем оборачивать рисование мира в context.save() и context.restore()?
AЧтобы сдвиг translate не накапливался кадр за кадром и чтобы UI рисовался без сдвига
BЧтобы сохранить картинку в файл
CЧтобы ускорить clearRect
DЭто нужно только для звука
5. Чему равно максимальное смещение камеры, чтобы не показывать пустоту за правым краем уровня?
ALEVEL_W + VIEW_W
BVIEW_W − LEVEL_W
CLEVEL_W − VIEW_W
DLEVEL_W / 2
6. Где правильно вызывать clearRect, чтобы очистить холст?
AПосле translate, чтобы очистка тоже сдвинулась
BДо save/translate, в настоящих экранных координатах
CВнутри цикла по платформам
DВообще не нужно чистить холст при использовании камеры