Простой ИИ-противник
Сегодня твой Понг наконец перестанет быть игрой «сам с собой»: правую ракетку возьмёт под управление компьютер, и с ним можно будет по-настоящему сражаться.
Простое правило почти всегда побеждает «умный ИИ»: чтобы ракетка-бот играла достойно, ей хватит одной мысли — «двигайся туда, где мяч по высоте». Никаких нейросетей, только сравнение двух чисел.
Зачем нам противник, который умеет проигрывать
Вспомни, в каком состоянии мы оставили Понг в прошлом уроке про счёт и интерфейс. У нас есть мяч, который летает и отскакивает, есть левая ракетка под управлением игрока, и наверху уже горит счёт. Одна проблема: играть не с кем. Правая ракетка либо стоит мёртвым столбом, либо ей тоже надо рулить с клавиатуры — а это весело только если рядом сидит друг.
Сегодня мы посадим за правую ракетку компьютер. И тут многие новички пугаются слова «ИИ» — кажется, что сейчас придётся писать что-то космически сложное. Расслабься: наш бот будет думать ровно одну мысль за кадр. Звучит она так: «Мяч сейчас выше или ниже меня? Если выше — поеду вверх, если ниже — вниз». Всё. Это и есть искусственный интеллект уровня Понга 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 бот будет вести себя непредсказуемо.
Мини-практика: сделай бота живее
Базовый соперник готов — теперь твоя очередь его прокачать. Возьми код из четвёртого примера и попробуй:
- Подбери честную скорость. Меняй
enemy.speedот 2 до 8 и поиграй на каждом значении пару минут. Найди число, при котором матч получается напряжённым: бот хорош, но обыграть его реально. - Сделай бота тупее, когда мяч далеко. Добавь правило: пока мяч летит к ракетке игрока (
ball.vx < 0, мяч движется влево), бот ленится и едет к центру поля, а не за мячом. Так он реагирует только когда мяч летит к нему — как настоящий игрок, который не дёргается зря. - Добавь «уровни сложности». Заведи переменную
levelи в зависимости от неё ставьenemy.speedиdeadZone: на лёгком — медленный бот с широкой мёртвой зоной, на сложном — быстрый с узкой. - Промахивающийся бот (для смелых). Сделай так, чтобы бот целился не точно в мяч, а чуть мимо: добавляй к ориентиру небольшое случайное смещение. Иногда он будет ошибаться, как живой соперник.
Если получилось хотя бы первое и второе — поздравляю, ты настроил баланс игры руками, а это уже работа настоящего геймдизайнера.
Итоги и что дальше
Сегодня ты сделал компьютерного соперника и понял, наверное, самую полезную мысль про игровой ИИ:
- Простое правило бьёт сложный алгоритм. Весь «интеллект» бота — это «двигайся туда, где мяч по высоте», то есть сравнение двух чисел.
- Сложность — это число, а не ум. Меняя
enemy.speed, ты делаешь противника легче или тяжелее, не трогая логику. Ограниченная скорость даёт игроку честный шанс выиграть. - Мёртвая зона убирает дрожание. Разреши боту стоять, когда мяч почти напротив центра, — и движение станет спокойным, как у человека.
- Не забывай про ось
yи границы поля — два места, где чаще всего прячутся баги.
Теперь у тебя полноценный Понг: мяч, две ракетки, счёт и живой соперник с цыплёнком, которого можно обыграть. Это уже законченная игра! В следующем уроке мы наведём на неё лоск — добавим звук удара по мячу, экраны старта и победы, и приведём весь код Понга в порядок, чтобы было не стыдно показать друзьям. А дальше нас ждёт целый новый раздел — Змейка, где тот же цыплёнок поведёт за собой растущий хвост. Увидимся там.