Простой ИИ-противник

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

Зачем нам противник, который умеет проигрывать

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

Сегодня мы посадим за правую ракетку компьютер. И тут многие новички пугаются слова «ИИ» — кажется, что сейчас придётся писать что-то космически сложное. Расслабься: наш бот будет думать ровно одну мысль за кадр. Звучит она так: «Мяч сейчас выше или ниже меня? Если выше — поеду вверх, если ниже — вниз». Всё. Это и есть искусственный интеллект уровня Понга 1972 года — и он работает.

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

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

Метафора: бот как кошка, следящая за лазерной указкой

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

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

Вся «логика мышления» бота — это сравнение «центр ракетки больше или меньше, чем ball.y». Если центр ракетки ниже мяча — надо подняться (уменьшить y, ведь на canvas ось y растёт вниз). Если выше — опуститься. Запомни эту картинку с кошкой: к ней мы будем возвращаться весь урок, когда станем добавлять боту слабости.

Что у нас уже есть: декорации к сцене

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

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

// мяч: центр и вектор скорости
const ball = { x: 240, y: 180, r: 8, vx: 4, vy: 3 };

// левая ракетка — игрок (рулит с клавиатуры)
const player = { x: 20, y: 150, w: 12, h: 70 };

// правая ракетка — бот с цыплёнком
const enemy = { x: 448, y: 150, w: 12, h: 70, speed: 5 };

// спрайт цыплёнка — наш сквозной герой
const chickenSprite = new Image();
chickenSprite.src = '/sprites/chicken.png';

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

Обрати внимание на поле speed: 5 у enemy. Это максимальная скорость бота — сколько пикселей за кадр он может проехать. Именно эту цифру мы будем крутить, чтобы делать противника легче или тяжелее. У player такого поля нет: игрока двигаешь ты сам, а боту скорость надо задавать в коде.

Пример 1: бот, который тупо догоняет мяч

Начнём с самой наивной версии — той самой кошки. Каждый кадр в функции update() мы будем смотреть, где центр ракетки относительно мяча, и подвигать ракетку в нужную сторону. Центр ракетки по высоте — это её y плюс половина высоты: enemy.y + enemy.h / 2.

function updateEnemy() {
  const enemyCenter = enemy.y + enemy.h / 2;

  if (enemyCenter < ball.y) {
    // центр ракетки выше мяча — едем вниз
    enemy.y += enemy.speed;
  } else {
    // центр ракетки ниже мяча — едем вверх
    enemy.y -= enemy.speed;
  }
}

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

Разберём по шагам, что здесь происходит:

  • enemyCenter = enemy.y + enemy.h / 2 — считаем, где сейчас середина ракетки по вертикали. Сравнивать удобнее именно центр, а не верхний край, иначе ракетка будет ловить мяч краешком.
  • if (enemyCenter < ball.y) — центр ракетки меньше (то есть выше на экране, ведь ось y растёт вниз), чем мяч. Значит, мяч ниже — едем вниз, прибавляя к y.
  • else — иначе мяч выше или на той же высоте, едем вверх, отнимая от y.
  • Эту функцию мы зовём из общего update() каждый кадр, рядом с обновлением мяча.

Уже работает! Но две проблемы налицо: бот слишком меткий (его не обыграть) и нервно дёргается. Обе чиним дальше — и обе чинятся буквально парой строк.

Пример 2: даём боту слабость — ограниченную скорость

На самом деле скорость мы уже ограничили: бот двигается не сразу к мячу, а по enemy.speed пикселей за кадр. Вся хитрость честного противника прячется в одном числе. Сравни, что меняется:

// почти непобедимый бот — успевает за быстрым мячом
const enemy = { x: 448, y: 150, w: 12, h: 70, speed: 8 };

// честный бот — иногда не успевает, его реально обыграть
const enemy = { x: 448, y: 150, w: 12, h: 70, speed: 4 };

// бот-новичок — еле ползёт, легко закинуть мимо
const enemy = { x: 448, y: 150, w: 12, h: 70, speed: 2 };

Результат: при speed: 8 ракетка приклеена к мячу и почти не пропускает — играть скучно. При speed: 4 бот резво гоняется, но на резких отскоках не дотягивает, и ты успеваешь забить гол. При speed: 2 цыплёнок откровенно тормозит, и обыграть его проще простого.

Тут важно поймать главную идею урока: сложность противника — это не «умность» алгоритма, а одно число. Алгоритм всё тот же наивный «едь к мячу», мы лишь регулируем, насколько быстро бот может это делать. Сравни со своей ракеткой: ты двигаешь её рукой и тоже не телепортируешься мгновенно — у тебя есть своя «скорость реакции». Когда enemy.speed примерно равна тому, как быстро двигается мяч и как ловко рулишь ты, матч становится напряжённым и честным.

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

Пример 3: убираем дёрганье — мёртвая зона

Теперь починим нервное дрожание. Откуда оно берётся? Наш if/else устроен так, что у ракетки нет состояния «стою на месте»: она обязана каждый кадр ехать либо вверх, либо вниз. Когда мяч точно напротив центра, ракетка проскакивает на пару пикселей, на следующем кадре видит, что промахнулась, едет назад, снова проскакивает — и так туда-сюда. Это и есть дрожь.

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

В коде это значит: если расстояние от центра ракетки до мяча меньше какого-то порога, просто ничего не делаем. Введём порог deadZone:

function updateEnemy() {
  const enemyCenter = enemy.y + enemy.h / 2;
  const deadZone = 10;            // порог «и так нормально»
  const diff = ball.y - enemyCenter; // насколько мяч ниже центра

  if (Math.abs(diff) < deadZone) {
    return; // мяч почти напротив центра — стоим, не дёргаемся
  }

  if (diff > 0) {
    enemy.y += enemy.speed; // мяч ниже — едем вниз
  } else {
    enemy.y -= enemy.speed; // мяч выше — едем вверх
  }
}

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

Разберём новые строки:

  • diff = ball.y - enemyCenter — на сколько мяч ниже центра ракетки. Положительное число — мяч ниже, отрицательное — выше. Это удобнее, чем сравнивать два числа напрямую: знак сразу подсказывает направление.
  • Math.abs(diff) — модуль разницы, то есть расстояние без учёта знака. Нам ведь всё равно, мяч выше или ниже, — важно, далеко ли он.
  • if (Math.abs(diff) < deadZone) return; — если мяч ближе 10 пикселей к центру, выходим из функции, ракетку не трогаем. Вот она, мёртвая зона.
  • Дальше по знаку diff решаем, куда ехать. diff > 0 — мяч ниже, едем вниз.

Размер мёртвой зоны — тоже вкусовая настройка. Слишком маленькая (2–3 пикселя) почти не убирает дрожь; слишком большая (40–50) делает бота «ленивым» — он перестаёт ловить мяч, проходящий рядом с краем ракетки. Значение около 10 пикселей — хороший старт.

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

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

const enemy = { x: 448, y: 150, w: 12, h: 70, speed: 4 };

function updateEnemy() {
  const enemyCenter = enemy.y + enemy.h / 2;
  const deadZone = 10;
  const diff = ball.y - enemyCenter;

  if (Math.abs(diff) >= deadZone) {
    if (diff > 0) {
      enemy.y += enemy.speed;
    } else {
      enemy.y -= enemy.speed;
    }
  }

  // не даём ракетке уехать за верх и низ поля
  if (enemy.y < 0) enemy.y = 0;
  if (enemy.y + enemy.h > canvas.height) {
    enemy.y = canvas.height - enemy.h;
  }
}

function drawEnemy() {
  // сама ракетка-планка
  context.fillStyle = '#ffcc00';
  context.fillRect(enemy.x, enemy.y, enemy.w, enemy.h);

  // цыплёнок верхом на ракетке
  context.drawImage(chickenSprite, enemy.x - 18, enemy.y + 18, 48, 48);
}

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

Что добавилось по сравнению с прошлым примером:

  • Объединили if мёртвой зоны и if направления: «двигаемся, только если расстояние не меньше порога».
  • Две проверки границ: если enemy.y стал меньше нуля — прижимаем к верху; если нижний край enemy.y + enemy.h вылез за canvas.height — прижимаем к низу. Это тот же приём ограничения, что и для ракетки игрока в прошлом уроке.
  • drawEnemy() рисует жёлтую планку, а поверх — спрайт chickenSprite со смещением, чтобы цыплёнок сидел по центру ракетки. Имена chickenSprite и context те же, что и во всех уроках курса.

Обе функции, updateEnemy() и drawEnemy(), вызываются из общего игрового цикла: первая — в фазе «обновить состояние», вторая — в фазе «нарисовать кадр». Бот готов.

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

Вот на чём обычно спотыкаются, когда впервые делают бота. Пробеги глазами — почти каждый пункт стоил кому-то получаса нервов.

  • Сравнивают enemy.y вместо центра ракетки. Если сравнивать с мячом верхний край ракетки (enemy.y), а не её середину (enemy.y + enemy.h / 2), бот будет ловить мяч самым краешком и постоянно мазать. Всегда веди к мячу центр.
  • Путают направление из-за оси y. На canvas ось y растёт вниз. «Мяч выше» означает, что у мяча y меньше, и ехать к нему надо уменьшая enemy.y. Если бот едет в противоположную сторону от мяча — ты перепутал плюс и минус.
  • Забывают про мёртвую зону и удивляются дрожанию. Без порога ракетка физически не может стоять на месте и вечно дёргается на пару пикселей. Это не баг отрисовки — это логика «всегда двигайся». Лечится мёртвой зоной.
  • Делают бота непобедимым. Если задать слишком большую speed или вообще присвоить enemy.y = ball.y напрямую (телепорт к мячу), бот станет идеальным и игру нельзя будет выиграть. Скорость должна быть ограниченной — в этом весь смысл.
  • Не ограничивают ракетку полем. Без проверок границ бот, гонясь за вылетающим мячом, уезжает за верх или низ холста и пропадает из виду. Не забудь прижимать enemy.y к краям.
  • Двигают ракетку в draw(), а не в update(). Логику движения бота держи в фазе обновления состояния, а рисование — отдельно. Если смешать, при изменении FPS бот будет вести себя непредсказуемо.

Мини-практика: сделай бота живее

Базовый соперник готов — теперь твоя очередь его прокачать. Возьми код из четвёртого примера и попробуй:

  1. Подбери честную скорость. Меняй enemy.speed от 2 до 8 и поиграй на каждом значении пару минут. Найди число, при котором матч получается напряжённым: бот хорош, но обыграть его реально.
  2. Сделай бота тупее, когда мяч далеко. Добавь правило: пока мяч летит к ракетке игрока (ball.vx < 0, мяч движется влево), бот ленится и едет к центру поля, а не за мячом. Так он реагирует только когда мяч летит к нему — как настоящий игрок, который не дёргается зря.
  3. Добавь «уровни сложности». Заведи переменную level и в зависимости от неё ставь enemy.speed и deadZone: на лёгком — медленный бот с широкой мёртвой зоной, на сложном — быстрый с узкой.
  4. Промахивающийся бот (для смелых). Сделай так, чтобы бот целился не точно в мяч, а чуть мимо: добавляй к ориентиру небольшое случайное смещение. Иногда он будет ошибаться, как живой соперник.

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

Итоги и что дальше

Сегодня ты сделал компьютерного соперника и понял, наверное, самую полезную мысль про игровой ИИ:

  • Простое правило бьёт сложный алгоритм. Весь «интеллект» бота — это «двигайся туда, где мяч по высоте», то есть сравнение двух чисел.
  • Сложность — это число, а не ум. Меняя enemy.speed, ты делаешь противника легче или тяжелее, не трогая логику. Ограниченная скорость даёт игроку честный шанс выиграть.
  • Мёртвая зона убирает дрожание. Разреши боту стоять, когда мяч почти напротив центра, — и движение станет спокойным, как у человека.
  • Не забывай про ось y и границы поля — два места, где чаще всего прячутся баги.

Теперь у тебя полноценный Понг: мяч, две ракетки, счёт и живой соперник с цыплёнком, которого можно обыграть. Это уже законченная игра! В следующем уроке мы наведём на неё лоск — добавим звук удара по мячу, экраны старта и победы, и приведём весь код Понга в порядок, чтобы было не стыдно показать друзьям. А дальше нас ждёт целый новый раздел — Змейка, где тот же цыплёнок поведёт за собой растущий хвост. Увидимся там.

Проверьте себя
1. В чём состоит вся «логика мышления» нашего бота-ракетки за один кадр?
AОн предсказывает, куда отскочит мяч через несколько кадров
BОн сравнивает свою высоту с высотой мяча и двигается в его сторону
CОн копирует движения ракетки игрока
DОн случайным образом выбирает, ехать вверх или вниз
2. Зачем мы ограничиваем скорость бота полем enemy.speed?
AЧтобы код работал быстрее
BЧтобы ракетка не вылетала за край поля
CЧтобы у бота было время на размышления
DЧтобы бот иногда не успевал за мячом и его можно было обыграть
3. Почему без мёртвой зоны ракетка-бот мелко дёргается вверх-вниз?
AКонструкция if/else не даёт ей стоять на месте — она каждый кадр обязана ехать туда или сюда
BБраузер перерисовывает экран слишком часто
CСпрайт цыплёнка слишком тяжёлый
DМяч движется с переменной скоростью
4. Мяч находится выше центра ракетки. Как боту доехать до него на canvas?
AУвеличить enemy.y, ведь ось y растёт вниз
BУменьшить enemy.y, ведь «выше» значит меньшее значение y
CОставить enemy.y без изменений
DУвеличить enemy.x
5. Для чего в коде бота нужны проверки enemy.y < 0 и enemy.y + enemy.h > canvas.height?
AЧтобы посчитать счёт матча
BЧтобы бот не уехал за верхний или нижний край поля
CЧтобы ускорить движение ракетки
DЧтобы определить, выше мяч или ниже
6. Какой главный вывод про сложность игрового ИИ даёт этот урок?
AЧем сложнее алгоритм, тем интереснее соперник
BСложность настраивается одним числом (скоростью), а не усложнением алгоритма
CХорошему боту обязательно нужна нейросеть
DБот должен всегда выигрывать, чтобы было сложно