Сбор предметов и враги
Берём пустой уровень из плиток и оживляем его: рассыпаем монетки, запускаем патрулирующих врагов и учим цыплёнка прыгать им на голову — как в любом классическом платформере.
Коллизия — это ситуация, когда два игровых объекта пересеклись, и игра обязана как-то отреагировать: засчитать монетку, отнять жизнь или раздавить врага.
До этого момента твой уровень был красивой, но мёртвой декорацией. В уроке про тайловые уровни ты собрал карту из плиток, по которой цыплёнок бегает и прыгает. Но согласись: бегать по пустым платформам скучно. Нет цели, нет риска, нет того самого «ещё разочек» — кор-лупа, ради которого в платформер вообще интересно играть.
Сегодня мы это починим. К концу урока на экране будет вот что: цыплёнок бежит по уровню, на пути блестят золотые монетки — пробежал сквозь, монетка исчезла, счётчик щёлкнул вверх. А между платформами туда-сюда ходит враг. Налетел на него сбоку — минус жизнь, цыплёнок отлетает. Но если подгадать момент и прыгнуть врагу точно на макушку — он лопается, а ты отскакиваешь вверх, как на батуте. Вот это уже игра.
Звучит как много всего, но на самом деле всё держится на одной идее — проверке пересечения прямоугольников. Разберём её один раз, а дальше будем переиспользовать для монеток, для врагов и для прыжка сверху. Поехали.
Одна идея на всё: пересечение прямоугольников
Представь, что у каждого объекта в игре есть невидимая коробка вокруг него — у цыплёнка, у монетки, у врага. Эту коробку называют хитбоксом. Чтобы понять, столкнулись ли двое, нам не нужно сравнивать сами картинки пиксель за пикселем — это медленно и сложно. Достаточно проверить, налезают ли друг на друга их коробки.
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 отбрасывают цыплёнка прочь, так что в следующем кадре коробки уже не пересекаются. В больших играх для надёжности добавляют ещё короткую неуязвимость после удара.
Мини-практика: добавь шипы и сердечко
Базовый уровень ожил — теперь доведи его до ума сам. Вот три задания по нарастанию сложности:
- Сердечко-аптечка. Сделай ещё один массив по образцу монеток, но при сборе вместо очков прибавляй жизнь:
lives += 1. Нарисуй его, например, розовым кружком. Готовый кодupdateCoinsможно скопировать и поправить пару строк. - Статичные шипы. Добавь прямоугольник-ловушку, который не двигается, но при касании всегда отнимает жизнь — без всякой проверки «сверху». Это просто
overlap(chicken, spike)иlives -= 1с отскоком. Поставь шипы так, чтобы их приходилось перепрыгивать. - Второй враг побыстрее. Скопируй объект
enemyво второго врага с другой скоростью и другими границами патруля. Подсказка: чтобы не дублировать код, заведи массивenemiesи пробегайся по нему в цикле — ровно как мы делали с монетками. Это маленький шаг к тому, чтобы хранить всех врагов как список сущностей.
Если справишься с третьим пунктом — считай, ты уже мыслишь как настоящий геймдев: одинаковые объекты живут в массиве, а один и тот же код обновляет их всех.
Итоги
Сегодня ты превратил пустой уровень в игру. Главное, что стоит унести с собой:
- Любое столкновение в платформере — это AABB, проверка пересечения двух прямоугольников. Одна функция
overlapобслуживает и монетки, и врагов, и шипы. - Монетки и враги — это сущности: наборы данных (координаты, размер, флажки), которые мы обновляем и рисуем каждый кадр в игровом цикле.
- Чтобы отличить прыжок на голову от удара сбоку, смотрим на вектор скорости цыплёнка (падает ли он вниз) и на его положение относительно врага.
- Флажки вроде
takenиaliveи отскок после удара спасают от классической беды «событие сработало сто раз за одно касание».
У тебя на руках почти готовый платформер: герой, цель, риск и награда. Чего не хватает? Игра пока не знает, что делать, когда жизни кончились или когда собраны все монетки. Нет ни экрана старта, ни экрана победы. В следующем уроке мы научим игру переключаться между режимами — меню, игра, пауза, проигрыш — это называется игровыми состояниями (scene). Тогда твой проект из «механики на холсте» превратится в законченную игру, в которую и правда можно играть от начала до конца.