Камера за героем
Учим уровень быть больше экрана: цыплёнок бежит вперёд, а мы двигаем за ним не его, а весь мир — это и есть камера.
Камера — сдвиг всей сцены так, чтобы герой оставался примерно в центре экрана, когда он идёт по уровню, который сильно больше холста.
В прошлый раз цыплёнок научился прыгать и приземляться на платформы — ты это сделал в уроке про платформы и приземление. Но есть проблема, которую ты, наверное, уже заметил: цыплёнок упирается в правый край холста и дальше бежать некуда. Уровень помещается в один экран, а это не платформер — это коробка. Сегодня мы сломаем стену экрана: уровень станет длинным, как в 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(), в экранных координатах.
Мини-проект: добей камеру сам
Базу ты собрал. Теперь три апгрейда, которые превратят учебную камеру в настоящую — делай по шагам, после каждого смотри, что изменилось на экране.
Мёртвая зона. Сейчас камера дёргается за каждым пикселем цыплёнка — даже когда он чуть качнулся на месте. Сделай «мёртвую зону»: камера трогается, только если цыплёнок ушёл дальше, чем на 80 пикселей от центра экрана. Подсказка: сравни
chicken.x + chicken.w / 2сcameraX + VIEW_W / 2и двигай камеру лишь на разницу сверх порога.Плавность. Жёсткая центровка выглядит резко. Сделай, чтобы камера догоняла цель: вместо
cameraX = targetнапишиcameraX += (target - cameraX) * 0.1. Камера будет плавно подъезжать, как в хороших платформерах. Не забудь после этого всё равно прогонять ограничители краёв.Маркер финиша. Поставь в конце уровня (около
x: 2300) яркий жёлтый флажок-прямоугольник и убедись, что камера у него останавливается ровно по формулеLEVEL_W - VIEW_W, не показывая пустоту за ним.
Если всё три пункта заработали — поздравляю, у тебя камера уровня настоящей инди-игры. Покрути цыплёнком туда-сюда и полюбуйся, как мир плавно едет за ним.
Итоги
Сегодня ты сломал стену экрана. Теперь твой уровень может быть сколько угодно длинным, а камера держит цыплёнка в кадре. Главное, что стоит унести с собой:
Камера — это число, а не объект.
cameraX= позиция героя − половина экрана.Двигаем не героя, а весь мир.
context.translate(-cameraX, 0)сдвигает сцену, и герой будто застывает в центре.save и restore оборачивают рисование мира, чтобы сдвиг не копился и чтобы интерфейс рисовался без сдвига.
Ограничители краёв (
0иLEVEL_W - VIEW_W) не дают камере показать пустоту за уровнем.
В следующем уроке мы добавим уровню глубину: дальние горы и облака будут ехать медленнее ближних платформ — это называется параллакс, и держится он ровно на той же cameraX, которую ты только что освоил. Один множитель скорости на слой — и плоский фон превратится в объёмный мир. До встречи, твой цыплёнок уже разминается!