Сбор предметов и враги

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

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

До этого момента твой уровень был красивой, но мёртвой декорацией. В уроке про тайловые уровни ты собрал карту из плиток, по которой цыплёнок бегает и прыгает. Но согласись: бегать по пустым платформам скучно. Нет цели, нет риска, нет того самого «ещё разочек» — кор-лупа, ради которого в платформер вообще интересно играть.

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

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

Одна идея на всё: пересечение прямоугольников

Представь, что у каждого объекта в игре есть невидимая коробка вокруг него — у цыплёнка, у монетки, у врага. Эту коробку называют хитбоксом. Чтобы понять, столкнулись ли двое, нам не нужно сравнивать сами картинки пиксель за пикселем — это медленно и сложно. Достаточно проверить, налезают ли друг на друга их коробки.

AABB (axis-aligned bounding box) — проверка столкновения двух прямоугольников, выровненных по осям экрана. Это самый простой и быстрый способ узнать, пересекаются ли два объекта.

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

// Универсальная проверка пересечения двух прямоугольников.
// У каждого объекта есть x, y (левый верхний угол), w (ширина), h (высота).
function overlap(a, b) {
  return (
    a.x < b.x + b.w &&   // левый край a левее правого края b
    a.x + a.w > b.x &&   // правый край a правее левого края b
    a.y < b.y + b.h &&   // верх a выше низа b
    a.y + a.h > b.y      // низ a ниже верха b
  );
}

Результат: на экране пока ничего не меняется — это «мотор» проверки. Функция overlap возвращает true, если коробки a и b налезли друг на друга, и false, если между ними есть просвет. Дальше мы скармливаем ей цыплёнка и монетку, цыплёнка и врага — и она каждый раз честно отвечает «да» или «нет».

Запомни эту функцию: на ней будет держаться весь урок. Если хочешь освежить, почему круглые объекты проверяются иначе, загляни в урок про круговые коллизии — там мы считали расстояние между центрами. Для прямоугольных героев и плиток AABB удобнее и быстрее.

Шаг 1. Рассыпаем монетки и считаем сбор

Монетка — это очень простая сущность: координаты, размер и флажок «собрана или нет». Заведём массив монеток и нарисуем их жёлтыми кружками (потом ты заменишь их на свой спрайт). Имена переменных оставляем сквозными: наш герой по-прежнему chicken.

// Список монеток. taken — собрана ли уже эта монетка.
let coins = [
  { x: 220, y: 300, w: 16, h: 16, taken: false },
  { x: 360, y: 260, w: 16, h: 16, taken: false },
  { x: 520, y: 300, w: 16, h: 16, taken: false },
];

let score = 0; // сколько монеток собрано

function drawCoins(ctx) {
  ctx.fillStyle = 'gold';
  for (const coin of coins) {
    if (coin.taken) continue; // собранную не рисуем
    ctx.beginPath();
    ctx.arc(coin.x + 8, coin.y + 8, 8, 0, Math.PI * 2);
    ctx.fill();
  }
}

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

Засчитываем монетку

Каждый кадр в фазе «обновить состояние» (помнишь игровой цикл — «ввод, обновление, отрисовка»?) пробегаемся по монеткам и проверяем нашей функцией overlap, не коснулся ли их цыплёнок. Если коснулся и монетка ещё не собрана — ставим флажок taken и прибавляем очко.

function updateCoins() {
  for (const coin of coins) {
    if (coin.taken) continue;          // уже собрана — пропускаем
    if (overlap(chicken, coin)) {      // цыплёнок коснулся монетки
      coin.taken = true;               // помечаем собранной
      score += 1;                      // плюс очко
      // тут позже добавим звон монетки
    }
  }
}

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

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

И ещё момент про размеры. У монетки w и h равны 16, хотя рисуем мы круг радиусом 8. Это потому, что хитбокс и картинка — не одно и то же. Картинка может быть круглой, мохнатой или какой угодно, но коробка вокруг неё для проверки столкновений почти всегда прямоугольная — так быстрее считать. Подростку это знакомо по любому файтингу: персонаж нарисован детально, а бьётся по невидимому прямоугольнику.

Шаг 2. Враг, который ходит туда-сюда

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

// Враг патрулирует между left и right.
let enemy = {
  x: 380, y: 320, w: 28, h: 28,
  vx: 1.2,        // скорость по горизонтали (пикселей за кадр)
  left: 340,      // левая граница патруля
  right: 540,     // правая граница патруля
  alive: true,
};

function updateEnemy() {
  if (!enemy.alive) return;
  enemy.x += enemy.vx;                       // двигаем врага
  if (enemy.x < enemy.left) enemy.vx = Math.abs(enemy.vx);   // у левой стены — идём вправо
  if (enemy.x + enemy.w > enemy.right) enemy.vx = -Math.abs(enemy.vx); // у правой — влево
}

function drawEnemy(ctx) {
  if (!enemy.alive) return;
  ctx.fillStyle = 'crimson';
  ctx.fillRect(enemy.x, enemy.y, enemy.w, enemy.h);
}

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

Маленькая хитрость с Math.abs: вместо того чтобы просто менять знак скорости на противоположный (enemy.vx = -enemy.vx), мы у левой границы делаем скорость заведомо положительной, а у правой — заведомо отрицательной. Так враг не застрянет, дрожа на месте, если за один кадр случайно залезет за границу и не успеет выйти.

Шаг 3. Главное: сбоку — больно, сверху — победа

А вот теперь самая вкусная часть, ради которой всё затевалось. Когда цыплёнок и враг пересеклись, игра должна понять: это цыплёнок прыгнул врагу на голову (тогда враг проигрывает) или цыплёнок врезался сбоку/снизу (тогда проигрывает цыплёнок)?

Как отличить одно от другого? Смотрим на вектор скорости цыплёнка и на его положение. Если цыплёнок падает вниз (его vy больше нуля, то есть он движется к земле) и его нижний край находится в верхней части врага — значит, он приземляется сверху. Во всех остальных случаях это удар сбоку.

function checkEnemyHit() {
  if (!enemy.alive) return;
  if (!overlap(chicken, enemy)) return; // не коснулись — выходим

  const chickenBottom = chicken.y + chicken.h; // низ цыплёнка
  const enemyMiddle = enemy.y + enemy.h / 2;   // середина врага по высоте

  // Падает вниз И его низ выше середины врага = прыжок на голову
  if (chicken.vy > 0 && chickenBottom < enemyMiddle + 10) {
    enemy.alive = false;     // враг побеждён
    chicken.vy = -10;        // отскок вверх, как с батута
    score += 5;              // бонус за смелость
  } else {
    // удар сбоку или снизу — теряем жизнь
    lives -= 1;
    chicken.vx = chicken.x < enemy.x ? -8 : 8; // отбрасываем в сторону
    chicken.vy = -6;                           // и чуть подкидываем
  }
}

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

Разберём ключевое условие по косточкам, потому что в нём вся магия:

  • chicken.vy > 0 — цыплёнок именно падает. Если он подпрыгивает снизу (vy отрицательное), это уже не «сверху», а удар в подбородок врага — должно быть больно.
  • chickenBottom < enemyMiddle + 10 — низ цыплёнка ещё не провалился ниже середины врага. То есть контакт произошёл именно макушкой врага, а не его боком. Запас в +10 пикселей делает прыжок чуть прощающим — иначе попасть было бы слишком сложно.

Собираем всё в игровой цикл

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

let lives = 3;

function update() {
  updateChicken();   // движение и гравитация цыплёнка (из прошлых уроков)
  updateEnemy();     // патруль врага
  updateCoins();     // сбор монеток
  checkEnemyHit();   // драка с врагом
}

function draw(ctx) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawLevel(ctx);    // плитки уровня из прошлого урока
  drawCoins(ctx);
  drawEnemy(ctx);
  drawChicken(ctx);  // наш цыплёнок поверх всего
  ctx.fillStyle = 'black';
  ctx.fillText('Очки: ' + score + '   Жизни: ' + lives, 12, 20);
}

function loop() {
  update();
  draw(ctx);
  requestAnimationFrame(loop); // следующий кадр
}
loop();

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

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

На этих граблях спотыкаются почти все, кто делает платформер впервые. Проверь себя.

1. Монетка собирается сто раз подряд

Если забыть про флажок taken и проверять монетку каждый кадр без отметки «уже собрана», то пока цыплёнок касается монетки (а это несколько кадров подряд, ведь за один кадр он не успевает её пролететь), счётчик накрутит +1 за каждый кадр. Было три монетки — стало +60 очков. Лечится ровно одной строчкой: if (coin.taken) continue; в начале проверки.

2. Прыжок на голову не срабатывает

Самая обидная ошибка: ты прыгаешь на врага, а вместо победы теряешь жизнь. Чаще всего причина — забытое условие chicken.vy > 0. Без него игра не понимает, падаешь ты или просто стоишь рядом, и считает любой контакт ударом сбоку. Второй виновник — слишком жёсткая граница без запаса +10: попасть пиксель в пиксель почти невозможно.

3. Враг дрожит на месте у границы

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

4. Жизни уходят в минус, а игра продолжается

Мы отнимаем жизнь, но нигде не проверяем, не закончились ли они. Цыплёнок будет уходить в -1, -2 и спокойно бегать дальше. Пока это не критично, но помни: проверку if (lives <= 0) { ... } нужно будет добавить, когда дойдём до экрана проигрыша (это уже игровое состояние — о них в следующих уроках про сцены).

5. Враг бьёт цыплёнка по сто раз за касание

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

Мини-практика: добавь шипы и сердечко

Базовый уровень ожил — теперь доведи его до ума сам. Вот три задания по нарастанию сложности:

  1. Сердечко-аптечка. Сделай ещё один массив по образцу монеток, но при сборе вместо очков прибавляй жизнь: lives += 1. Нарисуй его, например, розовым кружком. Готовый код updateCoins можно скопировать и поправить пару строк.
  2. Статичные шипы. Добавь прямоугольник-ловушку, который не двигается, но при касании всегда отнимает жизнь — без всякой проверки «сверху». Это просто overlap(chicken, spike) и lives -= 1 с отскоком. Поставь шипы так, чтобы их приходилось перепрыгивать.
  3. Второй враг побыстрее. Скопируй объект enemy во второго врага с другой скоростью и другими границами патруля. Подсказка: чтобы не дублировать код, заведи массив enemies и пробегайся по нему в цикле — ровно как мы делали с монетками. Это маленький шаг к тому, чтобы хранить всех врагов как список сущностей.

Если справишься с третьим пунктом — считай, ты уже мыслишь как настоящий геймдев: одинаковые объекты живут в массиве, а один и тот же код обновляет их всех.

Итоги

Сегодня ты превратил пустой уровень в игру. Главное, что стоит унести с собой:

  • Любое столкновение в платформере — это AABB, проверка пересечения двух прямоугольников. Одна функция overlap обслуживает и монетки, и врагов, и шипы.
  • Монетки и враги — это сущности: наборы данных (координаты, размер, флажки), которые мы обновляем и рисуем каждый кадр в игровом цикле.
  • Чтобы отличить прыжок на голову от удара сбоку, смотрим на вектор скорости цыплёнка (падает ли он вниз) и на его положение относительно врага.
  • Флажки вроде taken и alive и отскок после удара спасают от классической беды «событие сработало сто раз за одно касание».

У тебя на руках почти готовый платформер: герой, цель, риск и награда. Чего не хватает? Игра пока не знает, что делать, когда жизни кончились или когда собраны все монетки. Нет ни экрана старта, ни экрана победы. В следующем уроке мы научим игру переключаться между режимами — меню, игра, пауза, проигрыш — это называется игровыми состояниями (scene). Тогда твой проект из «механики на холсте» превратится в законченную игру, в которую и правда можно играть от начала до конца.

Проверьте себя
1. Зачем монетке нужен флажок taken (собрана/не собрана)?
AЧтобы монетка красиво анимировалась при сборе
BЧтобы очко не начислялось каждый кадр, пока цыплёнок касается монетки
CЧтобы монетка двигалась туда-сюда, как враг
DЧтобы браузер не тормозил при отрисовке
2. Что именно проверяет функция overlap(a, b) в этом уроке?
AРасстояние между центрами двух кругов
BСовпадают ли цвета двух спрайтов
CПересекаются ли два прямоугольника, выровненных по осям (AABB)
DСколько у объекта осталось жизней
3. Как игра отличает прыжок цыплёнка на голову врага от удара сбоку?
AПо цвету врага в момент касания
BПо тому, что цыплёнок падает вниз (vy > 0) и его низ выше середины врага
CПо количеству собранных монеток
DПо тому, нажата ли клавиша прыжка
4. Почему врага разворачивают через Math.abs, а не просто меняя знак скорости?
AMath.abs работает быстрее обычного минуса
BЧтобы враг не застрял, дрожа на границе, если за кадр чуть зайдёт за неё
CИначе враг будет двигаться по диагонали
DЭто требование функции requestAnimationFrame
5. Что происходит сразу после того, как цыплёнок получил урон сбоку от врага?
AИгра сразу заканчивается
BВраг исчезает, а цыплёнок получает очки
CЦыплёнка отбрасывает в сторону и чуть подкидывает, чтобы он не получал урон каждый кадр
DВсе монетки на уровне пропадают
6. В какой фазе игрового цикла вызываются updateCoins, updateEnemy и checkEnemyHit?
AВ фазе «нарисовать кадр»
BВ фазе «обновить состояние», до отрисовки
CТолько один раз при запуске игры
DВ обработчике нажатия клавиш