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

Открыть учебную сцену отдельно

Добавляем цель в сцену с котом

start совпадает с финалом урока про движение. Сохраняем фокус поля, стрелки, нормализованное направление, скорость 120 и один RAF с ограничением dt. Управление пока клавиатурное. Создай новую рабочую копию и проверь, что кот ходит по полю до изменений.

Рамка героя — прямоугольник от (player.x, player.y) размером 32 × 40. Внутри неё нарисован кот. Проверять каждый пиксель рисунка не будем: иногда стена коснётся пустого угла рамки раньше, чем видимой шерсти. Это осознанное упрощение для небольшой сцены.

Перед добавлением предметов упростим фон: уберём солнце и полосу земли, чтобы предметы и стену было хорошо видно. В drawScene оставь только цвет #e8e2ff и прямоугольник на весь холст. Это вид сверху; нижняя полоса больше не должна выглядеть как пол платформера. Стартовая папка остаётся прежней, новый фон появляется в step-01.

Когда два прямоугольника пересекаются

AABB — прямоугольные рамки, стороны которых параллельны осям. У первой рамки правая сторона — a.x + a.width, нижняя — a.y + a.height. Чтобы было пересечение, рамки должны заходить друг в друга по обеим осям. Добавь в game.js функцию, которая только сравнивает данные и возвращает true или false, перед update:

function overlaps(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;
}

Все четыре сравнения соединены «и». Строгие < и > означают: простое касание краями не считается пересечением. Это позволит поставить героя точно рядом со стеной. Поворотов у наших рамок нет: такая проверка не описывает наклонённую стену или сложный контур.

До игры проверь правило в Console. У первых двух рамок общий участок по x от 8 до 10; у следующих — только общая граница. Ожидаются true и false:

overlaps({ x: 0, y: 0, width: 10, height: 10 },
         { x: 8, y: 2, width: 10, height: 10 });
overlaps({ x: 0, y: 0, width: 10, height: 10 },
         { x: 10, y: 0, width: 10, height: 10 });

Один предмет и одна награда

После кнопок в HTML добавь <p>Собрано: <strong id="score">0</strong></p>. В JavaScript добавь счётчик, предмет и функцию подбора. Положение предмета находится справа от начальной позиции героя:

const scoreText = document.querySelector("#score");
let score = 0;
const item = { x: 160, y: 180, width: 18, height: 18, collected: false };

function collectItems() {
  if (!item.collected && overlaps(player, item)) {
    item.collected = true;
    score = score + 1;
    scoreText.textContent = score;
    statusText.textContent = "Предмет найден. Одно очко за один предмет.";
  }
}

collected хранится в данных. Проверка !item.collected не даст начислять очки каждый кадр, пока герой стоит на прежнем месте предмета. Мы сначала меняем флаг, затем счёт. Скрытая фигура сама по себе такой защиты не даёт.

В конце прежнего update, после keepInside(), вызови collectItems(). В render перед котом нарисуй ещё не взятый предмет:

if (!item.collected) {
  ctx.fillStyle = "#ffcb66";
  ctx.fillRect(item.x, item.y, item.width, item.height);
}

В handleReset перед render() верни score = 0, scoreText.textContent = score и item.collected = false. Это версия step-01. Пройди через предмет, постой на его месте, уйди и вернись. Счёт должен остаться 1. После сброса тот же предмет можно взять заново.

Добавляем стену

Добавим одну стену размером 24 × 150. Сразу храним её в небольшом массиве: знакомый for...of позволит позже добавить ещё прямоугольник без копирования проверки. Стена находится внутри поля и не перекрывает начальную позицию:

const walls = [
  { x: 240, y: 100, width: 24, height: 150 }
];

В render перед котом добавь отрисовку стен:

ctx.fillStyle = "#a4b9d9";
for (const wall of walls) {
  ctx.fillRect(wall.x, wall.y, wall.width, wall.height);
}

Подбор оставляет координату как есть. Стена требует другого ответа: пробуем смещение по x, проверяем пересечение и ставим героя вплотную к ближайшей стороне, затем отдельно делаем то же по y. Когда двигаемся вправо, левая координата героя становится wall.x - player.width. При движении влево — wall.x + wall.width. Аналогично рассчитываются верх и низ. Разделение осей позволяет скользить вдоль стены.

Почему одного пересечения в конце кадра мало

Если герой за один вызов перескочил с одной стороны тонкой стены на другую, в конечной позиции пересечения уже нет. Это называют туннелированием. deltaTime переводит скорость в расстояние, но не проверяет весь путь.

Для нашей сцены разделим расстояние на части не больше 4 пикселей по каждой оси. Например, 11 пикселей требуют трёх частей примерно по 3,67. Math.abs берёт величину без знака, Math.max выбирает большую ось, Math.ceil округляет вверх. Минимум одна часть нужен и при нулевом направлении, чтобы не делить на ноль. Внутри каждого малого шага проверяем стену и предмет:

function moveBy(dx, dy) {
  const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / 4));
  const stepX = dx / steps;
  const stepY = dy / steps;
  for (let i = 0; i < steps; i = i + 1) {
    player.x = player.x + stepX;
    keepInside();
    for (const wall of walls) {
      if (overlaps(player, wall)) {
        if (stepX > 0) player.x = wall.x - player.width;
        if (stepX < 0) player.x = wall.x + wall.width;
      }
    }
    player.y = player.y + stepY;
    keepInside();
    for (const wall of walls) {
      if (overlaps(player, wall)) {
        if (stepY > 0) player.y = wall.y - player.height;
        if (stepY < 0) player.y = wall.y + wall.height;
      }
    }
    collectItems();
  }
}

В update оставь вычисление и нормализацию направления. Последние строки изменения x/y и keepInside замени одним вызовом:

moveBy(dx * speed * dt, dy * speed * dt);

Это законченная step-02. При подходе слева кот остановится на x = 208: его правая сторона 240 совпадёт с левой стороной стены. Попробуй подойти справа, сверху, снизу и по диагонали. Затем обойди стену через верхний или нижний проход. Предмет по-прежнему подбирается только один раз.

В этом наборе все стены стоят неподвижно, герой 32 × 40, предметы не меньше 18 × 18, а начальное положение свободно. Шаг 4 заметно меньше этих размеров, поэтому пересечения на пути не пропускаются. Не увеличивай шаг ради производительности. Метод не решает движущиеся стены, телепортацию внутрь препятствия, повороты или физику толкания. При огромной скорости число частей растёт; для тысяч объектов нужен другой подход. Здесь небольшая сцена, а не универсальный физический движок.

Три предмета и завершение

Теперь заменим одиночный item массивом. Удали его объявление и прежнюю collectItems. Переменные score и scoreText не дублируй. Добавь флаг завершения, фабрику и новый подбор:

let finished = false;

function createItems() {
  return [
    { x: 160, y: 180, width: 18, height: 18, collected: false },
    { x: 330, y: 180, width: 18, height: 18, collected: false },
    { x: 400, y: 55, width: 18, height: 18, collected: false }
  ];
}

let items = createItems();

function collectItems() {
  for (const item of items) {
    if (!item.collected && overlaps(player, item)) {
      item.collected = true;
      score = score + 1;
      scoreText.textContent = score;
    }
  }
  if (score === items.length) {
    finished = true;
    stopLoop();
    statusText.textContent = "Все предметы собраны! Нажми «Сначала» для новой попытки.";
  }
}

Фабрика создаёт новые объекты для каждой попытки, как в карточной игре. Один предмет равен одному очку, поэтому равенство score === items.length здесь означает «все собраны». Если позднее награды станут разными, отдели количество найденного от суммы очков.

В render замени одиночный рисунок предмета перебором. Стены и кот остаются после этого блока:

for (const item of items) {
  if (!item.collected) {
    ctx.fillStyle = "#ffcb66";
    ctx.fillRect(item.x, item.y, item.width, item.height);
  }
}

В конце каждого малого шага moveBy, сразу после collectItems(), добавь if (finished) break;. break выходит из ближайшего цикла for: после последнего предмета оставшиеся части движения не нужны. Цикл RAF тоже не запросит новый кадр, потому что stopLoop сбросил running.

В начале handlePlay и handlePause добавь if (finished) return;. Иначе продолжение после победы снова запустит сцену, а потеря фокуса заменит сообщение о результате на «Пауза». В обработчике видимости используй условие document.hidden && !finished. Полностью замени сброс:

function handleReset() {
  stopLoop();
  player.x = 80;
  player.y = 180;
  score = 0;
  scoreText.textContent = score;
  items = createItems();
  finished = false;
  render();
  statusText.textContent = "Начальное положение. Нажми «Продолжить».";
}

Получилась final. Все три предмета доступны: стену можно обойти. Проверь победу, кнопку продолжения после неё и новую попытку. После пяти сбросов один предмет всё ещё даёт одно очко; при сбросе создаются новые объекты, поэтому предметы снова можно собрать.

Самостоятельно: предмет с другим эффектом

Добавь четвёртый предмет — ботинки. При подборе скорость меняется со 120 на 200 пикселей в секунду до конца попытки. Предмет по-прежнему даёт одно очко, а цель — собрать все четыре. Выдели его цветом и напиши правило в инструкции. При сбросе скорость снова 120. Не меняй RAF, нормализацию и защиту стен.

Выбери свободное место сам. Проверь: эффект срабатывает один раз, новая скорость ощущается, стену всё ещё нельзя перескочить, после сброса эффект исчезает. Затем добавь хотя бы одно своё правило: например, другой предмет замедляет движение до 90 или возвращает героя к старту. До кода запиши действие, изменение данных и условия повторного получения эффекта.

Подсказка 1: различать данные предметов

Добавь свойство вида kind: "boots". Внутри уже существующей ветки подбора проверь его и измени speed. Флаг collected остаётся общей защитой от повторного эффекта.

Подсказка 2: не забыть новую попытку

Создание новых предметов не изменяет отдельную переменную speed. Верни её в handleReset. Если собственное правило телепортирует героя, выбирай свободную точку: наш алгоритм движения не выталкивает объект, уже оказавшийся внутри стены.

Разбор после самостоятельной попытки

Решение содержит зелёные ботинки со свойством kind и начальную скорость в сбросе. Цель определяется длиной массива, поэтому четвертый предмет учитывается без отдельного числа. Собственное дополнительное правило в готовый пример не включено — попробуй написать его сам по тому же принципу. Файлы находятся в solutions/boots просмотрщика и ZIP. Проверить пример решения.

Проверка небольшой игры

ДействиеОжидаемый результат
Постоять на месте взятого предметаСчёт и эффект не повторяются
Подойти к каждой стороне стеныРамка героя не пересекает стену
Идти по диагонали вдоль краяМожно скользить по свободной оси
Увеличить смещение в проверкеМалые части пути всё равно проверяются
Собрать всё и нажать продолжениеРезультат сохраняется до новой попытки
Сбросить после собственного эффектаГерой, предметы, счёт, скорость и ввод возвращены к началу

Для проверки большого шага останови сцену, верни игрока в начало и вызови в Console moveBy(300, 0); render();. При y = 180 герой должен остаться слева от стены на x = 208. Это диагностический вызов, не новая схема управления. Не проверяй его поверх работающего RAF: иначе два независимых действия смешаются.

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