Понг: мяч и ракетки

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

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

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

Зачем начинать именно с Понга

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

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

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

Из чего состоит Понг: три объекта и поле

Любую игру удобно представлять как театр. Есть сцена (наше игровое поле на canvas) и есть актёры на ней — у каждого своя роль и свои «данные о себе». В Понге актёров трое:

  • Ракетка игрока — вертикальный прямоугольник слева, которым управляешь ты.
  • Ракетка соперника — такой же прямоугольник справа.
  • Мяч-цыплёнок — квадратик, который летает по полю.

Чтобы описать каждого актёра, мы заводим объект с его состоянием (state) — это набор данных вроде координат x, y и размеров. Если ты делал прошлые уроки про управление с клавиатуры и дельта-время, эта идея тебе уже знакома: объект chicken с полями x и y — это и есть его состояние.

Состояние игрока (state) — набор данных об объекте: его координаты, размеры, скорость и другие свойства, которые меняются от кадра к кадру. Вся игра — это, по сути, аккуратное изменение состояний и их перерисовка.

Заводим состояние для трёх объектов

Опишем сцену и трёх актёров в виде объектов. Размеры подобраны под холст 600×400.

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

// ракетка игрока (слева)
const player = {
  x: 20,
  y: 160,
  width: 14,
  height: 80,
  speed: 6
};

// ракетка соперника (справа)
const enemy = {
  x: 566,        // 600 - 20 - 14
  y: 160,
  width: 14,
  height: 80
};

// мяч — наш сквозной цыплёнок
const chicken = {
  x: 290,
  y: 190,
  size: 20,
  vx: 4,         // вектор скорости по горизонтали
  vy: 3          // вектор скорости по вертикали
};

Результат: на экране пока ничего не появилось — мы только описали данные. Но в памяти уже живут три объекта: player и enemy с координатами и размерами своих ракеток и chicken со своими координатами и парой чисел vx, vy — его будущей скоростью полёта. Это сцена, расставленная за кулисами, прежде чем поднять занавес.

Обрати внимание на пару чисел у цыплёнка — vx и vy. Это вектор скорости: vx говорит, на сколько пикселей мяч сдвинется вправо за кадр, а vy — насколько вниз. К ним мы вернёмся, когда заставим мяч лететь.

Рисуем поле, ракетки и мяч

Сцена описана — пора поднять занавес и нарисовать актёров. Рисовать будем через контекст 2D (тот самый context, полученный из canvas.getContext('2d')): он умеет заливать прямоугольники, рисовать линии и текст. Каждый кадр мы сначала стираем весь холст, а потом рисуем всё заново — как художник, который перерисовывает картинку с нуля на каждой странице флипбука.

Пример 1. Статичная картинка поля

Соберём функцию draw(), которая рисует тёмное поле, разделительную линию по центру и обе ракетки с мячом. Пока ничего не движется — просто смотрим на расстановку.

function draw() {
  // 1. заливаем поле тёмным фоном
  context.fillStyle = '#1b1b2f';
  context.fillRect(0, 0, canvas.width, canvas.height);

  // 2. разметка по центру — пунктир
  context.fillStyle = '#3b3b5c';
  for (let y = 0; y < canvas.height; y += 30) {
    context.fillRect(canvas.width / 2 - 2, y, 4, 18);
  }

  // 3. ракетки игрока и соперника — белые
  context.fillStyle = '#ffffff';
  context.fillRect(player.x, player.y, player.width, player.height);
  context.fillRect(enemy.x, enemy.y, enemy.width, enemy.height);

  // 4. мяч-цыплёнок — жёлтый квадратик
  context.fillStyle = '#ffdc3c';
  context.fillRect(chicken.x, chicken.y, chicken.size, chicken.size);
}

draw();

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

Разберём по шагам, что делает каждый блок:

  1. Фон. fillStyle задаёт цвет «кисти», а fillRect(0, 0, ширина, высота) заливает им весь холст. Это и стирает прошлый кадр, и рисует фон одним движением.
  2. Разметка. Цикл рисует короткие прямоугольнички сверху вниз с шагом 30 пикселей — получается пунктир ровно по центру (canvas.width / 2).
  3. Ракетки. Для каждой ракетки мы берём её координаты и размеры из объекта и рисуем прямоугольник. Координаты не зашиты числами — мы читаем их из player и enemy, поэтому, когда ракетка сдвинется, она перерисуется на новом месте сама.
  4. Мяч. Цыплёнок — это квадрат size × size в точке (chicken.x, chicken.y).

Главная мысль: draw() ничего не придумывает от себя — она просто рисует то, что записано в объектах. Хочешь сдвинуть ракетку — меняй player.y, а draw() сама нарисует её там, куда ты сказал.

Пример 2. Двигаем ракетку игрока стрелками

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

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

function update() {
  // двигаем ракетку игрока по вертикали
  if (keys['ArrowUp'])   player.y -= player.speed;
  if (keys['ArrowDown']) player.y += player.speed;

  // не выпускаем ракетку за края поля
  if (player.y < 0) player.y = 0;
  if (player.y > canvas.height - player.height) {
    player.y = canvas.height - player.height;
  }
}

function loop() {
  update();                       // обновили состояние
  draw();                         // нарисовали кадр
  requestAnimationFrame(loop);    // зовём себя на следующий кадр
}

loop();

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

Что здесь происходит:

  • Два if читают доску keys и меняют player.y. Помни, что в canvas ось y направлена вниз, поэтому «вверх» — это вычитание из y.
  • Две проверки-ограничителя не дают ракетке вылезти за поле: если y ушёл в минус — возвращаем в 0; если ракетка нижним краем перешагнула дно холста — прижимаем её к нему.
  • Игровой цикл loop() — это сердцебиение игры: каждый кадр он зовёт update() (поменять состояние) и draw() (перерисовать), а потом через requestAnimationFrame просит браузер повторить всё на следующем кадре.

Пример 3. Запускаем мяч лететь по вектору скорости

А вот и главный момент урока — заставим цыплёнка лететь. Идея проста до гениальности: каждый кадр мы прибавляем к координатам мяча его скорость. vx прибавляем к x, vy — к y. И всё — мяч полетел.

Вектор скорости — это пара чисел (vx, vy), которая показывает, насколько объект сдвигается по горизонтали и вертикали за один кадр. Положительный vx — вправо, отрицательный — влево; положительный vy — вниз, отрицательный — вверх.

function update() {
  // --- ракетка игрока (как в примере 2) ---
  if (keys['ArrowUp'])   player.y -= player.speed;
  if (keys['ArrowDown']) player.y += player.speed;
  if (player.y < 0) player.y = 0;
  if (player.y > canvas.height - player.height) {
    player.y = canvas.height - player.height;
  }

  // --- полёт мяча по вектору скорости ---
  chicken.x += chicken.vx;   // сдвигаем по горизонтали
  chicken.y += chicken.vy;   // сдвигаем по вертикали

  // отскок от верхней и нижней стенок
  if (chicken.y < 0) {
    chicken.y = 0;
    chicken.vy = -chicken.vy;          // разворачиваем вертикальную скорость
  }
  if (chicken.y > canvas.height - chicken.size) {
    chicken.y = canvas.height - chicken.size;
    chicken.vy = -chicken.vy;
  }
}

Результат: цыплёнок срывается с места и летит по диагонали — вправо и вниз, потому что у него vx = 4 и vy = 3. Долетев до нижней стенки, он отскакивает и идёт вверх, у верхней стенки — снова разворачивается вниз. Получается красивый зигзаг между потолком и полом поля, как настоящий мяч в Понге. Сквозь правый и левый края он пока пролетает насквозь — ловить его ракетками будем в следующем уроке.

Вот в чём фокус по строкам:

  • chicken.x += chicken.vx и chicken.y += chicken.vy — это и есть движение по вектору. Каждый кадр мяч делает один шаг: 4 пикселя вправо и 3 вниз. Шестьдесят таких шажков в секунду складываются в плавный полёт.
  • Отскок от стенки — это смена знака скорости. Было vy = 3 (летим вниз) — стало vy = -3 (полетели вверх). Строка chicken.vy = -chicken.vy просто переворачивает направление по вертикали, и мяч «отражается», как от зеркала.
  • Перед сменой знака мы прижимаем мяч точно к стенке (chicken.y = 0 или к низу). Это страховка, чтобы он не «утоп» в стенке на пару пикселей и не задёргался, отскакивая туда-сюда.

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

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

1. Забыть стереть прошлый кадр

Если в draw() не заливать фон заново каждый кадр, старые позиции объектов остаются на холсте. Мяч тогда оставляет за собой жирный жёлтый след, а ракетка — длинную полосу: холст превращается в детский рисунок мелком. Заливка фона в начале draw() (fillRect(0, 0, width, height)) стирает прошлый кадр — не убирай её.

2. Менять координаты прямо в draw()

Возникает соблазн прибавлять скорость прямо в функции рисования. Так делать не стоит: рисование должно только показывать состояние, а менять его — задача update(). Если перемешать, код быстро превратится в кашу, в которой не понять, кто за что отвечает. Держи правило: update() считает, draw() рисует.

3. Отскок без прижимания к стенке

Если просто перевернуть vy, не вернув мяч к границе (chicken.y = 0), может случиться вот что: за один кадр мяч улетел на 3 пикселя за стенку, ты развернул скорость, но на следующем кадре он всё ещё за стенкой — и условие срабатывает снова, опять переворачивая скорость. Мяч «залипает» в стене и дрожит. Лекарство — сначала прижать мяч к стенке, потом менять знак скорости.

4. Перепутать направление оси y

Как и в прошлых уроках: в canvas ось y растёт вниз. Если для движения ракетки вверх написать player.y += speed, она поедет вниз, в обратную сторону. Вверх — это всегда y -= speed.

5. Запустить draw() один раз и ждать движения

Если вызвать draw() разово, без игрового цикла, ты увидишь только первый застывший кадр — ничего не полетит. Движение рождается из повторения: update() и draw() должны вызываться каждый кадр через requestAnimationFrame. Нет цикла — нет жизни.

Мини-проект: настраиваем матч

Теперь твоя очередь. Возьми код из примера 3 (с летающим мячом и движущейся ракеткой игрока) и доработай каркас Понга:

  1. Оживи ракетку соперника. Пусть правая ракетка следует за мячом по вертикали: в update() сравни центр ракетки соперника с chicken.y и подвинь её чуть-чуть в нужную сторону (например, на 3 пикселя за кадр). Не забудь ограничить её краями поля, как ракетку игрока. Получится простой бот-соперник.
  2. Сделай старт случайным. При запуске задавай мячу случайное направление: пусть vy с равной вероятностью будет 3 или -3. Тогда каждый матч начинается по-новому.
  3. Покажи счёт-заготовку. Через context.fillText(...) выведи сверху по центру два нуля — 0 : 0. Считать очки мы научимся позже, но место под табло пусть будет уже сейчас.

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

  • Центр ракетки соперника по вертикали — это enemy.y + enemy.height / 2. Сравни его с chicken.y: если мяч ниже центра — двигай ракетку вниз, если выше — вверх.
  • Случайное направление удобно задать так: chicken.vy = Math.random() < 0.5 ? 3 : -3; — половина бросков монетки вверх, половина вниз.
  • Для текста сначала задай цвет и шрифт: context.fillStyle = '#ffffff'; context.font = '24px monospace'; — а затем context.fillText('0 : 0', canvas.width / 2 - 24, 30);.
  • Когда заработает — поиграй с числами: ускорь мяч (vx: 6), сделай ракетку соперника помедленнее, чтобы он чаще промахивался. Меняй и смотри, что вышло — так и нащупывается баланс игры.

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

Итоги

Сегодня ты собрал скелет первой настоящей игры. Вот что у тебя теперь есть:

  • Состояние трёх объектовplayer, enemy и мяч-chicken, у каждого свои координаты и размеры. Игра — это аккуратное изменение этих данных.
  • Функция draw() — рисует поле, разметку, ракетки и мяч, читая их прямо из объектов; она ничего не считает, только показывает.
  • Функция update() — двигает ракетку по клавишам и мяч по вектору скорости; вся «жизнь» игры происходит здесь.
  • Вектор скорости (vx, vy) — пара чисел, которую мы каждый кадр прибавляем к координатам мяча, чтобы он летел. Смена знака скорости — это отскок от стенки.
  • Игровой циклupdate() + draw() + requestAnimationFrame, повторяющиеся каждый кадр: сердцебиение, без которого ничего не движется.

Главный принцип урока: update() меняет состояние, draw() его показывает, цикл повторяет это каждый кадр. Запомни это разделение — на нём держится любая игра, которую ты напишешь дальше.

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

Проверьте себя
1. Что хранит вектор скорости (vx, vy) у мяча-цыплёнка?
AТекущие координаты мяча на холсте
BНа сколько пикселей мяч сдвигается по горизонтали и вертикали за один кадр
CРазмеры мяча по ширине и высоте
DСколько очков набрал игрок
2. Как заставить мяч отскочить от верхней или нижней стенки?
AОбнулить координату y мяча
BПеревернуть знак вертикальной скорости: chicken.vy = -chicken.vy
CОстановить игровой цикл на один кадр
DУвеличить размер мяча
3. Почему в начале draw() мы каждый кадр заливаем весь холст фоном?
AТак требует синтаксис canvas
BЧтобы стереть прошлый кадр — иначе объекты оставляют след
CЧтобы мяч летел быстрее
DЭто нужно только один раз при запуске
4. Кто отвечает за изменение координат, а кто — за рисование?
Adraw() считает координаты, update() рисует
Bupdate() меняет состояние, draw() только показывает его
CОбе функции делают и то и другое
DКоординаты меняет requestAnimationFrame
5. Что случится, если перевернуть vy, но не прижать мяч к стенке (chicken.y = 0)?
AНичего, всё будет работать как надо
BМяч может «залипнуть» в стене и дрожать, переворачивая скорость каждый кадр
CМяч улетит за пределы экрана навсегда
DИгра выдаст ошибку
6. Ракетка игрока должна поехать вверх по стрелке. Какую строку написать?
Aplayer.y += player.speed
Bplayer.y -= player.speed
Cplayer.x -= player.speed
Dplayer.up = true