Состояние игрока и обновление
Учимся хранить всё про цыплёнка в одном объекте 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 — едем вправо, отрицательный — влево.Золотое правило: обновили — потом нарисовали
Самая важная идея урока — разделить две вещи, которые новички вечно смешивают:
- update — посчитать новые числа. Сдвинуть
x, проверить границы, поменять состояние. Здесь мы вообще ничего не рисуем. - 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 — и цыплёнок поедет туда, куда жмёшь ты. Вся модель состояния, которую ты собрал сегодня, останется на месте: мы просто добавим, кто крутит рукоятку скорости. Готовь пальцы к стрелочкам.