Понг: мяч и ракетки
Собираем каркас Понга: рисуем поле, две ракетки и мяч-цыплёнка, двигаем свою ракетку клавишами и запускаем мяч лететь по экрану с вектором скорости.
Понг — самая первая в истории видеоигра, появившаяся ещё в 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();Результат: на холсте появляется тёмно-синее поле, по центру сверху вниз бежит пунктирная белая линия-разметка, слева и справа стоят две белые вертикальные ракетки, а в центре поля сидит жёлтый квадратик-цыплёнок. Картинка пока застывшая, как стоп-кадр перед началом матча.
Разберём по шагам, что делает каждый блок:
- Фон.
fillStyleзадаёт цвет «кисти», аfillRect(0, 0, ширина, высота)заливает им весь холст. Это и стирает прошлый кадр, и рисует фон одним движением. - Разметка. Цикл рисует короткие прямоугольнички сверху вниз с шагом 30 пикселей — получается пунктир ровно по центру (
canvas.width / 2). - Ракетки. Для каждой ракетки мы берём её координаты и размеры из объекта и рисуем прямоугольник. Координаты не зашиты числами — мы читаем их из
playerиenemy, поэтому, когда ракетка сдвинется, она перерисуется на новом месте сама. - Мяч. Цыплёнок — это квадрат
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 (с летающим мячом и движущейся ракеткой игрока) и доработай каркас Понга:
- Оживи ракетку соперника. Пусть правая ракетка следует за мячом по вертикали: в
update()сравни центр ракетки соперника сchicken.yи подвинь её чуть-чуть в нужную сторону (например, на3пикселя за кадр). Не забудь ограничить её краями поля, как ракетку игрока. Получится простой бот-соперник. - Сделай старт случайным. При запуске задавай мячу случайное направление: пусть
vyс равной вероятностью будет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), и начнём считать очки, когда мяч пролетает мимо ракетки. Каркас у тебя уже есть — пора превратить его в настоящий матч.