AABB: прямоугольные столкновения

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

AABB (Axis-Aligned Bounding Box) — проверка пересечения двух прямоугольников, выровненных по осям. Два прямоугольника пересекаются только тогда, когда они перекрываются и по горизонтали, и по вертикали одновременно.

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

Зачем это нужно

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

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

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

Что такое прямоугольник, выровненный по осям

Давай разберёмся с длинным названием AABB — Axis-Aligned Bounding Box, «ограничивающий прямоугольник, выровненный по осям». Звучит как заклинание, но идея до смешного простая.

Представь, что каждый игровой объект — цыплёнок, монетка, кирпич — мы мысленно обводим прямоугольной рамкой, как стикером на холодильнике. Рамка строго прямая: её стороны идут параллельно краям экрана, она не наклонена и не повёрнута. Вот это «не повёрнута, стороны вдоль осей X и Y» и есть выровненный по осям прямоугольник.

Цыплёнок может быть круглым, пушистым и с клювом, но для проверки столкновений мы смотрим не на картинку, а на невидимую прямоугольную рамку вокруг него — хитбокс. Игре всё равно, как объект выглядит; ей важны четыре числа рамки.

Каждый такой прямоугольник описывается всего четырьмя числами, которые ты уже знаешь по предыдущим урокам:

  • x — координата левого края рамки;
  • y — координата верхнего края (помни: в canvas ось y растёт вниз);
  • width — ширина рамки;
  • height — высота рамки.

Из этих четырёх чисел легко достать края прямоугольника. Левый край — это x, правый — x + width. Верхний — y, нижний — y + height. Запомни эти четыре границы: именно их мы будем сравнивать.

Почему «выровненный по осям» так важен? Потому что для прямых, не повёрнутых рамок проверка пересечения сводится к сравнению чисел — быстро и просто. Стоит рамку повернуть под углом, и математика разрастается в разы (это уже отдельная тема — SAT). Поэтому в большинстве 2D-игр объекты обводят именно прямыми рамками: дёшево, быстро, понятно. AABB — рабочая лошадка, на которой ездит полиндустрии.

Главная идея: пересечение по двум осям сразу

Вот сердце урока. Когда два прямоугольника пересекаются? Кажется, что задача сложная — рамки могут наезжать друг на друга уголком, краем, целиком. Но есть гениально простой способ думать об этом.

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

  • Спроецируй обе рамки на горизонтальную линию (ось X). Получатся два отрезка. Перекрываются ли они? Это перекрытие по горизонтали.
  • Теперь спроецируй на вертикальную линию (ось Y). Снова два отрезка. Перекрываются ли они? Это перекрытие по вертикали.

Два прямоугольника пересекаются тогда и только тогда, когда они перекрываются и по горизонтали, и по вертикали одновременно. Хватит провала хотя бы по одной оси — и пересечения нет.

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

Четыре условия AABB

Удобнее проверять не «когда пересекаются», а наоборот — «когда точно НЕ пересекаются», и потом перевернуть ответ. Прямоугольники не пересекаются, если выполнено хотя бы одно из четырёх «разъехались»:

  1. A полностью левее B: правый край A левее левого края B → a.x + a.width < b.x.
  2. A полностью правее B: левый край A правее правого края B → a.x > b.x + b.width.
  3. A полностью выше B: нижний край A выше верхнего края B → a.y + a.height < b.y.
  4. A полностью ниже B: верхний край A ниже нижнего края B → a.y > b.y + b.height.

Если ни одно из этих «разъехались» не выполнено — рамки перекрываются по обеим осям, значит, они пересекаются. На практике мы записываем сразу условие пересечения, перевернув каждое сравнение. Прямоугольники пересекаются, когда все четыре условия истинны одновременно:

УсловиеЧто значит
a.x < b.x + b.widthлевый край A левее правого края B
a.x + a.width > b.xправый край A правее левого края B
a.y < b.y + b.heightверхний край A выше нижнего края B
a.y + a.height > b.yнижний край A ниже верхнего края B

Первые два условия отвечают за перекрытие по горизонтали, вторые два — по вертикали. Все четыре через && («и») — и ты получил универсальную проверку столкновений. Давай напишем её кодом.

Пример 1. Функция проверки пересечения

Оформим AABB в маленькую функцию overlap, которая принимает два прямоугольника и возвращает true, если они пересекаются.

// принимает два прямоугольника { x, y, width, height }
// возвращает true, если они пересекаются
function overlap(a, b) {
  return (
    a.x < b.x + b.width &&   // левый край A левее правого края B
    a.x + a.width > b.x &&   // правый край A правее левого края B
    a.y < b.y + b.height &&  // верхний край A выше нижнего края B
    a.y + a.height > b.y      // нижний край A ниже верхнего края B
  );
}

// проверим на двух прямоугольниках
const box1 = { x: 10, y: 10, width: 50, height: 50 };
const box2 = { x: 40, y: 40, width: 50, height: 50 };
console.log(overlap(box1, box2));   // true — уголки заехали друг на друга

const box3 = { x: 200, y: 10, width: 50, height: 50 };
console.log(overlap(box1, box3));   // false — box3 далеко справа

Результат: функция ничего не рисует, она просто считает. Для box1 и box2 в консоль выведется true — их уголки перекрываются (правый-нижний угол box1 заходит на левый-верхний угол box2). Для box1 и box3 выведется false — box3 стоит далеко справа, и горизонтальные отрезки не пересекаются, поэтому первое же условие a.x < b.x + b.width (то есть 10 < 250) истинно, но второе a.x + a.width > b.x (то есть 60 > 200) ложно — и вся цепочка через && рушится в false.

Разберём построчно, что делает каждое сравнение:

  • a.x < b.x + b.width — левый край A должен быть левее правого края B. Иначе A целиком ускакал правее B.
  • a.x + a.width > b.x — правый край A должен быть правее левого края B. Иначе A целиком левее B.
  • a.y < b.y + b.height — верхний край A выше нижнего края B. Иначе A целиком ниже B.
  • a.y + a.height > b.y — нижний край A ниже верхнего края B. Иначе A целиком выше B.

Заметь, как красиво устроены первые две строки: они зеркальны. Одна проверяет, что A не убежал слишком вправо, другая — что не убежал слишком влево. Вместе они говорят: «отрезки по оси X перекрываются». Третья и четвёртая делают то же по оси Y. Четыре строки, две оси, один ответ.

Пример 2. Цыплёнок подсвечивается при столкновении

Теперь главное ради чего всё затевалось. Возьмём цыплёнка из прошлых уроков (тот же объект chicken с координатами и управлением) и положим на холст препятствие — кирпич. Каждый кадр будем проверять overlap(chicken, brick) и, если есть касание, рисовать вокруг цыплёнка яркий контур.

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

// сквозной герой курса — наш цыплёнок
const chicken = { x: 60, y: 60, width: 40, height: 40, speed: 4 };

// препятствие — красный кирпич посреди холста
const brick = { x: 300, y: 180, width: 120, height: 80 };

// управление с клавиатуры (доска клавиш из прошлых уроков)
const keys = {};
window.addEventListener('keydown', function (e) { keys[e.code] = true; });
window.addEventListener('keyup',   function (e) { keys[e.code] = false; });

function overlap(a, b) {
  return (
    a.x < b.x + b.width &&
    a.x + a.width > b.x &&
    a.y < b.y + b.height &&
    a.y + a.height > b.y
  );
}

function update() {
  if (keys['ArrowLeft']  || keys['KeyA']) chicken.x -= chicken.speed;
  if (keys['ArrowRight'] || keys['KeyD']) chicken.x += chicken.speed;
  if (keys['ArrowUp']    || keys['KeyW']) chicken.y -= chicken.speed;
  if (keys['ArrowDown']  || keys['KeyS']) chicken.y += chicken.speed;
}

function draw() {
  context.clearRect(0, 0, canvas.width, canvas.height);

  // кирпич
  context.fillStyle = '#e84545';
  context.fillRect(brick.x, brick.y, brick.width, brick.height);

  // цыплёнок
  context.fillStyle = '#ffdc3c';
  context.fillRect(chicken.x, chicken.y, chicken.width, chicken.height);

  // если есть столкновение — обводим цыплёнка ярким контуром
  if (overlap(chicken, brick)) {
    context.strokeStyle = '#00e0ff';
    context.lineWidth = 4;
    context.strokeRect(chicken.x - 3, chicken.y - 3,
                       chicken.width + 6, chicken.height + 6);
  }
}

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

loop();

Результат: на холсте — жёлтый цыплёнок в левом верхнем углу и красный кирпич в центре. Управляй стрелками или WASD: пока цыплёнок не касается кирпича, он просто жёлтый квадратик. Но как только он наезжает на кирпич хоть уголком, вокруг цыплёнка мгновенно вспыхивает голубой контур — игра «почувствовала» столкновение. Отъехал — контур погас. Каждый кадр overlap заново отвечает на вопрос «касаемся?», и подсветка точно следует за касанием.

Что здесь стоит понять:

  • Проверка overlap(chicken, brick) вызывается каждый кадр внутри draw(). Столкновение — это не разовое событие, а состояние, которое мы пересчитываем 60 раз в секунду.
  • И у цыплёнка, и у кирпича теперь есть поля width и height — их хитбоксы. Функция overlap не знает и не хочет знать, что один из объектов — герой, а другой — препятствие. Ей дай два прямоугольника, она вернёт ответ. Поэтому ту же функцию ты применишь к монеткам, врагам, пулям — к чему угодно.
  • strokeRect с отступом в 3 пикселя рисует контур чуть больше самого цыплёнка, чтобы подсветка была заметной рамкой, а не сливалась с краем спрайта.

Пример 3. Стенка, сквозь которую не пройти

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

function update() {
  // запоминаем, где цыплёнок был ДО движения
  const prevX = chicken.x;
  const prevY = chicken.y;

  // двигаем как обычно
  if (keys['ArrowLeft']  || keys['KeyA']) chicken.x -= chicken.speed;
  if (keys['ArrowRight'] || keys['KeyD']) chicken.x += chicken.speed;
  if (keys['ArrowUp']    || keys['KeyW']) chicken.y -= chicken.speed;
  if (keys['ArrowDown']  || keys['KeyS']) chicken.y += chicken.speed;

  // если после движения влезли в кирпич — откатываемся назад
  if (overlap(chicken, brick)) {
    chicken.x = prevX;
    chicken.y = prevY;
  }
}

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

Логика проста, как откат хода в настольной игре: перед движением мы сохранили prevX и prevY; подвигались; если новая позиция оказалась внутри кирпича — отменяем ход и возвращаем героя туда, где он был. Игрок этого «отката» даже не замечает — для него цыплёнок просто упёрся в стену. Это не самый продвинутый способ (в платформерах столкновение разруливают точнее, отдельно по осям), но для первой твёрдой стены он работает отлично и держится всё на той же функции overlap.

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

AABB кажется очевидной, но на ней спотыкаются почти все новички. Вот грабли, которые сэкономят тебе вечер отладки «почему он не сталкивается / сталкивается, когда не должен».

1. Перепутать < и > в условиях

Самая частая ошибка. Если в одном из четырёх сравнений случайно развернуть знак (написать a.x > b.x + b.width вместо a.x < b.x + b.width), функция будет возвращать чушь: либо вечный true, либо вечный false. Запомни правило-якорь: левый край одного сравниваем с правым краем другого, и наоборот. Левый край A (a.x) меньше правого края B (b.x + b.width); правый край A (a.x + a.width) больше левого края B (b.x).

2. Использовать <= вместо < (или наоборот)

Когда рамки касаются ровно краями (правый край A точно равен левому краю B), со строгим < это считается «не пересекаются», а с нестрогим <= — «пересекаются». Оба варианта допустимы, но выбери один и держись его во всех четырёх строках. Иначе получишь «дребезг» на границе: на пиксельном касании столкновение то срабатывает, то нет, и герой дёргается у самой стенки.

3. Сравнивать центры вместо краёв

Новички иногда пытаются проверять расстояние между центрами объектов. Для прямоугольников это лишнее усложнение и частый источник ошибок. AABB работает именно с краями: x, x + width, y, y + height. Не надо ничего делить пополам и искать центры — бери края напрямую.

4. Хитбокс не совпадает с тем, что нарисовано

Если в overlap ты передаёшь width и height, отличные от размеров, которыми рисуешь спрайт, столкновения будут срабатывать «в воздухе» или, наоборот, пропускать явное касание. Следи, чтобы числа хитбокса и числа отрисовки совпадали (или сознательно делай хитбокс чуть меньше спрайта — так в хороших играх делают, чтобы герою было «не обидно» получать урон от пиксельного задевания).

5. Забыть, что ось y растёт вниз

По школьной привычке кажется, что «выше» — это больший y. В canvas всё наоборот: верх экрана — это y = 0, и y растёт вниз. Поэтому «верхний край A выше нижнего края B» записывается как a.y < b.y + b.height. Если держать в голове «обычную» школьную ось, легко перевернуть вертикальные условия и удивляться, почему столкновения по вертикали врут.

Мини-проект: цыплёнок собирает монетку

Теперь твоя очередь. Возьми код из примера 2 (цыплёнок, кирпич, функция overlap) и преврати его в кусочек настоящей игры:

  1. Добавь монетку. Заведи объект coin с полями x, y, width, height и флагом collected: false. В draw() рисуй монетку (жёлтый или золотой кружок/квадратик) только если coin.collected ещё false.
  2. Собирай монетку при касании. Каждый кадр проверяй overlap(chicken, coin). Как только цыплёнок коснулся монетки — ставь coin.collected = true. Монетка пропадёт с холста.
  3. Считай очки. Заведи переменную score. В момент сбора монетки увеличивай её на 1 и выводи на холст через context.fillText('Очки: ' + score, 10, 30).

Подсказки, чтобы получилось:

  • Проверку сбора оберни в условие if (!coin.collected && overlap(chicken, coin)) — иначе очки будут расти каждый кадр, пока цыплёнок стоит на монетке.
  • Чтобы поиграть подольше, после сбора не удаляй монетку насовсем, а перенеси её в случайную точку: coin.x = Math.random() * (canvas.width - coin.width) и аналогично по y, а collected снова в false. Получится бесконечный сбор монеток — это уже почти аркада!
  • Та же функция overlap работает и с кирпичом, и с монеткой, и с чем угодно ещё. Добавь второй кирпич-препятствие — и убедись, что менять в самой функции ничего не нужно.

Если цыплёнок собирает монетку, счётчик растёт, а кирпич остаётся твёрдым — поздравляю, ты собрал кор-механику доброй половины 2D-игр. Функцию overlap ты будешь переиспользовать в каждом следующем уроке без единой правки.

Итоги

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

  • Прямоугольник, выровненный по осям — невидимая прямая рамка-хитбокс вокруг объекта, описанная четырьмя числами: x, y, width, height.
  • Главная идея AABB — два прямоугольника пересекаются только тогда, когда перекрываются и по горизонтали, и по вертикали одновременно. Провал хотя бы по одной оси — пересечения нет.
  • Четыре условия — два сравнения по оси X и два по оси Y, соединённые через &&. Это вся проверка целиком.
  • Функция overlap(a, b) — универсальная, не зависит от того, что за объекты ей дали. Цыплёнок, кирпич, монетка, враг — для неё все они просто прямоугольники.
  • Реакция на столкновение — подсветка контуром, откат к прошлой позиции, сбор монетки: всё держится на одном и том же ответе overlap.

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

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

Проверьте себя
1. Что означает «выровненный по осям» в названии AABB?
AПрямоугольник всегда квадратный
BСтороны рамки параллельны осям X и Y, рамка не повёрнута
CОбъект стоит ровно в центре холста
DКоординаты объекта всегда положительные
2. Когда два прямоугольника AABB пересекаются?
AКогда перекрываются хотя бы по одной оси
BКогда их центры совпадают
CКогда перекрываются и по горизонтали, и по вертикали одновременно
DКогда у них одинаковые width и height
3. Какое сравнение проверяет, что правый край A правее левого края B?
Aa.x < b.x + b.width
Ba.x + a.width > b.x
Ca.y < b.y + b.height
Da.y + a.height > b.y
4. Сколько условий через && нужно, чтобы определить пересечение двух прямоугольников?
AДва: по одному на ось
BЧетыре: два по оси X и два по оси Y
CОдно общее условие
DВосемь: по четыре на каждый прямоугольник
5. Почему в примере функцию overlap вызывают каждый кадр, а не один раз?
AИначе JavaScript выдаст ошибку
BСтолкновение — это состояние, которое меняется при движении, и его пересчитывают каждый кадр
CФункцию вообще можно вызвать только в loop()
DТак быстрее работает браузер
6. Цыплёнок в canvas должен оказаться выше кирпича. Какое условие отражает «верхний край A выше нижнего края B» с учётом оси y?
Aa.y > b.y + b.height
Ba.y < b.y + b.height
Ca.y + a.height < b.y
Da.x < b.x + b.width