Реакция на столкновение

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

Зачем это нужно: цыплёнок-привидение

Помнишь, на прошлом уроке мы сделали проверку пересечения двух прямоугольников (AABB)? Мы научились отвечать на вопрос «да или нет, эти два объекта сейчас касаются друг друга?». И это было круто — мы могли подсветить стену красным, когда цыплёнок её задевает.

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

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

К концу урока твой цыплёнок будет упираться в стены носом и ни на пиксель в них не залезать. А ещё научится отскакивать от стен, как мячик в Понге, собирать монетки и получать по шапке от шипов. Поехали.

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

Главная идея: насколько глубоко мы провалились

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

Вот это «ровно настолько» — ключ ко всему уроку. Когда цыплёнок врезается в стену, он за один кадр успевает чуть-чуть в неё залезть. Наша задача — понять, на сколько пикселей он залез, и вытолкнуть его обратно ровно на эту глубину. Эту величину называют глубиной пересечения (по-английски часто пишут overlap или penetration depth).

Считаем глубину по каждой оси

Два прямоугольника, которые уже пересеклись, перекрываются и по горизонтали, и по вертикали одновременно. Значит, и глубину надо считать отдельно по X и отдельно по Y. Формула простая: берём правый край левого объекта минус левый край правого — и наоборот, выбираем меньшее.

// Глубина пересечения по каждой оси.
// a и b — объекты с полями x, y, w, h (левый верхний угол + размеры).
function getOverlap(a, b) {
  // Сколько перекрытие по горизонтали
  const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
  // Сколько перекрытие по вертикали
  const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
  return { x: overlapX, y: overlapY };
}

Результат: функция возвращает два числа — насколько глубоко прямоугольники въехали друг в друга по горизонтали и по вертикали. Если оба числа положительные — объекты пересекаются. Например, цыплёнок 32×32 заехал левым краем в стену на 6 пикселей по X и на 30 по Y — функция вернёт { x: 6, y: 30 }.

Зачем нам сразу обе оси? Потому что выталкивать цыплёнка надо по той оси, где он залез меньше. Логика тут житейская: если ты въехал в дверной проём боком на 2 см, тебя проще оттолкнуть вбок на эти 2 см, чем выпихивать назад на полметра. Меньшее перекрытие — это «короткий путь наружу».

Давай прикинем на пальцах. Цыплёнок размером 32×32 едет вправо и за кадр въехал левым краем в высокую стену. По горизонтали он залез всего на 6 пикселей, а по вертикали стена и цыплёнок перекрываются на все 30 — ведь стена высокая, и они «совпадают» почти на всю высоту цыплёнка. Если бы мы вытолкнули его по Y (где 30), он бы внезапно выпрыгнул вверх или вниз на 30 пикселей — это выглядело бы как телепорт-глюк. А вот толчок на 6 пикселей влево по X — ровно то, чего ждёт глаз: цыплёнок просто упёрся и остановился. Поэтому мы всегда сравниваем два числа и берём ось с меньшим.

Пример 1: цыплёнок упирается в стену

Соберём первую честную реакцию. Цыплёнок двигается, мы проверяем столкновение со стеной, считаем глубину и выталкиваем по той оси, где перекрытие меньше. Имена переменных оставляем сквозные — chicken и chickenSprite, как во всех уроках курса.

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

const chicken = { x: 40, y: 200, w: 32, h: 32, vx: 0, vy: 0, speed: 3 };
const wall = { x: 300, y: 120, w: 40, h: 240 };

const keys = {};
window.addEventListener('keydown', (e) => { keys[e.key] = true; });
window.addEventListener('keyup',   (e) => { keys[e.key] = false; });

function getOverlap(a, b) {
  const x = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
  const y = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
  return { x, y };
}

function resolveWall(obj, solid) {
  const o = getOverlap(obj, solid);
  if (o.x <= 0 || o.y <= 0) return; // не пересекаются — выходим

  // Выталкиваем по оси с меньшим перекрытием
  if (o.x < o.y) {
    // центр цыплёнка левее центра стены — выталкиваем влево
    const chickenCenter = obj.x + obj.w / 2;
    const solidCenter   = solid.x + solid.w / 2;
    obj.x += (chickenCenter < solidCenter) ? -o.x : o.x;
  } else {
    const chickenCenter = obj.y + obj.h / 2;
    const solidCenter   = solid.y + solid.h / 2;
    obj.y += (chickenCenter < solidCenter) ? -o.y : o.y;
  }
}

function update() {
  chicken.vx = (keys['ArrowRight'] ? 1 : 0) - (keys['ArrowLeft'] ? 1 : 0);
  chicken.vy = (keys['ArrowDown']  ? 1 : 0) - (keys['ArrowUp']   ? 1 : 0);
  chicken.x += chicken.vx * chicken.speed;
  chicken.y += chicken.vy * chicken.speed;
  resolveWall(chicken, wall); // въехал — вытолкнули обратно
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#888';
  ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
  ctx.drawImage(chickenSprite, chicken.x, chicken.y, chicken.w, chicken.h);
}

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

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

Что тут происходит по шагам

  1. Сначала двигаем, потом исправляем. Мы спокойно прибавляем скорость к координатам — пусть цыплёнок даже залезет в стену. На этом же кадре resolveWall заметит проблему и откатит.
  2. Считаем глубину через getOverlap. Если хоть одно из чисел не больше нуля — пересечения нет, делать нечего.
  3. Выбираем ось. Сравниваем o.x и o.y: выталкиваем по той, где перекрытие меньше — это короткий путь наружу.
  4. Выбираем направление. Смотрим, с какой стороны стены находится центр цыплёнка. Левее — толкаем влево (минус), правее — вправо (плюс).

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

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

Пример 2: останавливаем скорость, чтобы не дёргалось

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

function resolveWall(obj, solid) {
  const o = getOverlap(obj, solid);
  if (o.x <= 0 || o.y <= 0) return;

  if (o.x < o.y) {
    const left = obj.x + obj.w / 2 < solid.x + solid.w / 2;
    obj.x += left ? -o.x : o.x;
    obj.vx = 0; // упёрлись боком — горизонтальная скорость гаснет
  } else {
    const above = obj.y + obj.h / 2 < solid.y + solid.h / 2;
    obj.y += above ? -o.y : o.y;
    obj.vy = 0; // упёрлись сверху/снизу — вертикальная гаснет
  }
}

Результат: цыплёнок прилипает к стене ровно и неподвижно, без дрожания. При этом он по-прежнему может скользить вдоль стены: если упёрся вправо, стрелка «вниз» всё ещё работает — мы погасили только vx, а vy цела.

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

И ещё один момент, который любят упускать. Мы гасим скорость только по той оси, по которой вытолкнули. Это не случайность, а специально. Представь героя, который бежит вправо вдоль стены и одновременно падает вниз под гравитацией. Он упёрся в стену справа — мы гасим vx (вбок он больше не лезет), но vy не трогаем, чтобы он спокойно продолжал скользить вниз вдоль стены. Если бы мы по лени обнулили обе скорости, герой бы намертво прилипал к любой стене, которую коснулся, — и стены превратились бы в липучки. А так получается естественное скольжение, как когда ведёшь ладонью по стене коридора.

Пример 3: отскок, как мячик в Понге

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

function resolveBounce(obj, solid) {
  const o = getOverlap(obj, solid);
  if (o.x <= 0 || o.y <= 0) return;

  if (o.x < o.y) {
    const left = obj.x + obj.w / 2 < solid.x + solid.w / 2;
    obj.x += left ? -o.x : o.x;
    obj.vx = -obj.vx; // отражаем горизонтальную скорость
  } else {
    const above = obj.y + obj.h / 2 < solid.y + solid.h / 2;
    obj.y += above ? -o.y : o.y;
    obj.vy = -obj.vy; // отражаем вертикальную
  }
}

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

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

Пример 4: разные реакции для разных объектов

В настоящей игре цыплёнок врезается не только в стены. Есть монетки (их надо собрать и убрать), шипы (от них больно), пружины (они подбрасывают). Все они проверяются одним и тем же AABB, но реагируют по-разному. Заведём каждому объекту поле type и разведём реакции по нему.

const things = [
  { type: 'wall',   x: 300, y: 120, w: 40, h: 240, alive: true },
  { type: 'coin',   x: 150, y: 250, w: 24, h: 24, alive: true },
  { type: 'spikes', x: 420, y: 320, w: 60, h: 16, alive: true },
];

let score = 0;
let hp = 3;

function react(obj, thing) {
  if (!thing.alive) return;
  const o = getOverlap(obj, thing);
  if (o.x <= 0 || o.y <= 0) return; // не пересеклись

  if (thing.type === 'wall') {
    resolveWall(obj, thing);        // твёрдая стена — выталкиваем
  } else if (thing.type === 'coin') {
    thing.alive = false;            // монетку собрали и убрали
    score += 1;
  } else if (thing.type === 'spikes') {
    hp -= 1;                        // шипы ранят
    obj.x = 40; obj.y = 200;        // отбрасываем цыплёнка на старт
  }
}

function update() {
  // ...двигаем chicken стрелками, как в примере 1...
  for (const thing of things) react(chicken, thing);
}

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

Это и есть учебная цель «различать реакции для разных типов объектов». Твёрдые объекты выталкивают. Собираемые исчезают. Опасные ранят. AABB для всех один — а if по type решает, что делать дальше.

Подумай, как легко теперь докинуть новый объект. Хочешь дверь, которая открывается ключом? Добавь type: 'door' и ветку, где проверяешь, есть ли ключ в инвентаре. Хочешь батут, лужу-замедлитель, телепорт между двумя точками? Каждое новое поведение — это ещё один else if, а вся машинерия обнаружения и выталкивания уже написана и работает. Именно так растут настоящие игры: один раз делаешь крепкий фундамент столкновений, а дальше просто добавляешь типы.

Маленькая хитрость для порядка: когда веток if становится много, их удобно вынести в отдельные функции — onCoin, onSpikes, onSpring — или хранить реакцию прямо в объекте. Но пока типов три-четыре, обычной лесенки if / else if более чем достаточно — не усложняй раньше времени.

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

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

1. Выталкивать сразу по обеим осям

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

2. Сначала исправлять, потом двигать

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

3. Забыть обнулить скорость и получить дрожь

Мы про это говорили в примере 2: если упёрся в стену, но vx остался прежним, цыплёнок каждый кадр заезжает и выталкивается — и дрожит. Обнуляй (или отражай) скорость по той же оси, по которой вытолкнул, а не по обеим.

4. Шаг скорости больше толщины стены

Если цыплёнок двигается на 50 пикселей за кадр, а стена толщиной 40, он может за один кадр перепрыгнуть её целиком — на следующем кадре пересечения уже нет, и стена «не сработала». Это называется туннелирование. Для школьных игр достаточно держать скорость меньше толщины самого тонкого препятствия; серьёзное лечение (continuous collision) оставим на потом.

5. Менять alive, но продолжать рисовать и проверять

Собрал монетку, поставил thing.alive = false — но забыл проверить этот флаг при отрисовке и в react. Тогда монетки не видно, а очки капают каждый кадр, пока стоишь на её месте. Всегда в начале react и draw проверяй if (!thing.alive) return; или continue;.

Мини-практика: пружина для цыплёнка

Добавь в массив things новый тип — 'spring' (пружина). Логика такая:

  • Пружина — это маленький прямоугольник внизу экрана, например { type: 'spring', x: 250, y: 360, w: 40, h: 12 }.
  • Когда цыплёнок приземляется на неё сверху (перекрытие по Y меньше, чем по X), пружина подбрасывает его вверх: задаёт большую отрицательную vy, например chicken.vy = -12.
  • Если цыплёнок задевает пружину сбоку — она ведёт себя как обычная твёрдая стена (resolveWall).

Подсказка: внутри ветки thing.type === 'spring' сначала посчитай getOverlap, и если o.y < o.x и цыплёнок выше центра пружины — подбрасывай. Иначе вызывай обычное выталкивание. Это объединит сразу два умения урока: распознать ось удара и выбрать реакцию по типу объекта.

Когда заработает — поэкспериментируй с силой подброса. Слабая пружина (-7) даёт пологий прыжок, сильная (-16) запускает цыплёнка под потолок. Это уже почти геймдизайн!

Итоги

Сегодня твой цыплёнок перестал быть привидением и научился по-настоящему взаимодействовать с миром:

  • Глубина пересечения — считаем перекрытие отдельно по X и Y и берём меньшее как «короткий путь наружу».
  • Выталкивание — после удара сдвигаем цыплёнка обратно ровно на глубину пересечения по одной оси.
  • Остановка — обнуляем скорость по оси удара, чтобы не было дрожи; основа приземления в платформере.
  • Отражение — меняем знак скорости по оси удара, и получается отскок как в Понге.
  • Разные реакции — один общий AABB, но if по type решает: вытолкнуть, собрать, ранить или подбросить.

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

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

Проверьте себя
1. Что такое глубина пересечения (overlap) двух прямоугольников?
AРасстояние между их центрами
BНа сколько пикселей они залезли друг в друга по каждой оси
CПлощадь общей области в пикселях
DВремя с момента начала столкновения
2. По какой оси нужно выталкивать цыплёнка из стены?
AПо обеим осям сразу
BВсегда по горизонтали
CПо той оси, где перекрытие меньше
DПо той оси, где перекрытие больше
3. Зачем после выталкивания из стены обнулять скорость по оси удара?
AЧтобы цыплёнок не дрожал, заезжая и выталкиваясь каждый кадр
BЧтобы ускорить отрисовку кадра
CЧтобы стена стала прозрачной
DБез этого цыплёнок вообще не остановится никогда
4. Чем реакция-отскок отличается от реакции-остановки в коде?
AОтскок вообще не считает глубину пересечения
BПри остановке скорость по оси обнуляют, а при отскоке меняют её знак
CОтскок работает только по горизонтали
DПри отскоке не нужно выталкивать объект
5. Цыплёнок движется на 50 пикселей за кадр и иногда пролетает сквозь стену толщиной 40. Как называется эта проблема?
AДельта-время
BПараллакс
CТуннелирование (объект проскакивает препятствие за один кадр)
DКоллизия по типу AABB
6. Как одной общей проверкой AABB сделать так, чтобы стена выталкивала, а монетка собиралась?
AИспользовать разные функции проверки пересечения для каждого объекта
BДать объектам поле type и в react разводить реакцию через if по типу
CПроверять монетки в одном кадре, а стены в другом
DДля монеток вообще не вызывать проверку столкновения