Коротко
Пинг-понг для двоих — это поле, две ракетки, мяч с отскоками, подача и счёт, а для игры против компьютера ещё и соперник, который предсказывает точку прилёта мяча с отскоками. Чтобы соперника можно было обыграть, он смотрит на мяч раз в 0,1–0,35 с, двигает ракетку с ограниченной скоростью и ошибается сильнее на быстрых мячах. Ниже — параметры поля, формула предсказания, таблица трёх уровней сложности с измеренной долей отбитых мячей и код, проверенный в Node.js 24.
Содержание
- Поле, ракетки и счёт
- Управление для двоих на одной клавиатуре
- Отскоки, ускорение и потолок скорости
- Подача к проигравшему
- Предсказание точки прилёта с отскоками
- Соперник, которого можно обыграть
- Таблица: три уровня сложности
- Код на JavaScript
- Практический сценарий: игра за вечер
- Когда вам это не подойдёт
Поле, ракетки и счёт
Для примера возьмём поле 800×450 пикселей, ракетки 12×80 у левого и правого края и мяч радиусом 8 пикселей. Ракетка правого игрока стоит передней гранью на x = 760, левого — на x = 52. Игра идёт до 11 очков; это наш выбор для короткой партии, его легко поменять. Очко получает игрок, мимо которого не пролетел мяч.
Управление для двоих на одной клавиатуре
Двум игрокам на одной клавиатуре дают разнесённые клавиши: левому — KeyW и KeyS, правому — ArrowUp и ArrowDown. Проверять нужно KeyboardEvent.code, а не key: по MDN, code описывает физическую клавишу и не зависит от раскладки, поэтому на AZERTY, где клавиша с кодом KeyQ печатает A, управление останется на тех же местах. Подробнее о слое действий и геймпадах — в статье про ввод, ссылка в конце.
Держите набор нажатых кодов и двигайте ракетку на скорость × dt в кадре, а не на фиксированный шаг по событию нажатия: событие повтора клавиши приходит с задержкой, и движение было бы рывками.
Отскоки, ускорение и потолок скорости
От верхней и нижней стены мяч отражается сменой знака вертикальной скорости, а от ракетки — сменой знака горизонтальной и новым углом, который зависит от точки удара: чем ближе к краю ракетки, тем круче. Этот приём из аркадного Pong разобран в статье об истории механик 2D-игр.
После каждого удара скорость растёт на 5% до потолка 900 пикс/с. Потолок защищает от туннелирования:
шаг за кадр = 900 пикс/с ÷ 60 = 15 пикс
защита = толщина ракетки + диаметр мяча = 12 + 16 = 28 пикс
15 < 28 — мяч не проскочит ракетку
Подача к проигравшему
Подача идёт из центра поля к игроку, который проиграл очко, под случайным углом не круче 30° и после паузы в 1 секунду. Пауза даёт время вернуть взгляд к центру, а ограничение угла исключает подачу почти вертикально, когда мяч долго скачет между стенами. Стартовая скорость после подачи сбрасывается к начальной — 360–450 пикс/с.
Предсказание точки прилёта с отскоками
Точку, где мяч пересечёт линию ракетки, считают одной формулой: где был бы мяч без стен, а затем «складывают» путь по высоте поля:
t = (x_ракетки − x_мяча) / vx
сырой = y_мяча + vy × t − r
пролёт = H − 2r
m = ((сырой mod 2·пролёт) + 2·пролёт) mod 2·пролёт
y = r + m, если m ≤ пролёт
y = r + 2·пролёт − m, иначе
Для мяча с x = 160, y = 100 и скоростью (300; −300) формула даёт y = 368, и пошаговая симуляция с шагом 1/6000 с приходит туда же.
Соперник, которого можно обыграть
Идеальное предсказание делает соперника непобедимым, поэтому ему добавляют три ограничения:
- Задержка реакции. Цель пересчитывается не каждый кадр, а раз в
reactionсекунд. Между пересчётами соперник едет к старой цели. - Предельная скорость ракетки. Если мяч прилетает далеко от ракетки, соперник не успевает.
- Ошибка прицела. К предсказанию добавляется случайное смещение до
errorпикселей, умноженное на отношение скорости мяча к 360 пикс/с, — быстрые мячи он читает хуже.
Когда мяч летит от соперника, тот возвращается в центр: это и выглядит «по-человечески», и оставляет шанс сыграть в угол.
Таблица: три уровня сложности
Параметры ниже — наш стартовый набор; доля отбитых ударов измерена в коде из этой статьи на 1000 ударах игрока из-под своей ракетки, под углом до 45°, с ракеткой соперника в случайной точке.
| Уровень | Реакция | Скорость ракетки | Ошибка | Отбито при 450 пикс/с | при 750 | при 900 |
|---|---|---|---|---|---|---|
| Лёгкий | 0,35 с | 260 пикс/с | 50 пикс | 89% | 61% | 54% |
| Средний | 0,20 с | 380 пикс/с | 30 пикс | 100% | 90% | 78% |
| Сложный | 0,14 с | 460 пикс/с | 26 пикс | 100% | 96% | 89% |
Медленный мяч средний и сложный соперники отбивают всегда, поэтому выигрывать у них нужно скоростью и углом. Если игроки жалуются, что сложный «читерит», увеличьте реакцию или ошибку, а не снижайте скорость ракетки — иначе он будет двигаться неестественно медленно.
Код на JavaScript
Код содержит предсказание точки прилёта, подачу, соперника с тремя ограничениями и прогон, который измеряет долю отбитых ударов. Генератор случайных чисел с зерном делает проверку повторяемой. Код проверен в Node.js 24, вывод совпадает с комментариями.
const W = 800, H = 450, R = 8, PADDLE_H = 80;
const AI_X = 760; // передняя грань ракетки соперника
// Детерминированный генератор, чтобы проверки повторялись
function rng(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Где мяч пересечёт линию x = targetX с учётом отскоков от верха и низа
function predictY(ball, targetX) {
const t = (targetX - ball.x) / ball.vx;
const raw = ball.y + ball.vy * t - R;
const span = H - 2 * R;
const m = ((raw % (2 * span)) + 2 * span) % (2 * span); // «складываем» путь
return R + (m <= span ? m : 2 * span - m);
}
// Подача: к проигравшему очко, угол не круче 30°
function serve(rand, towardRight, speed) {
const a = (rand() * 2 - 1) * (Math.PI / 6);
const dir = towardRight ? 1 : -1;
return { x: W / 2, y: H / 2 + (rand() * 2 - 1) * 100, vx: dir * speed * Math.cos(a), vy: speed * Math.sin(a) };
}
// Уровни сложности соперника
const LEVELS = {
easy: { reaction: 0.35, maxSpeed: 260, error: 50 },
normal: { reaction: 0.2, maxSpeed: 380, error: 30 },
hard: { reaction: 0.14, maxSpeed: 460, error: 26 },
};
// Соперник «смотрит» на мяч раз в reaction секунд и ошибается тем сильнее, чем быстрее мяч
function makeAI(level, rand) {
return { y: H / 2, target: H / 2, timer: 0, level, rand };
}
function updateAI(ai, ball, dt) {
const L = ai.level;
ai.timer -= dt;
if (ai.timer <= 0) {
ai.timer = L.reaction;
if (ball.vx > 0) {
const speed = Math.hypot(ball.vx, ball.vy);
const err = (ai.rand() * 2 - 1) * L.error * (speed / 360);
ai.target = predictY(ball, AI_X) + err;
} else {
ai.target = H / 2; // мяч летит к игроку — возвращаемся в центр
}
}
const d = ai.target - ai.y;
if (Math.abs(d) > 4) ai.y += Math.sign(d) * Math.min(Math.abs(d), L.maxSpeed * dt);
}
// Удар игрока: мяч уходит от его ракетки (x = 52) под углом до 45°
function playerShot(rand, speed) {
const a = (rand() * 2 - 1) * (Math.PI / 4);
return { x: 52, y: R + rand() * (H - 2 * R), vx: speed * Math.cos(a), vy: speed * Math.sin(a) };
}
// Прогон: какую долю ударов игрока соперник отбивает
function hitRate(levelName, speed, serves, seed) {
const rand = rng(seed);
let hits = 0;
for (let s = 0; s < serves; s++) {
const ball = playerShot(rand, speed);
const ai = makeAI(LEVELS[levelName], rand);
ai.y = R + rand() * (H - 2 * R); // ракетка там, где осталась после прошлого розыгрыша
const dt = 1 / 60;
while (ball.x < AI_X) {
updateAI(ai, ball, dt);
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.y < R) { ball.y = 2 * R - ball.y; ball.vy = -ball.vy; }
if (ball.y > H - R) { ball.y = 2 * (H - R) - ball.y; ball.vy = -ball.vy; }
}
if (Math.abs(ball.y - ai.y) <= PADDLE_H / 2 + R) hits++;
}
return Math.round((hits / serves) * 100);
}
// Проверка предсказания против пошаговой симуляции с мелким шагом
const b = { x: 160, y: 100, vx: 300, vy: -300 };
const sim = { ...b };
while (sim.x < AI_X) {
const dt = 1 / 6000;
sim.x += sim.vx * dt; sim.y += sim.vy * dt;
if (sim.y < R) { sim.y = 2 * R - sim.y; sim.vy = -sim.vy; }
if (sim.y > H - R) { sim.y = 2 * (H - R) - sim.y; sim.vy = -sim.vy; }
}
console.log(predictY(b, AI_X), Math.round(sim.y)); // 368 368
for (const name of ['easy', 'normal', 'hard']) {
console.log(name, hitRate(name, 450, 1000, 7) + '%', hitRate(name, 750, 1000, 7) + '%', hitRate(name, 900, 1000, 7) + '%');
}
// easy 89% 61% 54%
// normal 100% 90% 78%
// hard 100% 96% 89%
Функция serve в прогоне не используется — она нужна в самой игре после каждого очка.
Практический сценарий: игра за вечер
Пинг-понг собирается за 6 шагов:
- Поле и отрисовка. Холст 800×450, ракетки и мяч прямоугольниками, счёт сверху по центру.
- Ввод. Набор нажатых
code, движение ракеток наскорость × dt, ограничение по краям поля. - Мяч. Движение на
v × dt, отскок от стен, отскок от ракеток с углом по точке удара и ускорением 5% до 900 пикс/с. - Очко и подача. Мяч ушёл за край — очко сопернику, пауза 1 с, подача к проигравшему.
- Компьютер. Вместо управления правой ракеткой с клавиатуры вызывайте
updateAI; уровень выбирается в меню. - Проверка. Запустите прогон
hitRateпосле каждого изменения параметров, чтобы сложность не уехала незаметно.
Когда вам это не подойдёт
Схема «предсказание плюс ограничения» хороша для пинг-понга и похожих игр, но не для всех:
- Соперник с несколькими объектами: в хоккее или футболе сверху нужно выбирать, за кем следить и куда бежать, — это задача для деревьев поведения или выбора по полезности.
- Физика с вращением мяча: если мяч закручивается и траектория искривляется, формула со «складыванием» перестаёт работать, и точку прилёта ищут пошаговой симуляцией вперёд.
- Сетевая игра для двоих: задержка сети требует отдельной синхронизации мяча, и локальной логики из этой статьи недостаточно.
Мы разрабатываем J2DRU — браузерный 2D игровой движок, где управление с клавиатуры, мыши, геймпада и касанием входит в проверенные внутри разработки основы, а клавиши можно переназначать. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).
Источники (проверено: ноябрь 2026): MDN — KeyboardEvent: code property (физическая клавиша, пример KeyQ на AZERTY). Параметры поля, уровни сложности и доля отбитых ударов — наш пример и наши измерения в Node.js 24.
Читайте также: