Завершаем трилогию про пинг-понг на JavaScript. Если в прошлой части мы сделали не слишком умного бота, то теперь всё серьёзно — победить этого противника физически невозможно. Платформа всегда отобьёт мяч, а ваша новая задача — постичь дзен и поставить новый рекорд.
Подготовка
За основу возьмём наш последний код из второй части, когда платформа движется сама.
<!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>
Что будем делать сегодня:
- Настроим всё, чтобы левая платформа всегда следовала за мячом.
- Добавим подсчёт очков.
- Сделаем так, чтобы компьютер запоминал новый рекорд и выводил его при каждой игре.
- Наконец-то уберём код, который отвечает за управление левой платформой с клавиатуры.
Настраиваем левую платформу Сначала нам нужно отключить автостарт платформы при запуске игры. Находим строчку 53 и удаляем такое:
leftPaddle.dy = paddleSpeed;
Теперь платформа не будет никуда рандомно двигаться. Дальше отключаем автоматический отскок от границ поля — удаляем ненужные строки, которые добавляли для этого:
// Если левая платформа пытается вылезти за игровое поле вниз,
if (leftPaddle.y < grid) {
// то оставляем её на месте
leftPaddle.y = grid;
// ВОТ ЭТО НАМ УЖЕ НЕ НАДО: leftPaddle.dy = paddleSpeed;
}
// Проверяем то же самое сверху
else if (leftPaddle.y > LeftmaxPaddleY) {
leftPaddle.y = LeftmaxPaddleY;
// И ВОТ ЭТО ТОЖЕ НЕ НАДО: leftPaddle.dy = -paddleSpeed;
}
А теперь делаем магию — добавляем автоматическое слежение платформы за мячом. Для этого находим фрагмент, который отвечает за движение мяча, и добавляем после него такое:
// Если мяч на предыдущем шаге куда-то двигался — пусть продолжает двигаться
ball.x += ball.dx;
ball.y += ball.dy;
// пусть платформа движется так же, как и мяч
leftPaddle.dy = ball.dy;
Смысл тут вот в чём: нас интересует только движение мячика вверх или вниз, а за это отвечает свойство .dy
. Мы берём его из мячика и отдаём в платформу. Теперь она будет двигаться вниз, если мяч идёт вниз, и вверх — если мяч летит вверх.
Считаем очки
Принцип будет такой:
- Сначала у игрока 0 очков. Рекорд — тоже 0.
- За каждое отбивание мяча игрок получает одно очко.
- Если игрок побил свой старый рекорд — он тут же обновляется на текущее значение очков.
- Когда мяч улетел за платформу, очки обнуляются, а рекорд остаётся.
- Постоянно выводим на экран оба этих показателя: рекорд и количество набранных очков.
Делаем нужные переменные. Находим в самом начале раздел с переменными и добавляем нужный код после скорости мяча:
// Рекорд
var record = 0;
// Набранные очки
var count = 0;
Добавляем очки за отбивания игроком. Теперь нам нужно найти код, который проверяет касание правой платформы, и если было касание — увеличить количество набранных очков на единицу (count += 1;):
// Проверяем и делаем то же самое для правой платформы
else if (collides(ball, rightPaddle)) {
ball.dx *= -1;
ball.x = rightPaddle.x - ball.width;
count +=1;
}
Следим за рекордом. Здесь всё просто: как только игрок пропустил мяч, мы смотрим, сколько очков он набрал к этому моменту. Если их больше, чем текущее значение больше рекорда, — записываем их как новый рекорд и обнуляем.
Всё это мы сделаем в коде, который обрабатывает вылет мяча за край платформы:
// Если мяч улетел за игровое поле влево или вправо — перезапускаем его
if ( (ball.x < 0 || ball.x > canvas.width) && !ball.resetting) {
// Помечаем, что мяч перезапущен, чтобы не зациклиться
ball.resetting = true;
// Если игрок набрал больше рекорда — записываем это как новый рекорд
if (count > record) { record = count };
// Обнуляем количество очков у игрока
count = 0;
// ДАЛЬШЕ ИДЁТ ОСТАЛЬНОЙ КОД ОБРАБОТКИ ВЫЛЕТА, ЕГО НЕ ТРОГАЕМ
Сохраняем рекорд в памяти компьютера. Когда мы делали свой планировщик задач на JavaScript, мы использовали localStorage для хранения данных в памяти браузера. Используем этот же подход, чтобы таким же образом сохранить текущее значение рекорда игрока.
Смысл в том, что мы точно так же, как и в планировщике, проверяем сначала размер хранилища. Если оно не пустое и в нём что-то есть — достаём значение рекорда оттуда. Если пустое — заводим запись и кладём туда ноль (значит, что рекорд пока никто не поставил). Добавим этот код после блока с переменными:
// Узнаём размер хранилища
var Storage_size = localStorage.length;
// Если в хранилище что-то есть…
if (Storage_size > 0){
// Достаём оттуда текущее значение рекорда
record = localStorage.getItem('record');
// Если там ничего нет —
} else
{
// Делаем новую запись и кладём туда ноль — рекорда пока нет
localStorage.setItem('record',0);
}
У нас есть место, где мы можем его хранить. Осталось только сохранить значение рекорда в памяти браузера. Для этого добавим такой код в блок обработки вылета мяча за игровое поле, сразу после обнуления значения набранных очков пользователя:
// Кладём значение рекорда в хранилище браузера
localStorage.setItem('record',record);
Добавляем вывод значений на экран. Будем использовать стандартные свойства объекта context:
- fillStyle — отвечает за цвет надписей (и за цвет в принципе),
- font — каким шрифтом будем выводить надписи,
- fillText — выводит заданный текст в определённых координатах.
Давайте слева выведем текущее значение рекорда, а справа — количество очков, набранных за игру. Этот код нужно вставить в самый конец главного цикла, сразу после блока обработки нажатия клавиш:
// Цвет текста
context.fillStyle = "#ff0000";
// Задаём размер и шрифт
context.font = "20pt Courier";
// Сначала выводим рекорд
context.fillText('Рекорд: ' + record, 150, 550);
// Затем — набранные очки
context.fillText(count, 450, 550);
Убираем обработку нажатий
Единственное, что мы ещё не сделали — не убрали реакцию на клавиши управления левой платформой. Исправим это — найдём и удалим вот этот код:
// Если нажата клавиша W,
if (e.which === 87) {
// то двигаем левую платформу вверх
leftPaddle.dy = -paddleSpeed;
}
// Если нажата клавиша S,
else if (e.which === 83) {
// то двигаем левую платформу вниз
leftPaddle.dy = paddleSpeed;
}
И этот:
// А если это W или S,
if (e.which === 83 || e.which === 87) {
// останавливаем левую платформу
leftPaddle.dy = 0;
}
<!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;
// Рекорд
var record = 0;
// Набранные очки
var count = 0;
// Узнаём размер хранилища
var Storage_size = localStorage.length;
// Если в хранилище что-то есть…
if (Storage_size > 0) {
// Достаём оттуда текущее значение рекорда
record = localStorage.getItem('record');
// Если там ничего нет —
} else {
// Делаем новую запись и кладём туда ноль — рекорда пока нет
localStorage.setItem('record', 0);
}
// Описываем левую платформу
const leftPaddle = {
// Ставим её по центру
x: grid * 2,
y: canvas.height / 2 - paddleHeight / 2,
// Ширина — одна клетка
width: grid,
// Высоту берём из константы
height: paddleHeight * 2,
// Платформа на старте никуда не движется
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 > LeftmaxPaddleY) {
leftPaddle.y = LeftmaxPaddleY;
}
// Если правая платформа пытается вылезти за игровое поле вниз,
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;
// пусть платформа движется точно также, как и мяч
leftPaddle.dy = 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;
// Если игрок набрал больше рекорда — записываем это как новый рекорд
if (count > record) { record = count };
// Обнуляем количество очков у игрока
count = 0;
// Кладём значение рекорда в хранилище браузера
localStorage.setItem('record', record);
// Даём секунду на подготовку игрокам
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;
count += 1;
}
// Рисуем мяч
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;
}
});
// А теперь следим за тем, когда кто-то отпустит клавишу, чтобы остановить движение платформы
document.addEventListener('keyup', function (e) {
// Если это стрелка вверх или вниз,
if (e.which === 38 || e.which === 40) {
// останавливаем правую платформу
rightPaddle.dy = 0;
}
});
// Цвет текста
context.fillStyle = "#ff0000";
// Задаём размер и шрифт
context.font = "20pt Courier";
// Сначала выводим рекорд
context.fillText('Рекорд: ' + record, 150, 550);
// Затем — набранные очки
context.fillText(count, 450, 550);
}
// Запускаем игру
requestAnimationFrame(loop);
</script>
</body>
</html>
Го играть
Вот итоговая страничка: заходите и играйте.