Состояние игрока и обновление

Учимся хранить всё про цыплёнка в одном объекте state и менять его каждый кадр — отсюда начинается настоящее движение.
Состояние игрока (state) — это набор данных об объекте: его координаты x и y, скорость и другие свойства, которые меняются каждый кадр.

Зачем вообще что-то «хранить»?

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

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

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

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

const chicken = {
  x: 50,
  y: 200,
  vx: 2,   // скорость по горизонтали: +2 пикселя за кадр
  vy: 0,   // скорость по вертикали пока нулевая
};

Результат: на экране пока ничего не происходит — мы только описали, что цыплёнок стоит в точке (50, 200) и собирается ехать вправо со скоростью 2 пикселя за кадр. Это его паспорт, который мы дальше будем менять.

Состояние как «паспорт» объекта

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

У цыплёнка анкета попроще, но смысл тот же. Главные поля:

  • x и y — где он сейчас на холсте (координаты в пикселях);
  • vx и vyвектор скорости, пара чисел, показывающая, насколько он сдвинется по горизонтали и вертикали за один кадр.

Почему именно объект, а не четыре отдельные переменные chickenX, chickenY, chickenVx, chickenVy? Потому что цыплёнок один, и логично держать всё про него в одной коробке. Когда позже появятся враги и монетки, у каждого будет своя такая же коробка, и код останется аккуратным. К тому же объект легко передать в функцию целиком: «вот тебе цыплёнок, обнови его».

Представь, что у тебя четыре разные переменные. Захотел добавить второго цыплёнка — придётся заводить ещё четыре: chicken2X, chicken2Y и так далее. Десять врагов? Сорок переменных, и ты в них утонешь. А с объектами всё иначе: один цыплёнок — один объект, десять врагов — массив из десяти объектов, и каждый знает про себя всё сам. Это и называется красивым словом сущность (entity) — любой игровой объект, описанный набором данных и поведением. Пока у нас одна сущность — цыплёнок, но привычку складывать её свойства в объект закладываем уже сейчас, чтобы потом не переучиваться.

Вектор скорости — это пара чисел (vx, vy), показывающая, насколько объект сдвигается по горизонтали и вертикали за кадр. Положительный vx — едем вправо, отрицательный — влево.

Золотое правило: обновили — потом нарисовали

Самая важная идея урока — разделить две вещи, которые новички вечно смешивают:

  1. update — посчитать новые числа. Сдвинуть x, проверить границы, поменять состояние. Здесь мы вообще ничего не рисуем.
  2. draw — взять текущее состояние и нарисовать кадр. Здесь мы ничего не считаем и не меняем — только показываем то, что уже посчитано.

Это как приготовить блюдо, а потом подать на стол. Сначала на кухне всё намешали (update), потом красиво выложили на тарелку (draw). Если начать выкладывать недоваренное и доваривать прямо на тарелке — получится каша. В коде «каша» проявляется как баги, которые невозможно поймать: то цыплёнок дёргается, то скорость меняется в случайных местах. Держи кухню и стол отдельно.

У этого разделения есть и приятный бонус на будущее. Когда логика (update) и картинка (draw) живут раздельно, ты можешь, например, поставить игру на паузу — просто перестать вызывать update, но продолжать рисовать последний кадр. Или, наоборот, посчитать состояние несколько раз, а нарисовать один раз, если игра тормозит. Всё это возможно только потому, что считаем мы в одном месте, а рисуем — в другом. Так что это не занудное правило ради правила, а фундамент, на котором потом держатся пауза, замедление времени и куча других вещей, которые делают игру живой.

Запомни порядок внутри одного оборота цикла: сначала обработать ввод (нажатые клавиши — будет в следующем уроке), потом обновить состояние (update), потом нарисовать кадр (draw). Это и есть классический игровой цикл — бесконечно повторяющийся набор шагов, на котором держится любая игра. Сегодня мы делаем средний шаг по-настоящему мощным.

Пример 1. Цыплёнок едет вправо

Соберём минимальный движок: цикл из прошлого урока, функция update и функция draw. Спрайт цыплёнка (chickenSprite) мы уже загружали раньше — переиспользуем его как есть.

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

const chicken = {
  x: 50,
  y: 200,
  vx: 2,
  vy: 0,
};

function update() {
  chicken.x = chicken.x + chicken.vx;
  chicken.y = chicken.y + chicken.vy;
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
}

function loop() {
  update();
  draw();
  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

Результат: цыплёнок появляется у левого края на высоте 200 и плавно едет вправо, добавляя по 2 пикселя к своему x каждый кадр. Через пару секунд он спокойно уезжает за правый край и пропадает — границы мы пока не проверяем.

Разберём по шагам, что тут происходит за один оборот цикла:

  • update() прибавляет vx к x. Был 50 — стал 52, потом 54, 56… Само число поехало.
  • draw() сначала вызывает clearRect — стирает весь предыдущий кадр. Без этого цыплёнок оставлял бы за собой шлейф из копий, как смазанная фотка.
  • Потом drawImage рисует спрайт в новой точке chicken.x, chicken.y.
  • requestAnimationFrame(loop) просит браузер позвать loop снова перед следующей перерисовкой — и круг замыкается.

Заметь: в update мы трогаем только числа, в draw — только холст. Кухня и стол разведены.

Пример 2. Удержим цыплёнка в границах

То, что цыплёнок уезжает в никуда, в реальной игре никуда не годится. Добавим в update проверку: если он доехал до правого края — стоп. Ширину спрайта будем считать равной 48 пикселям (подставь размер своего спрайта).

const CHICK_W = 48;

function update() {
  chicken.x = chicken.x + chicken.vx;

  // правый край: дальше холста не пускаем
  if (chicken.x + CHICK_W > canvas.width) {
    chicken.x = canvas.width - CHICK_W;
    chicken.vx = 0;
  }

  // левый край: за ноль тоже не пускаем
  if (chicken.x < 0) {
    chicken.x = 0;
    chicken.vx = 0;
  }
}

Результат: цыплёнок едет вправо, упирается правым боком в стенку холста и замирает ровно у края, не вылезая за него ни на пиксель. Скорость обнулилась — он стоит.

Тут есть тонкость, на которой спотыкаются почти все. Координата x — это левый угол спрайта. Если проверять просто chicken.x > canvas.width, цыплёнок успеет наполовину вылезти за край, прежде чем остановится: ведь его правый бок находится в точке x + CHICK_W. Поэтому сравниваем именно chicken.x + CHICK_W с шириной холста.

И ещё: мы не просто обнуляем скорость, но и возвращаем x точно к краю (canvas.width - CHICK_W). Иначе за тот кадр, в котором сработала проверка, цыплёнок мог чуть-чуть зайти за границу — мы его подтягиваем обратно, чтобы не было дрожания у стенки.

Зеркалим логику по вертикали

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

function clampToScreen() {
  if (chicken.x < 0) { chicken.x = 0; chicken.vx = 0; }
  if (chicken.x + CHICK_W > canvas.width) {
    chicken.x = canvas.width - CHICK_W;
    chicken.vx = 0;
  }
  if (chicken.y < 0) { chicken.y = 0; chicken.vy = 0; }
  if (chicken.y + CHICK_H > canvas.height) {
    chicken.y = canvas.height - CHICK_H;
    chicken.vy = 0;
  }
}

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

Вынеся проверку в отдельную функцию clampToScreen, мы держим update чистым: сначала двигаем, потом одной строкой зажимаем в границы.

Пример 3. Почему скорость, а не «просто прибавь к x»

Можно спросить: зачем вообще поле vx? Почему не писать chicken.x += 2 прямо в update? Потому что скорость — это ручка громкости, которую удобно крутить из одного места.

function update() {
  // ускоряемся, пока едем вправо
  if (chicken.vx > 0 && chicken.vx < 8) {
    chicken.vx = chicken.vx + 0.2;
  }
  chicken.x = chicken.x + chicken.vx;
  clampToScreen();
}

Результат: цыплёнок трогается медленно, потом разгоняется — будто набирает ход, — и на полной скорости 8 пикселей за кадр влетает в правую стенку. Эффект разгона получился буквально парой строк, потому что скорость живёт в состоянии отдельно от позиции.

Если бы движение было зашито в x += 2, никакого разгона, отскока, замедления или прыжка ты бы не сделал, не переписав всё. А так у тебя есть отдельная «рукоятка» vx/vy, и любые красивые штуки — это просто игра с этой рукояткой. В уроке про гравитацию мы будем каждый кадр прибавлять к vy маленькое ускорение вниз — и тот же самый цыплёнок начнёт падать. Та же модель состояния, новое поведение.

Подумай, сколько эффектов открывает одна эта рукоятка. Хочешь, чтобы цыплёнок тормозил по инерции, как машина с выключенным двигателем? Каждый кадр чуть-чуть умножай vx на 0.95 — скорость будет плавно стремиться к нулю, и герой докатится сам. Хочешь резкий рывок? Разово прибавь к vx большое число. Хочешь скольжение по льду? Уменьшай трение. Всё это — разные способы трогать vx и vy, а позиция x/y просто послушно едет следом. Именно поэтому опытные разработчики почти никогда не двигают объект «напрямую»: они меняют его скорость, а позиция обновляется одной и той же строкой x += vx. Это разделение «что мы хотим» (скорость) и «где мы в итоге» (позиция) — маленькая, но очень взрослая идея.

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

1. Забыл clearRect — цыплёнок мажет

Если в draw не стирать предыдущий кадр, каждая новая позиция рисуется поверх старой. Получается не движущийся цыплёнок, а жирная полоса из сотен его копий. Правило: первая строка draw почти всегда ctx.clearRect(0, 0, canvas.width, canvas.height).

2. Двигаю и рисую в одной куче

Когда drawImage и chicken.x += vx перемешаны в одной функции, очень быстро теряешь контроль: непонятно, в каком порядке что считается, и появляются баги вроде «иногда сдвигается на два шага за кадр». Держи update (только числа) и draw (только холст) раздельно — это спасёт тебе кучу нервов.

3. Меняю позицию прямо в условии границы, но забываю про второй край

Поставил проверку на правый край, обрадовался — а цыплёнок при движении влево уезжает за левый. Границ всегда минимум две (а с вертикалью — четыре). Проверяй обе стороны, иначе герой сбежит туда, где его не проверяют.

4. Сравниваю x вместо x + ширины

Самая коварная: if (chicken.x > canvas.width) вместо if (chicken.x + CHICK_W > canvas.width). Цыплёнок тычется в стенку, наполовину вылезая за неё, потому что x — это его левый угол, а упирается-то он правым боком. Всегда думай, какой край спрайта касается границы.

5. Создаю новый объект chicken внутри loop

Если объявить const chicken = {...} внутри функции loop или update, он будет заново создаваться каждый кадр с начальными координатами — и цыплёнок намертво застынет в стартовой точке. Состояние должно жить снаружи цикла, чтобы переживать кадры. Объявляй chicken один раз на верхнем уровне.

Мини-практика: маятник у стены

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

Подсказка: вместо того чтобы обнулять vx при ударе о границу, поменяй у него знак. Скелет внутри update:

chicken.x = chicken.x + chicken.vx;

if (chicken.x + CHICK_W > canvas.width) {
  chicken.x = canvas.width - CHICK_W;
  chicken.vx = /* ??? сделай скорость противоположной */;
}
if (chicken.x < 0) {
  chicken.x = 0;
  chicken.vx = /* ??? и здесь тоже */;
}

Результат, к которому стремимся: цыплёнок доезжает до правой стенки, разворачивается и едет к левой, упирается, снова разворачивается — и так бесконечно, как заставка DVD-плеера, которую все ждали поймать в углу.

Если осилил это — попробуй усложнить: пусть он отскакивает и по вертикали тоже (добавь vy и проверки по y). Тогда цыплёнок начнёт носиться по всему экрану и биться об все четыре стенки. Это уже почти физика мячика из настоящей игры.

Итоги

Сегодня ты сделал самый важный шаг в любой игре — научил картинку двигаться честно:

  • завёл объект состояния chicken с полями x, y и вектором скорости vx, vy;
  • в функции update меняешь координаты каждый кадр, прибавляя скорость к позиции;
  • держишь update (считаем) и draw (рисуем) раздельно — кухня отдельно, стол отдельно;
  • зажимаешь цыплёнка в границы холста через clampToScreen, сравнивая правильный край спрайта с границей.

Сейчас цыплёнок едет сам по себе. В следующем уроке мы дадим тебе руль: подключим клавиатуру, чтобы стрелки меняли vx и vy — и цыплёнок поедет туда, куда жмёшь ты. Вся модель состояния, которую ты собрал сегодня, останется на месте: мы просто добавим, кто крутит рукоятку скорости. Готовь пальцы к стрелочкам.

Проверьте себя
1. Зачем хранить координаты и скорость цыплёнка в объекте state, а не пересоздавать их в каждом кадре?
AЧтобы объект помнил своё положение между кадрами и мог двигаться
BЧтобы код быстрее компилировался
CПотому что canvas не умеет рисовать без объекта
DЧтобы не подключать requestAnimationFrame
2. Что делает строка chicken.x = chicken.x + chicken.vx внутри update?
AРисует цыплёнка на новой позиции
BСдвигает координату x на величину горизонтальной скорости за этот кадр
CУдваивает скорость цыплёнка
DСтирает предыдущий кадр
3. Почему важно разделять функции update и draw?
AТак требует синтаксис JavaScript
Bdraw работает быстрее, если ничего не считает
CЧтобы логика (считаем числа) и отрисовка (показываем кадр) не путались и баги было легче ловить
DИначе requestAnimationFrame не запустится
4. Цыплёнок упирается в правую стенку, но половиной тела вылезает за неё. В чём ошибка проверки границы?
AНадо сравнивать chicken.x + ширина_спрайта с canvas.width, а не один chicken.x
BНадо увеличить vx
CЗабыли вызвать clearRect
Dcanvas.width слишком маленький
5. Что произойдёт, если объявить const chicken = {...} внутри функции loop?
AЦыплёнок будет двигаться вдвое быстрее
BОбъект пересоздаётся каждый кадр с начальными координатами, и цыплёнок застынет на старте
CИгра вообще не запустится
DСпрайт исчезнет с экрана
6. Как в мини-практике заставить цыплёнка отскакивать от стенки, а не останавливаться?
AОбнулить vx при ударе
BПоменять знак vx на противоположный при касании границы
CУвеличить canvas.width
DУбрать clearRect из draw