В одном из наших проектов мы делали игру в пинг-понг на JavaScript, где нужно было играть вдвоём. Один игрок управлял одной ракеткой, второй — другой ракеткой, а мячик летал по написанным нами законам физики.
Следующий шаг: научим компьютер играть против человека. Сегодня реализуем самый простой вариант.
Что нам понадобится
Для работы нужен только исходный код старой игры и немного новой логики. Скопируйте код из выпадайки в новый документ, сохраните как HTML и откройте в браузере.
<!DOCTYPE html>
<html>
<head>
<title>Пинг-понг на JavaScript</title>
<!-- Задаём стили для всего документа -->
<style>
html,
body {
height: 100%;
margin: 0;
}
body {
background: black;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<!-- Рисуем игровое поле -->
<canvas width="750" height="585" id="game"></canvas>
<script>
// Обращаемся к игровому полю из документа
const canvas = document.getElementById('game');
// Делаем поле двухмерным
const context = canvas.getContext('2d');
// Размер игровой клетки
const grid = 15;
// Высота платформы
const paddleHeight = grid * 5; // 80
// Задаём максимальное расстояние, на которое может подняться платформа
const maxPaddleY = canvas.height - grid - paddleHeight;
// Скорость платформы
var paddleSpeed = 6;
// Скорость мяча
var ballSpeed = 5;
// Описываем левую платформу
const leftPaddle = {
// Ставим её по центру
x: grid * 2,
y: canvas.height / 2 - paddleHeight / 2,
// Ширина — одна клетка
width: grid,
// Высоту берём из константы
height: paddleHeight,
// Платформа на старте никуда не движется
dy: 0
};
// Описываем правую платформу
const rightPaddle = {
// Ставим по центру с правой стороны
x: canvas.width - grid * 3,
y: canvas.height / 2 - paddleHeight / 2,
// Задаём такую же ширину и высоту
width: grid,
height: paddleHeight,
// Правая платформа тоже пока никуда не двигается
dy: 0
};
// Описываем мячик
const ball = {
// Он появляется в самом центре поля
x: canvas.width / 2,
y: canvas.height / 2,
// квадратный, размером с клетку
width: grid,
height: grid,
// На старте мяч пока не забит, поэтому убираем признак того, что мяч нужно ввести в игру заново
resetting: false,
// Подаём мяч в правый верхний угол
dx: ballSpeed,
dy: -ballSpeed
};
// Проверка на то, пересекаются два объекта с известными координатами или нет
// Подробнее тут: https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
function collides(obj1, obj2) {
return obj1.x < obj2.x + obj2.width &&
obj1.x + obj1.width > obj2.x &&
obj1.y < obj2.y + obj2.height &&
obj1.y + obj1.height > obj2.y;
}
// Главный цикл игры
function loop() {
// Очищаем игровое поле
requestAnimationFrame(loop);
context.clearRect(0, 0, canvas.width, canvas.height);
// Если платформы на предыдущем шаге куда-то двигались — пусть продолжают двигаться
leftPaddle.y += leftPaddle.dy;
rightPaddle.y += rightPaddle.dy;
// Если левая платформа пытается вылезти за игровое поле вниз,
if (leftPaddle.y < grid) {
// то оставляем её на месте
leftPaddle.y = grid;
}
// Проверяем то же самое сверху
else if (leftPaddle.y > maxPaddleY) {
leftPaddle.y = maxPaddleY;
}
// Если правая платформа пытается вылезти за игровое поле вниз,
if (rightPaddle.y < grid) {
// то оставляем её на месте
rightPaddle.y = grid;
}
// Проверяем то же самое сверху
else if (rightPaddle.y > maxPaddleY) {
rightPaddle.y = maxPaddleY;
}
// Рисуем платформы белым цветом
context.fillStyle = 'white';
// Каждая платформа — прямоугольник
context.fillRect(leftPaddle.x, leftPaddle.y, leftPaddle.width, leftPaddle.height);
context.fillRect(rightPaddle.x, rightPaddle.y, rightPaddle.width, rightPaddle.height);
// Если мяч на предыдущем шаге куда-то двигался — пусть продолжает двигаться
ball.x += ball.dx;
ball.y += ball.dy;
// Если мяч касается стены снизу — меняем направление по оси У на противоположное
if (ball.y < grid) {
ball.y = grid;
ball.dy *= -1;
}
// Делаем то же самое, если мяч касается стены сверху
else if (ball.y + grid > canvas.height - grid) {
ball.y = canvas.height - grid * 2;
ball.dy *= -1;
}
// Если мяч улетел за игровое поле влево или вправо — перезапускаем его
if ((ball.x < 0 || ball.x > canvas.width) && !ball.resetting) {
// Помечаем, что мяч перезапущен, чтобы не зациклиться
ball.resetting = true;
// Даём секунду на подготовку игрокам
setTimeout(() => {
// Всё, мяч в игре
ball.resetting = false;
// Снова запускаем его из центра
ball.x = canvas.width / 2;
ball.y = canvas.height / 2;
}, 1000);
}
// Если мяч коснулся левой платформы,
if (collides(ball, leftPaddle)) {
// то отправляем его в обратном направлении
ball.dx *= -1;
// Увеличиваем координаты мяча на ширину платформы, чтобы не засчитался новый отскок
ball.x = leftPaddle.x + leftPaddle.width;
}
// Проверяем и делаем то же самое для правой платформы
else if (collides(ball, rightPaddle)) {
ball.dx *= -1;
ball.x = rightPaddle.x - ball.width;
}
// Рисуем мяч
context.fillRect(ball.x, ball.y, ball.width, ball.height);
// Рисуем стены
context.fillStyle = 'lightgrey';
context.fillRect(0, 0, canvas.width, grid);
context.fillRect(0, canvas.height - grid, canvas.width, canvas.height);
// Рисуем сетку посередине
for (let i = grid; i < canvas.height - grid; i += grid * 2) {
context.fillRect(canvas.width / 2 - grid / 2, i, grid, grid);
}
// Отслеживаем нажатия клавиш
document.addEventListener('keydown', function (e) {
// Если нажата клавиша вверх,
if (e.which === 38) {
// то двигаем правую платформу вверх
rightPaddle.dy = -paddleSpeed;
}
// Если нажата клавиша вниз,
else if (e.which === 40) {
// то двигаем правую платформу вниз
rightPaddle.dy = paddleSpeed;
}
// Если нажата клавиша W,
if (e.which === 87) {
// то двигаем левую платформу вверх
leftPaddle.dy = -paddleSpeed;
}
// Если нажата клавиша S,
else if (e.which === 83) {
// то двигаем левую платформу вниз
leftPaddle.dy = paddleSpeed;
}
});
// А теперь следим за тем, когда кто-то отпустит клавишу, чтобы остановить движение платформы
document.addEventListener('keyup', function (e) {
// Если это стрелка вверх или вниз,
if (e.which === 38 || e.which === 40) {
// останавливаем правую платформу
rightPaddle.dy = 0;
}
// А если это W или S,
if (e.which === 83 || e.which === 87) {
// останавливаем левую платформу
leftPaddle.dy = 0;
}
});
}
// Запускаем игру
requestAnimationFrame(loop);
</script>
</body>
</html>
Новая логика игры
Для игры с компьютером нужно сделать так, чтобы одна из платформ двигалась сама — пусть это будет левая платформа. Так как у нас лёгкая версия игры, то движение будет очень простым: платформа будет двигаться от края до края без остановок. Если повезёт — отобьёт мяч, если нет — значит, не повезло.
Чтобы у компьютера были хоть какие-то шансы на выигрыш, сделаем его платформу в два раза больше, чем наша — так ему будет проще отбивать мяч.
Последнее, что можно сделать, — замедлить мяч, чтобы игрок успевал его постоянно отбивать.
Начнём с мяча.
Замедляем мяч
Это самое простое изменение — достаточно поменять одну цифру в коде, как игра становится более понятной и управляемой:
// Скорость мяча
var ballSpeed = 4;
Если 4 для вас всё равно слишком быстро — поставьте 3, а если хотите занять детей — то 2.
Увеличиваем левую платформу
Находим код, который отвечает за левую платформу. За размер отвечает параметр высоты. Просто умножаем её на 2:
// Описываем левую платформу
const leftPaddle = {
// Ставим её по центру
x: grid * 2,
y: canvas.height / 2 - paddleHeight / 2,
// Ширина — одна клетка
width: grid,
// Удваиваем размер платформы
height: paddleHeight * 2,
// Платформа на старте никуда не движется
dy: 0
};
Пока ничего никуда не движется, но сейчас мы это исправим.
Запускаем платформу
Для этого нам нужно добавить одну строчку сразу после предыдущего раздела. Она задаёт платформе начальную скорость, которая у нас прописана в константе paddleSpeed:
leftPaddle.dy = paddleSpeed;
Но сразу после запуска игры платформа доезжает до края платформы, немного проваливается вниз, несмотря на свой размер, и останавливается. Нужно это исправить:
Делаем отскок от границ
Находим блок, который проверяет, вышли платформы за границы или нет. Правую пока не трогаем, нас интересует только левая, за которую играет компьютер.
Наша задача — поменять условия так, чтобы они заставляли платформу саму двигаться вверх и вниз:
// Если левая платформа пытается вылезти за игровое поле вниз,
if (leftPaddle.y < grid) {
// то оставляем её на месте
leftPaddle.y = grid;
leftPaddle.dy = paddleSpeed;
}
// Проверяем то же самое сверху
else if (leftPaddle.y > maxPaddleY) {
leftPaddle.y = maxPaddleY;
leftPaddle.dy = -paddleSpeed;
}
Мы добавили смену скорости левой платформы при касании границ. Посмотрим, как это сработало: платформа движется как нам нужно, но полностью игнорирует свой новый размер, когда уезжает вниз.
Исправляем баг с размером
Штука в том, что код не знает, что у левой платформы изменилась длина, несмотря на то, что мы это явно прописали в самом начале. Всё дело в том, что этот блок сравнения, который мы только что правили, оперирует другой константой: maxPaddleY. В исходной версии игры платформы были одинакового размера, поэтому всё было в порядке, но теперь у нас две разных длины.
Чтобы исправить этот баг с размером, нам нужно будет завести две отдельные константы — с длиной левой и правой платформ по отдельности. Немного модифицируем правую и добавим левую:
const LeftmaxPaddleY = canvas.height - grid - paddleHeight * 2;
const RightmaxPaddleY = canvas.height - grid - paddleHeight;
Теперь видно, что левая платформа в 2 раза больше правой, и координаты крайней точки тоже изменились. Теперь нам нужно просмотреть весь код и найти все места, где используется старая константа maxPaddleY и заменить её на новую, учитывая сторону платформы.
На наше счастье, это нужно сделать только в блоке проверки границ при движении. Сразу исправим:
// Если левая платформа пытается вылезти за игровое поле вниз,
if (leftPaddle.y < grid) {
// то оставляем её на месте
leftPaddle.y = grid;
leftPaddle.dy = paddleSpeed;
}
// Проверяем то же самое сверху
else if (leftPaddle.y > LeftmaxPaddleY) {
leftPaddle.y = LeftmaxPaddleY;
leftPaddle.dy = -paddleSpeed;
}
// Если правая платформа пытается вылезти за игровое поле вниз,
if (rightPaddle.y < grid) {
// то оставляем её на месте
rightPaddle.y = grid;
}
// Проверяем то же самое сверху
else if (rightPaddle.y > RightmaxPaddleY) {
rightPaddle.y = RightmaxPaddleY;
}
Теперь всё как нужно. Можно играть против роботизированной ракетки и, наверное, довольно часто выигрывать.
<!DOCTYPE html>
<html>
<head>
<title>Пинг-понг на JavaScript</title>
<!-- Задаём стили для всего документа -->
<style>
html,
body {
height: 100%;
margin: 0;
}
body {
background: black;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<!-- Рисуем игровое поле -->
<canvas width="750" height="585" id="game"></canvas>
<script>
// Обращаемся к игровому полю из документа
const canvas = document.getElementById('game');
// Делаем поле двухмерным
const context = canvas.getContext('2d');
// Размер игровой клетки
const grid = 15;
// Высота платформы
const paddleHeight = grid * 5; // 80
// Задаём максимальное расстояние, на которое могут двигаться платформы
const LeftmaxPaddleY = canvas.height - grid - paddleHeight * 2;
const RightmaxPaddleY = canvas.height - grid - paddleHeight;
// Скорость платформы
var paddleSpeed = 6;
// Скорость мяча
var ballSpeed = 4;
// Описываем левую платформу
const leftPaddle = {
// Ставим её по центру
x: grid * 2,
y: canvas.height / 2 - paddleHeight / 2,
// Ширина — одна клетка
width: grid,
// Высоту берём из константы
height: paddleHeight * 2,
// Платформа на старте никуда не движется
dy: 0
};
leftPaddle.dy = paddleSpeed;
// Описываем правую платформу
const rightPaddle = {
// Ставим по центру с правой стороны
x: canvas.width - grid * 3,
y: canvas.height / 2 - paddleHeight / 2,
// Задаём такую же ширину и высоту
width: grid,
height: paddleHeight,
// Правая платформа тоже пока никуда не двигается
dy: 0
};
// Описываем мячик
const ball = {
// Он появляется в самом центре поля
x: canvas.width / 2,
y: canvas.height / 2,
// квадратный, размером с клетку
width: grid,
height: grid,
// На старте мяч пока не забит, поэтому убираем признак того, что мяч нужно ввести в игру заново
resetting: false,
// Подаём мяч в правый верхний угол
dx: ballSpeed,
dy: -ballSpeed
};
// Проверка на то, пересекаются два объекта с известными координатами или нет
// Подробнее тут: https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
function collides(obj1, obj2) {
return obj1.x < obj2.x + obj2.width &&
obj1.x + obj1.width > obj2.x &&
obj1.y < obj2.y + obj2.height &&
obj1.y + obj1.height > obj2.y;
}
// Главный цикл игры
function loop() {
// Очищаем игровое поле
requestAnimationFrame(loop);
context.clearRect(0, 0, canvas.width, canvas.height);
// Если платформы на предыдущем шаге куда-то двигались — пусть продолжают двигаться
leftPaddle.y += leftPaddle.dy;
rightPaddle.y += rightPaddle.dy;
// Если левая платформа пытается вылезти за игровое поле вниз,
if (leftPaddle.y < grid) {
// то оставляем её на месте
leftPaddle.y = grid;
leftPaddle.dy = paddleSpeed;
}
// Проверяем то же самое сверху
else if (leftPaddle.y > LeftmaxPaddleY) {
leftPaddle.y = LeftmaxPaddleY;
leftPaddle.dy = -paddleSpeed;
}
// Если правая платформа пытается вылезти за игровое поле вниз,
if (rightPaddle.y < grid) {
// то оставляем её на месте
rightPaddle.y = grid;
}
// Проверяем то же самое сверху
else if (rightPaddle.y > RightmaxPaddleY) {
rightPaddle.y = RightmaxPaddleY;
}
// Рисуем платформы белым цветом
context.fillStyle = 'white';
// Каждая платформа — прямоугольник
context.fillRect(leftPaddle.x, leftPaddle.y, leftPaddle.width, leftPaddle.height);
context.fillRect(rightPaddle.x, rightPaddle.y, rightPaddle.width, rightPaddle.height);
// Если мяч на предыдущем шаге куда-то двигался — пусть продолжает двигаться
ball.x += ball.dx;
ball.y += ball.dy;
// Если мяч касается стены снизу — меняем направление по оси У на противоположное
if (ball.y < grid) {
ball.y = grid;
ball.dy *= -1;
}
// Делаем то же самое, если мяч касается стены сверху
else if (ball.y + grid > canvas.height - grid) {
ball.y = canvas.height - grid * 2;
ball.dy *= -1;
}
// Если мяч улетел за игровое поле влево или вправо — перезапускаем его
if ((ball.x < 0 || ball.x > canvas.width) && !ball.resetting) {
// Помечаем, что мяч перезапущен, чтобы не зациклиться
ball.resetting = true;
// Даём секунду на подготовку игрокам
setTimeout(() => {
// Всё, мяч в игре
ball.resetting = false;
// Снова запускаем его из центра
ball.x = canvas.width / 2;
ball.y = canvas.height / 2;
}, 1000);
}
// Если мяч коснулся левой платформы,
if (collides(ball, leftPaddle)) {
// то отправляем его в обратном направлении
ball.dx *= -1;
// Увеличиваем координаты мяча на ширину платформы, чтобы не засчитался новый отскок
ball.x = leftPaddle.x + leftPaddle.width;
}
// Проверяем и делаем то же самое для правой платформы
else if (collides(ball, rightPaddle)) {
ball.dx *= -1;
ball.x = rightPaddle.x - ball.width;
}
// Рисуем мяч
context.fillRect(ball.x, ball.y, ball.width, ball.height);
// Рисуем стены
context.fillStyle = 'lightgrey';
context.fillRect(0, 0, canvas.width, grid);
context.fillRect(0, canvas.height - grid, canvas.width, canvas.height);
// Рисуем сетку посередине
for (let i = grid; i < canvas.height - grid; i += grid * 2) {
context.fillRect(canvas.width / 2 - grid / 2, i, grid, grid);
}
// Отслеживаем нажатия клавиш
document.addEventListener('keydown', function (e) {
// Если нажата клавиша вверх,
if (e.which === 38) {
// то двигаем правую платформу вверх
rightPaddle.dy = -paddleSpeed;
}
// Если нажата клавиша вниз,
else if (e.which === 40) {
// то двигаем правую платформу вниз
rightPaddle.dy = paddleSpeed;
}
// Если нажата клавиша W,
if (e.which === 87) {
// то двигаем левую платформу вверх
leftPaddle.dy = -paddleSpeed;
}
// Если нажата клавиша S,
else if (e.which === 83) {
// то двигаем левую платформу вниз
leftPaddle.dy = paddleSpeed;
}
});
// А теперь следим за тем, когда кто-то отпустит клавишу, чтобы остановить движение платформы
document.addEventListener('keyup', function (e) {
// Если это стрелка вверх или вниз,
if (e.which === 38 || e.which === 40) {
// останавливаем правую платформу
rightPaddle.dy = 0;
}
// А если это W или S,
if (e.which === 83 || e.which === 87) {
// останавливаем левую платформу
leftPaddle.dy = 0;
}
});
}
// Запускаем игру
requestAnimationFrame(loop);
</script>
</body>
</html>
Что дальше
В следующей серии мы сделаем непобедимый пинг-понг: платформа всегда будет двигаться за мячом, чтобы отбить его. Вам останется только продержаться как можно дольше.
Но это потом. А сейчас — обыграйте глупую платформу.