Обучение
November 14, 2022

Игра Понг на JavaScript

Весь код моего Понга с комментариями к каждой строчке

⚠ Эту статью также можно прочитать на Хабре

Это моя первая статья вышедшая на Хабре. Менее чем за неделю она собрала почти 4000 просмотров и 47 добавлений в избранное. Я очень рад такому результату! А еще в читатели на Хабре указали мне на то, что комментарии к функциям должны быть написаны сверху, поэтому тут выходит уже исправленный вариант.

Я был очень вдохновлен созданием Змейки. Даже не смотря на то, что она является просто повторением чужого гайда, из-за того я проанализировал в ней каждую строчку кода, мне удалось почерпнуть немало полезных знаний и навыков. Я также смог сам внести интересные изменения в ее код.

Повторил

К повторению Понга я приступил сразу же и это не заняло много времени. Но на этапе анализа стало ясно, что код очень сложный для понимания, все слишком переплетено и запутано. Еще недавно я бы подумал, что это я не все понимаю, но сейчас я уже видел как это можно сделать лучше.

Переосмыслил

Я писал этот Понг с нуля, взяв из исходника только некоторые функции и концепции. Полностью поменял структуру, добавил модуль с настройками, отдельно вынес все функции рисования и многое другое.

Учитывая мой совсем маленький опыт программирования, чуть меньше 2 месяцев, я очень горжусь проделанной работой.

Расписал

В статье я постарался расписать каждую функцию и изложить ход своих мыслей. Это заняло прилично времени, но возможно кому-то зайдет такой гайд, лично мне иногда мне не хватало описаний логики функций, пояснений того, что куда передается и зачем.

Буду рад, если подскажите как можно улучшить код или найдете и укажите на ошибки в комментариях, даже орфографические🙂

Гитхаб с моим Понгом: https://github.com/Buninman/Pong

А вот видео-гайд изначального пинг-понга от автора, и его код на гитхабе

Кстати, оригинальная игра 1972 года называется Pong, а пинг-понг. Хотя идеей-прародительницей для нее действительно был настольный теннис.

Что примечательно, Понг была первой коммерчески успешной видеоигрой.

Тут можно поиграть в готовый Понг


Начало

Набросал схему, чтоб было понятнее, что куда экспортируется. Но на этот раз мы пойдем не по порядку схемы, а немного по-другому. Сначала рассмотрим те модули, которые используются везде, такие как файл настроек и рисование.

  1. setting.js
  2. canvas.js
  3. printer.js
  4. game.js
  5. ball.js
  6. player.js
  7. index.html
  8. style.css

setting.js

Модуль настроек. Я сложил сюда все значения которые используются в игре, кроме надписей и размеров шрифтов. Доступ к экземпляру настроек есть у всех модулей и он всегда будет присвоен переменной set.

export default class Setting {
  constructor() {
    // Высота и ширина игрового поля, и всех слоев канваса
    this.boxWidth = 800
    this.boxHeight = 500
    // Радиус закругления углов игрового поля
    this.boxRound = 20
    // Серый цвет заливки игрового поля
    this.boxColor = '#333333'
    // Толщина линий
    this.lineWidth = 6
    // Цвет линий. Темно-серый, как основной фон окна брузера в CSS
    this.lineColor = '#232323'
    // Светло-серый цвет, используется для текста таймера и бегунка
    this.textColor = '#EBEBEB'
    // Вспомогательные красный и желтый цвета,
    // используются для подсветки технических нюансов
    this.supportColorRed = '#FA0556'
    this.supportColorYellow = '#FAC405'

Далее идут все параметры мячика. В отдельный объект вынесены те параметры, которые изменяются в процессе игры.

    // Скорость мячика
    this.ballSpeed = 7
    // Радиус мячка, диаметр получается 16px
    this.ballRadius = 8
    // Координаты мячика по умолчанию,
    // равны половине длины и ширины поля, это центр
    this.ballXDefault = (this.boxWidth / 2)
    this.ballYDefault = (this.boxHeight / 2)
    // Цвет мячика, я сделал таким же светло-серым как textColor
    this.ballColor = '#EBEBEB'
    // Счетчик отбитых мячей, также к нему привязано увеличение
    // скорости мячика. Увеличиваем скорость при каждом ударе
    this.ballHitScore = 0
    
    this.ball = {
      // Текущие координаты мячика, которые меняются в процессе
      // игры, изначально они равны дефолтным - мячик в центре поля
      x: this.ballXDefault,
      y: this.ballYDefault,
      // Ускорение мячика по осям. Изначально равно 0, но
      // позже получает рандомное значение от 0.8 до 1, с + или - 
      dx: 0,
      dy: 0,
      // Еще одно значение скорости, оно нужно, т.к. скорость мяча
      // постепено растет и переодически надо ее возвращать
      // к дефолтному this.ballSpeed
      speed: this.ballSpeed
    }

Дальше идут параметры обоих игроков. В отдельные объекты вынесены параметры уникальные для каждого игрока. Мы будем передавать эти отдельные объекты при создании экземпляра класса Player

    // Радиус игрока. Правильнее было бы назвать толщиной, т.к.
    // игрок это векторная линия. Но в расчетах столкновений, 
    // я использую крайние точки как окружности, поэтому это радиус.
    // Реальная толщина игрока - это два его радиуса, 14px.
    this.playerRadius = 7        
    // Высота игрока. Растояние от верхней до нижней точки игрока.
    // Но т.к. платформы игроков имеют закругления, реальный размер
    // получается на два радиуса больше, 94px
    this.playerHeight = 80
    // Скорость игрока. Используется как коэффициент для ускорения
    this.playerSpeed = 8
    // Пространство от краев игрока до стенки сверху и снизу
    this.playerBorder = this.playerRadius * 3
    // Пространство от центра платформы игрока до стенки за ним
    this.playerSpace = this.playerRadius * 6
    // Изначальная координата игрока Y равна половине высоты
    // игрового поля минус половина высоты игрока, таким образом
    // игрок будет распологаться посередине при любой заданной длине
    this.playerYDefault =
                   (this.boxHeight / 2) - (this.playerHeight / 2)
    this.playerL = {
      // Счетчик очков
      score: 0,
      // Координата Х для появления надписей "+1" на игровом поле.
      // Для левого игрока равна ширине поля минус 2 растояния
      // до игрока. Таким образом она находится на поле противника,
      // справа
      goalPointX: this.boxWidth - this.playerSpace * 2,
      // Параметр определяет выравнивание текста "+1".
      // Я также использую этот параметр для определения стороны
      // в которую полетит мяч после забития гола
      align: 'right',
      // Кординаты игрока. X равен заданному растоянию playerSpace,
      // а Y дефолтному значению, общему для обоих игроков
      x: this.playerSpace,
      y: this.playerYDefault,
      // Зарезервированная переменная со значением Y по умолчанию
      yDefault: (this.boxHeight / 2) - (this.playerHeight / 2),
      // Цвет игрока. Оранжевый
      color: '#A55F02',
      // Создаем массив с парами ключ-значение.
      // Номер клавиши и строка
      // с направлением. Узнать номер клавиш можно тут:
      // https://puzzleweb.ru/javascript/char_codes-key_codes.php
      keys: [[87,'up'], [83,'down']],  
    }
    this.playerR = {
      score: 0,
      goalPointX: this.playerSpace * 2,
      align: 'left',
      // Координата X для правого игрока равна всей ширине поля,
      // минус заданное растояние playerSpace
      x: this.boxWidth - (this.playerSpace),
      y: this.playerYDefault,
      // Цвет игрока. Голубой
      color: '#38887A',
      keys: [[38,'up'], [40,'down']],
    }
  }
}
Наверх

canvas.js

Модуль можно использовать в других проектах, практически в неизменном виде, т.к. он содержит непосредственные функции рисования примитивов на 2D-канвасе, которые в свою очередь используются модулем printer.js.

Для начала рассмотрим конструктор класса Canvas:

export default class Canvas {
  constructor(setting) {
    // Передаем переменной set общие настройки
    this.set = setting
    // Создаем элемент canvas и переменную для доступа к нему
    this.canvas = document.createElement('canvas')
    // Создаем в канвасе 2d-контекст, нужен для рисования фигур
    this.ctx = this.canvas.getContext('2d')
    // Задаем канвасу высоту и ширину
    this.canvas.width = this.set.boxWidth
    this.canvas.height = this.set.boxHeight
    // Находим в html тег game (id="game") и как дочерний эллемент
    // создаем в нем наш canvas
    document.querySelector('#game').appendChild(this.canvas)
  }

Рисование текста. В него передается сам текст, координаты, размер текста, цвет, выравнивание и выравнивание по базовой линии (координате y)

  drawText(text, x, y, fontSize, color = this.set.textColor, 
                            align = "center", baseline = 'middle') {
    // Указываем цвет заливки
    this.ctx.fillStyle = color
    // Указываем шрифт и атрибуты
    this.ctx.font = `bold ${fontSize} 'Fira Mono', monospace`
    // Указываем выравнивание по краю
    this.ctx.textAlign = align
    // Указываем выравнивание по базовой линии
    this.ctx.textBaseline = baseline
    // Пишем текст, передаем туда строку с текстом
    // и ккординаты начальной точки
    this.ctx.fillText(text, x, y)
  }

С помощью трех функций ниже мы рисуем игровое поле, которое состоит из прямоугольника с закругленными краями, круга посередине и трех линий.

Помимо игрового поля, линией мы рисуем игрока, а круг также используется для мячика.

  drawLine(xS, yS, xF, yF, lineWidth, color) {
    // Указываем, что линия будет с закруглениями на концах
    this.ctx.lineCap = 'round'
    // beginPath() начинает вектор
    this.ctx.beginPath() 
    // Аргументами указываем координаты начальной точки линии
    this.ctx.moveTo(xS, yS)
    // Аргументами  указываем координаты конечной точки линии
    this.ctx.lineTo(xF, yF)
    // Указываем толщину линии, ее мы также передаем аргументом
    this.ctx.lineWidth = lineWidth
    // Указываем цвет обводки
    this.ctx.strokeStyle = color
    // Рисуем обводку (линию)
    this.ctx.stroke()
    // Завершем создание вектора
    this.ctx.closePath()
  }
  
  drawRectangleRound(x, y, width, height, radius, color) {
    // beginPath() начинает вектор
    this.ctx.beginPath()
    // Указываем координаты начальной точки линии
    this.ctx.moveTo(x + radius, y)
    // Указываем координаты следующей точки линии
    this.ctx.lineTo(x + width - radius, y)
    // Указываем координаты точки, до куда будет идти закругление
    this.ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
    // Указываем координаты следующей точки линии и т.д
    this.ctx.lineTo(x + width, y + height - radius)
    this.ctx.quadraticCurveTo(x + width, y + height,
                                     x + width - radius, y + height)
    this.ctx.lineTo(x + radius, y + height)
    this.ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
    this.ctx.lineTo(x, y + radius)
    this.ctx.quadraticCurveTo(x, y, x + radius, y)
    // Завершем создание вектора
    this.ctx.closePath()
    // Указываем цвет заливки
    this.ctx.fillStyle = color
    // Создаем заливку
    this.ctx.fill()
  }
  
  drawCircle(x, y, radius, fillColor, stroke = true) {
    // beginPath() начинает вектор
    this.ctx.beginPath()
    // Создаем арку. Агругументами выступают координаты
    // центра окружности, радиус, начальный угол в радианах
    // и конечный угол в радианах.
    // Math.PI*2 это число Пи умноженное на 2, дает замкнутый круг
    this.ctx.arc(x, y, radius, 0, Math.PI * 2)
    // Указываем цвет заливки
    this.ctx.fillStyle = fillColor
    // Создаем заливку
    this.ctx.fill()
    // Если нам не нужна обводка, то аргументам мы передаем false,
    // а по умолчанию обводка есть
    if (stroke) {
      // Указываем толщину линии
      this.ctx.lineWidth = 6
      // Указываем цвет обводки
      this.ctx.strokeStyle = this.set.lineColor
      // Рисуем обводку
      this.ctx.stroke()
    }
    // Завершем создание вектора
    this.ctx.closePath()
  }

В основе рисования круга функцией drawCircle() используется метод arc() в котором используется значение угла в радианах. Про радианы на википедии есть понятная статья с картинкой, если интересно. ༼ つ ◕_◕ ༽つ

Функция drawArc(), также использует в основе своей метод arc(), но тут мы не замыкаем дугу, а оставляем ее небольшой кусочек. Можно было бы обойтись одной функцией, но нужно было бы передавать в нее больше аргументов. Поэтому для таймера я сделал отдельную функцию.

  drawArc(radius, sAngle, eAngle, color = this.set.textColor) {
    const centerW = (this.set.boxWidth / 2)
    const centerH = (this.set.boxHeight / 2)
    
    // lineCap определяет то что обводка будет закругляться на конце
    this.ctx.lineCap = 'round'
    this.ctx.beginPath()
    this.ctx.arc(centerW, centerH, radius, sAngle, eAngle)
    this.ctx.lineWidth = 6
    this.ctx.strokeStyle = color
    this.ctx.stroke()
    this.ctx.closePath()
  }

Функция отчищает канвас. Используется в метод clearRect(), который принимает начальные и конечные точки прямоугольника, который надо отчистить. В данном случае мы чистим канвас целиком.

  clear() {        
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
  }
}
Наверх

printer.js

Модуль работает напрямую с функциями канваса. В нем создаются сразу несколько канвасов, которые выступают слоями и перерисовываются независимо друг от друга.

Все внутренние переменные в функциях объявлены только для удобства читаемости и компактности кода в гайде.

Для начала рассмотрим конструктор класса Printer:

// В файл подключаем класс Canvas
import Canvas from './canvas.js'

export default class Printer {
  // В принтер передаем только настройки
  constructor(setting) {    
    // Передаем общие настройки переменной set,
    this.set = setting
    // а переменной ball настройки мячика, для удобства
    this.ball = setting.ball
    // Создаем новый Map с пятью классами Canvas под разные нужды,
    // порядок будущих слоев влияет на их видимость, первый будет 
    // перекрывать вторым и т.д.
    this.canvas = new Map([
      // На слое background рисуется неподвижные и необновляемые
      // элементы игры. Игровое поле
      ['background', new Canvas(this.set)],
      // Слой для отрисовки счета игроков
      ['score', new Canvas(this.set)],
      // Слой для впомогательных функций, не нужен для игры
      ['support', new Canvas(this.set)],
      // Дополнительный слой для разных задач
      ['other', new Canvas(this.set)],
      // Слой для текста, появляющегося на экране
      ['text', new Canvas(this.set)],
      // Слой для игровых элементов с постоянной перерисовкой,
      // таких как мячик и игроки
      ['gamelayer', new Canvas(this.set)]
      ])
    // Для сокращения ширины кода, помещаю пути обращения
    // к Canvas в переменные, можно этого не делать
    this.bgCan = this.canvas.get('background')
    this.scoreCan = this.canvas.get('score')
    this.supCan = this.canvas.get('support')
    this.othCan = this.canvas.get('other')
    this.txtCan = this.canvas.get('text')
    this.gameCan = this.canvas.get('gamelayer')
    
    // Переменная ballDirectionAngle нужна передачи значения угла,
    // под которым требуется рисовать белый бегунок, отображающий    
    // направление броска мячика
    this.ballDirectionAngle = 0
  }


Функция ниже рисует игровое поле, это фон с закругленными краями и разные темные линии. Рисуется на специальном слое канваса - 'background'.

  drawBackground() {        
    // Высота и ширина игрового поля и всех слоев канваса
    const width = this.set.boxWidth        
    const height = this.set.boxHeight        
    // Радиус закругления углов игрового поля 
    const boxRound = this.set.boxRound        
    // Цвет заливки игрового поля         
    const boxColor = this.set.boxColor        
    // Толщина и цвет линий             
    const lineW = this.set.lineWidth        
    const lineColor = this.set.lineColor        
    // Пространство от центра платформы игрока до стенки за ним
    const plSpace = this.set.playerSpace        
    // Пространство от краев игрока до стенки сверху и снизу
    const plBorder = this.set.playerBorder        
    
    // Рисуем основной прямоугольник игрового поля с закруглениями
    this.bgCan.drawRectangleRound(0, 0, width, height,
                                               boxRound, boxColor)
    // Рисуем вертикальную линию посередине
    this.bgCan.drawLine((width / 2), 0, (width / 2),
                                           height, lineW, lineColor)
    // Рисуем круг посередине, с радиусом в 1/4 высоты поля
    this.bgCan.drawCircle((width / 2), (height / 2),
                                    (height / 4), boxColor)
    // Рисуем 2 линии под игроками с отступами от края
    this.bgCan.drawLine(plSpace, plBorder, plSpace,
                             (height - plBorder), lineW, lineColor)
    this.bgCan.drawLine((width - plSpace), plBorder,
          (width - plSpace), (height - plBorder), lineW, lineColor)
  }

Функция drawBriefing() рисует инструкцию с клавишами управления. Рисую ее на слое для игры 'game', т.к. при первоначальном отчете он не перерисовывается и мы можем им воспользоваться.

К моменту вызова drawBriefing() у нас уже будут нарисованы игроки с помощью функции drawPlayer(), о ней ниже.

  drawBriefing() {
    // Цвета левого и правого игроков
    const plLColor = this.set.playerL.color
    const plRColor = this.set.playerR.color
    // Координаты x и y для текста с инструкцией
    // Немного сложно, но все координаты отталкиваются от статичных
    // значений и масштабируются с игровым полем
    const controlXL = (this.set.playerSpace * 2)
    const controlXR = this.set.boxWidth - (this.set.playerSpace * 2)
    const controlY = (this.set.boxHeight / 17)
    
    // Рисуем текст инструкций. Для каждого игрока свой цвет.
    // Параметры 'left' и 'right' отвечаю за выравнивание текста
    this.gameCan.drawText('keys:', controlXL , (controlY * 8),
                                         '15px', plLColor, 'left')
    this.gameCan.drawText('W and S', controlXL, (controlY * 9),
                                         '20px', plLColor, 'left')
    this.gameCan.drawText('keys:', controlXR, (controlY * 8),
                                        '15px', plRColor, 'right')
    this.gameCan.drawText('Arrows', controlXR, (controlY * 9),
                                        '20px', plRColor, 'right')
  }

Функция drawScore() отвечает за визуальное отображение текущего счета на игровом поле. Рисуется на специальном слое 'score'.

  drawScore() {
    // Цвета левого и правого игроков
    const plLColor = this.set.playerL.color
    const plRColor = this.set.playerR.color
    // Значение количества очков каждого игрока
    const plLScore = this.set.playerL.score
    const plRScore = this.set.playerR.score
    // Координаты x и y для отбражения очков игроков
    // отталкиваются от статичных значений ширины и высоты поля
    const scoreXL = (this.set.boxWidth / 9 * 4)
    const scoreXR = (this.set.boxWidth / 9 * 5)
    const scoreY = (this.set.boxHeight / 20)
    
    // Рисуем текст счета. Для каждого игрока свой цвет.
    // Параметр 'left' рисует текст справа от точки координат,
    // а параметр 'right', наоборот, слева.
    // Таким образом цифры счета никогда не слипнутся (⊙_⊙)
    this.scoreCan.drawText(plLScore, scoreXL, scoreY, '40px',
                                         plLColor, 'right', 'top')
    this.scoreCan.drawText(plRScore, scoreXR, scoreY, '40px',
                                          plRColor, 'left', 'top')
  }

Функции ниже проверяют какое направление для мяча выбрано и запускают цикл рисования белой полосочки бегающей по кругу. Вызывается drawBallDirection() из модуля game.js, при старте каждого матча.

В основе рисования этого "белого бегунка" используется метод arc() в котором используется значение угла в радианах. Про радианы на википедии есть понятная статья с картинкой, если интересно. ༼ つ ◕_◕ ༽つ

  // В функцию передается переменная int, которая содержит
  // корректировщик коээфициента значения угла. Она может иметь
  // значение 2 для таймера после гола и 4 для начального таймера
  drawBallDirection(int = 2) {   
    // Ось координат канваса начинается сверху слева, поэтому
    // если направление мячика по X больше 0, то он летит вправо,
    // если направление мячика по Y больше 0, то он летит вниз     
    // следовательно здесь мячик летит по диагонали вправо вниз
    if (this.ball.dx > 0 && this.ball.dy > 0) {
      // Я подобрал необходимые коэффициенты для       
      // значений угла окружности, в котором бегунок 
      // должен будет остановиться
      
      // В зависимости от направления полета мячика
      // передаем нужное значение во внешнюю переменную
      this.ballDirectionAngle = 6.3
    }
    if (this.ball.dx < 0 && this.ball.dy > 0) {
        this.ballDirectionAngle = 6.8
    }        
    if (this.ball.dx < 0 && this.ball.dy < 0) {
        this.ballDirectionAngle = 7.3
    }
    if (this.ball.dx > 0 && this.ball.dy < 0) { 
        this.ballDirectionAngle = 7.8
    }
    // Запускаем цикл рисования белого бегунка,        
    // передаем в него значение угла минус значение переменной int.
    // Я нарочно увеличил значения углов на 2 оборота окружности,
    // чтоб при вычитании значения оставались положительными.
    // В противном случае появляются погрешности¯\_(ツ)_/¯
    this.loopBallDirection(this.ballDirectionAngle - int) 
  }
  
  // Функция представляет собой цикл, перерисовывающий бегунок,
  // пока он не достигнет нужного угла на окружности  
  loopBallDirection(someAngle) {    
    // Радиус окружности такой же, как радиус круга на игровом поле
    const rad = (this.set.boxHeight / 4)        
    // При первом вызове функции переменной angle присваивается   
    // значение угла, полученного из drawBallDirection(),
    // но в последствии оно берется из цикла
    let angle = someAngle        
    
    // Рисуем часть окружности через функцию канвасе      
    this.othCan.drawArc(rad, Math.PI * angle - 0.3, Math.PI * angle)
    // С помощью встроенной в JS функции setTimeout(),        
    // делаем задержку в 0.06 секунд, или '60' милисекунд        
    setTimeout(() => {
      angle += 0.1 
      if(angle <= this.ballDirectionAngle) {                
      this.clear('other')                
      this.loopBallDirection(angle)            
      }        
    }, 60)
  }

Первая функция ниже является посреднической, она используется для рисования текста по центру игрового поля, а две другие функции уже используют ее внутри себя для рисования различного текста на поле.

  // Функция рисует текст в центре экрана, по умолчанию использует    
  // белый цвет и '90px' размер шрифта, это счетчик стартового таймера
  centerText(text, fontSize = '90px', color = this.set.textColor) {
    // Координаты центра игрового поля
    const centerW = (this.set.boxWidth / 2)
    const centerH = (this.set.boxHeight / 2)
    
    this.txtCan.drawText(text, centerW, centerH, fontSize, color)
  }
  
  // Функция рисует счетчик ударов по цетру экрана.
  // Можно было обойтись одной фунцией centerText(), но так понятнее
  drawBallHit() {
    // Используем цвет линий, чтоб счетсчик не отвлекал внимание
    this.centerText(this.set.ballHitScore, '70px', this.set.lineColor)
  }
  
  // Функция вызывается из экземпляра Player и принимает координату Х,
  // цвет игрока и выравнивание
  drawGoal(x, color, align) {
    // рисуем "+1" на поле проигравшего. Цветом забившего игрока
    this.txtCan.drawText('+1', x, this.ball.y, '20px', color, align)
    // Рисуем надпись "Goal" в центре. Цветом забившего игрока  
    this.centerText('Goal!', '50px', color)
    // Через 0.8 сукунд отчищаем слой 'text'
    setTimeout(() => {
      this.clear('text') }, 800)
  }

Функции рисуют мячик и игрока. Мячик это круг, а игрок это линия с обводкой и закругленными краями.

  // Рисует мячик используя его текущее местоположение
  drawBall() {    
    let ballX = this.ball.x
    let ballY = this.ball.y
    let radius = this.set.ballRadius
    let color = this.set.ballColor
    
    this.gameCan.drawCircle(ballX, ballY, radius, color, false)
  } 
  
  // Рисуем игрока используя его текущее местоположение.
  // Принимает координату Х и две координаты Y, верхнюю и нижнюю.
  // Рисует между ними линию. Также принимает толщину линии
  // и цвет игрока
  drawPlayer(xS, yS, yF, lineWidth, color) {
    this.gameCan.drawLine(xS, yS, xS, yF, lineWidth, color)
  }

Функция очистки канваса. Как аргумент принимает название слоя канваса который надо почистить и вызывает для него метод clear().

  // Функция отчищает нужный канвас    
  // Передаем в нее имя нужного слоя в виде 'строки' текста
  clear(canvas) {
    this.canvas.get(canvas).clear()    
  }

Функции ниже нужны только для проверки игровых механик, они делают видимыми внутренние механизмы игры, такие как "тень" игроков, желтая зона и возможные направления вылета мячика

  // Функция рисует фактический размер игрока.
  // В движении зона отбития платформы увеличивается
  drawShadowPlayer(xS, yS, yF) {    
    const color = this.set.supportColorYellow
    const plWidth = (this.set.playerRadius * 2)
  
    this.supCan.drawLine(xS, yS, xS, yF, plWidth, color)
  }
  
  // Если мячик находится в желтой зоне (перед игроком),
  // то он отскакивает от координаты X игрока. Тут мы подсвечиваем
  // эту желтую зону
  drawYellowZone(x, yS, yF) {    
    const color = this.set.supportColorYellow
    const center = (this.set.boxWidth / 2)

    this.supCan.drawLine(x, yS, center, yS, 1, color)
    this.supCan.drawLine(x, yF, center, yF, 1, color)
  }
  
  // Функция подсвечивает все направления вылета мяча,
  // нужны были для расчета угла остановки белого бегунка
  drawAngleZone() {    
    const color = this.set.supportColorRed
    const radius = (this.set.boxHeight / 4)
    
    this.supCan.drawArc(radius, Math.PI * 0.2, Math.PI * 0.3, color)
    this.supCan.drawArc(radius, Math.PI * 0.7, Math.PI * 0.8, color)
    this.supCan.drawArc(radius, Math.PI * 1.2, Math.PI * 1.3, color)
    this.supCan.drawArc(radius, Math.PI * 1.7, Math.PI * 1.8, color)
    }
  }
Наверх

game.js

На этот модуль ссылается index.html отвечает за запуск самой игры. В нем создаются почти все классы и запускается цикл отрисовки кадров анимации. Он также является хабом, через который модули взаимодействую друг с другом.

Для начала рассмотрим конструктор класса Game:

// В файл подключаем Настройки, Игрока, Мячик и Принтер
import Setting from './setting.js'
import Player from './player.js'
import Ball from './ball.js'
import Printer from './printer.js'

class Game {
  constructor() {        
    // Передаем переменной set общие настройки
    this.set = new Setting()
    // В Printer передаем настройки
    this.print = new Printer(this.set)
    // В Ball передаем весь класс Game
    this.ball = new Ball(this)
    // Создаем два класса Player и передаем туда весь класс Game, 
    // а также отдельно настройки каждого игрока
    this.playerL = new Player(this, this.set.playerL)
    this.playerR = new Player(this, this.set.playerR)
    // Переменная "requestId" служит для запуска и остановки анимации.
    // Меняя значение на false, мы сможем останавливать анимацию
    this.reqId = true        
    // Инициируем первый запуск игры
    this.firstLaunch()
  }

Функция firstLaunch() отвечает только за первый запуск и срабатывает один раз при запуске. Функции которые она вызывает внутри себя будут подробнее расписаны внутри своих классов.

  firstLaunch() { 
    // Рисуем игровое поле
    this.print.drawBackground()
    // Вспомогательные функции. Вызвав ее здесь, мы увидим
    // 4 возможных направления для полета мячика
    this.support()
    // Рисуем игроков
    this.playerL.draw()
    this.playerR.draw()
    // Рисуем цифры счета, они пока равны 0   
    this.print.drawScore()
    // Функция drawBriefing() рисует инструкцию по управлению  
    this.print.drawBriefing()
    // dropBall() выбирает рандомное направление для мячика 
    this.ball.dropBall()
    // drawBallDirection(4) проверяет какое направление для мяча 
    // выбрано и запускает цикл рисования белой полосочки, 
    // бегающей по кругу
    this.print.drawBallDirection(4)
    // Рисуем цифру 3. Передаем значение в метод класса Print
    this.print.centerText('3')

    setTimeout(() => {
      // Отчищаем слой с цифрой 3 и рисуем цифру 2
      // с помощью встроенной в JS функции setTimeout(),
      // делаем задержку выполнений блока {} кода в 0.8 секунд, 
      // или '800' милисекунд
      this.print.clear('text'),
      this.print.centerText('2') }, 800)
    setTimeout(() => {
      // Каждое следующее действие происходит еще на '800' милисекунд
      // позднее предыдущего 
      this.print.clear('text'),
      this.print.centerText('1') }, 1600)
    setTimeout(() => {
      // Стираем цифру и пишем "Go"
      this.print.clear('text'),
      this.print.centerText('Go')}, 2400)
    setTimeout(() => {
      // На последней функции отчищаем слои и запускаем игру.
      // В функцию start() передаем переменную this.reqId, 
      // значение которой изначально стоит true
      this.print.clear('text'),
      this.print.clear('other')
      this.start(this.reqId) }, 3200)
    }

Далее две функции создающие постоянный цикл анимации. Тут используется функция requestAnimationFrame(), о ней подробно можно прочитать в документации, она перерисовывает кадр.

  // Функция запускает анимацию, при условии что переданная
  // переменная равна true  
  start(reqId) {
    if (reqId) {
      // Если reqId = true, то метод requestAnimationFrame()
      // вызвает указанную функцию для обновления данных перед
      // следующим перерисовыванием 
      this.reqId = requestAnimationFrame((t) => this.timeLoop(t))
    }
  }
  
  timeLoop(t) { 
    // Отчищаем игровой слой, это нужно чтоб игроки и мяч
    // не оставляли за собой след из предыдущих отрисовок
    this.print.clear('gamelayer')
    // Функции обновления мячика и игроков, они, в свою очередь, 
    // вызывают все нужные функции внутри своих классов
    this.ball.update()
    this.playerL.update()
    this.playerR.update()
    // Вспомогательные функции. Вызвав ее здесь, мы увидим
    // границы желтых зон, от которых зависит направление
    // в котором отбивается мячик от платформы
    this.support()
    // Снова вызываем start() вызывая зацикленность анимации.
    // В качестве значения передаем requestId, он содержит
    // метод requestAnimationFrame() и выдаст true
    this.start(this.reqId)
  }

Функция reStart() отвечает за остановку анимации и перезапуск матча. Ее задачи частично схожи с функцией firstLaunch().

Эта функция срабатывает при забитии гола и в нее передается свойство align того игрока, который забил мяч.

  reStart(align) {
    // присваиваем reqId значение false, это останавливает анимацию
    this.reqId = false        

    // Делаем задержку в 0.8 секунд, и выполняем следующее:    
    setTimeout(() => {        
      // Отчищаем игровой слой
      this.print.clear('gamelayer')
      // Возвращаем игрокам и мячику значения позиций по умолчанию
      this.playerL.defaultSet()            
      this.playerR.defaultSet()            
      this.ball.defaultSet()            
      // Снова рисуем игроков и мячик, уже в стартовых позициях   
      this.playerL.draw()
      this.playerR.draw()
      this.ball.draw()
      // Вспомогательные функции. Вызвав ее здесь, мы увидим
      // 4 возможных направления для полета мячика   
      this.support()            
      // dropBall() выбирает рандомное направление для мячика 
      // Значение align укажет направление броска в забившего
      // предыдущий гол. Рандомость будет заключатся только в 
      // напрвлении вверх или вниз
      this.ball.dropBall(align)            
      // Функция запускает белый бегунок по направлению,
      // определенному выше в dropBall()
      this.print.drawBallDirection()            
    }, 800)
    
    // Следующие действия произойдут уже через 1.6 секунды
    setTimeout(() => {
      // Отчищаем слой other, это удалит белый бегунок с экрана
      this.print.clear('other')
      // Снова присваиваем reqId значение true
      // и перезапускаем игровой цикл
      // Эти действия произойдут уже через 1.6 секунды,
      // после предыдущего setTimeout()
      this.reqId = true
      this.start(this.reqId)
    }, 2400)
  }

Следующая функция не влияет на работоспособность и нужна исключительно для визуального понимая работы "желтой зоны" и механизма отбития мячика платформами.

Подробнее о желтой зоне в модуле Player

  // Функция вызывается в firstLaunch(), timeLoop() и reStart() 
  // и запускает отрисовку всех вспомогательных функций
  support() {
    // Отчищает свой слой канваса
    this.print.clear('support')
    // Рисует желтые зоны игроков
    this.playerL.support()
    this.playerR.support()
    // Рисует 4 направления для мяча
    this.print.drawAngleZone()
  }
}

Функция ниже запускается первой, после загрузки index.html, создает класс Game и по факту запускает весь остальной JavaScript код

// Функция создает объект Game после того как все файлы
// будут подгружены браузером
window.onload = () => {
  const game = new Game()
}
Наверх

ball.js

Модуль отвечает за мячик. За рандомность его направления, разворот, отрисовку и т.п.

export default class Ball {
  // При создании в Ball мы передали в него весь класс Game    
  constructor(Game) {  
    // Через game мы будем получать доступ в методу reStart()  
    this.game = Game
    // Передаем переменной set общие настройки
    this.set = Game.set
    // Для удобства, сразу выделим в отдельную переменную ball,
    // настройки мячика которые изменяюся по ходу игры
    this.ball = Game.set.ball
    // Переменная для доступа к модулю Printer
    this.print = Game.print
  }

Две функции ниже отвечают за рандомность полета мячика. Первая выбирает величину ускорения от 0.8 до 1, а вторая выбирает направления этого ускорения - с минусом или плюсом.

Мячик двигается путем прибавления значения ускорения к его координатам. Например, чтобы сместить мячик вправо, мы можем прибавить 1 к координате x, а чтобы сместить его вниз, прибавить 1 к координате y (ось координат начинается в верхнем левом углу). Но таким образом, мячик будет лететь строго под 45°.

Чтоб этого не происходило есть функция getRandom(), она возвращает рандомное число в промежутке от 0.8 до 1. Получается, что мы прибавляем к координатам x и y разные значения и мячик никогда НЕ двигается строго под 45°. Этого практически не заметно, но такое поведение не дает мячику зацикливаться в одной траектории и делает игру более разнообразной.

Функция getRandomDirection() в случайном порядке присваивает отрицательное или положительное значение результату getRandom().

  getRandom() {
    // Метод Math.random() генерирует случайное положительное        
    // значение в диапазоне от 0 до <1,       
    // а формула Math.random() * (max - min) + min позволяет
    // получить рандомное число в нужном диапазоне от min до max  
    return Math.random() * (1 - 0.8) + 0.8        
  }
  
  getRandomDirection() {  
    // Math.random() генерирует случайное значение        
    // Math.round() округляет это значение до целого 0 или 1
    // Boolean() возвращает 0 как false, а 1 как true          
    if (Boolean(Math.round(Math.random()))) {        
      // Если Boolean() вернул true, возвращаем случайное число   
      // в нужном нам диапазоне функцией getRandom()        
      return this.getRandom()            
    // Если Boolean() вернул false, то возвращаем тоже самое,   
    // но со знаком "минус"
    } else { return -this.getRandom() }
  }

Следующие две функции отвечает за движение мячика.

Так, функция dropBall() выбирает изначальное направление для полета мячика. Чтоб он мог полететь из центра в любой из 4 углов поля.

Используя описанные выше функции, она генерирует рандомные значения и присваивает их переменным dx и dy, это значения ускорения для каждой из координат мячика.

А функция move() присваевает получившиеся значения dx и dy реальным координатам мячика. Ее мы будем вызывать на каждом кадре.

  // Функция принимаем значение align от игрока,
  // это дает нам понять какой игрок забил гол в прошлом матче
  dropBall(player) {        
    // С помощью getRandomDirection() мы генерируем        
    // рандомное ускорение с рандомным знаком + или -  
    this.ball.dx = this.getRandomDirection()  
    this.ball.dy = this.getRandomDirection()           
    // Так как мы не знаем какое значение нагенерировано,        
    // приминяем следующее:            
    switch (player) {
      // Если значение left, то подает левый игрок,
      // значит мячик должен лететь вправо.             
      case 'left':
        // Чтоб исключить возможность полета влево  
        // Используем метод Math.abs(), который возвращает
        // положительно значение.
        // Присваеваем dx точно положительное значение dx
        this.ball.dx = Math.abs(this.ball.dx)
        break  
      // Для полета влево мы также применяем Math.abs(),  
      // но после превращения значения в положительное,       
      // делаем его отрицательным с помощью - 
      case 'right':                
        this.ball.dx = -Math.abs(this.ball.dx)
        break        
    }        
    // Если значение player не было передано, т.е оно undefined, 
    // то ничего не меняется и все значения останутся
    // полностью случайны, такое случается на первом старте игры
  }
  
  // Двигает мяч в пространсте прибавляя сгенерированные ранее
  // значения к его координатам, умножая на коэффециент скорости
  // который мы задали в настройках мячика как speed
  move() {
    // Оператор += сначала складывает исходное значение (это х)
    // и расчетное значение (результат умножения),
    // а потом присваивает полученное значение переменной х
    this.ball.x += (this.ball.dx * this.ball.speed)
    this.ball.y += (this.ball.dy * this.ball.speed)
  }

Функция checkCollisionWithWalls() отвечает за просчет столкновений мячика со стенками игрового поля и, в случае столкновения мяча со стенкой за игроком, вызывает функцию goalProcess().

Ось координат в JavaScript начинается с верхнего левого угла, что показано на рисунке ниже. Следовательно верхняя и левая стенки будут иметь координаты 0, а правая и нижняя равны длине и ширине поля.

  checkCollisionWithWalls() {  
    // В данном случае ballX и ballY это координаты мяча в будующем,
    // которые будут на следующем кадре, но без учета скорости
    let ballX = (this.ball.x + this.ball.dx)   
    let ballY = (this.ball.y + this.ball.dy)    
    // Координата Х правой стены (ширина поля) минус размер 
    // радиуса мяча, чтоб при столкновении мяч не утопал в стену
    const rightWall = (this.set.boxWidth - this.set.ballRadius)
    // Координата Х левой стены (это 0) плюс размер радиуса мяча.
    // Просто отступ от края размером с половинку мячика   
    const leftWall = this.set.ballRadius
    // Координата Y верхней стены (также 0),
    // Просто берем радиус мячика    
    const TopWall = this.set.ballRadius    
    // Координата Y нижней стены (высота поля) минус радиус
    const BottomWall = (this.set.boxHeight - this.set.ballRadius)
    
      // Если координаты мячика стали больше координат правой стены,
      // то:
      if (ballX >= rightWall) {        
        // Меняем направление по оси Х с помощью функции reverseBall()
        this.ball.dx = this.reverseBall(this.ball.dx)
        // Вызываем функцию goalProcess() которая завершает матч
        this.goalProcess(this.set.playerL)
        }
      // Если координаты мячика стали меньше координат левой стены,
      // то:          
      if (ballX <= leftWall) {        
        this.ball.dx = this.reverseBall(this.ball.dx)
        this.goalProcess(this.set.playerR)
      }
      // Если мячик коснулся верхней или нижней стенки, то:  
      if (ballY >= BottomWall || ballY <= TopWall) {        
        // Разворачиваем мяч по оси Y  
        this.ball.dy = this.reverseBall(this.ball.dy)
      }
    }

Как следует из названия, функция reverseBall() разворачивает мячик по одной из оси координат. меняя значение его ускорения с отрицательного на положительное и наоборот.

  // В функцию передается значение ускорения dx или dy
  reverseBall(dir) {
    // Если значение ускорения положительное, то
    if (dir > 0) {
      // Возвращаем новое рандомное значение для ускорения
      // со знаком минус, т.е. отрицательное
      return -this.getRandom()
    // Если значение ускорения отрицательное, то
    } else {
      // Также возвращаем новое рандомное значение,
      // но уже положительное
      return this.getRandom()
    }
  }

Функция goalProcess() запускает процесс завершения матча после гола. Аргументом в нее передаются настройки игрока, который забил мяч.

  // Запускает процесс завершения матча после гола.
  // Сюда передается align игрока, который забил мяч
  goalProcess(winner) {
    winner.score++
    // Прибавляем к счету забившего +1 очко,
    // отчищаем старый счет со слоя 'score' и рисуем новый счет
    this.print.clear('score')
    this.print.drawScore()
    // Отчищаем слой 'text', чтоб удалить с центра поля
    // счетчик количества отбитий мяча в матче
    this.print.clear('text')
    // Обнуляем счетчик отбитий, чтоб в следующем матче
    // он пошел с нуля
    this.set.ballHitScore = 0
    // Вызываем функцию рисования Гола, она просто пишет
    // в центре надпись "Goal!" и рисует "+1" на поле соперника
    this.print.drawGoal(winner.goalPointX, winner.color, winner.align)
    // Вызываем метод reStart() класса Game, он остановит анимацию,
    // обнулит положение всех элементов и нарисует их заного
    this.game.reStart(winner.align)
  }

Для большего интереса, я создал функцию, которая увеличивает скорость мячика на 0.1 каждый раз, когда он отражается от платформы игрока.

Она также прибавляет 1 к счетчику ударов мяча о платформу, так мы видим сколько раз игрокам удалось отбить мяч.

  // Увеличивает скорость мяча на 0.1    
  // и прибавляет 1 к счетчику ударов мяча о платформу.    
  // Отчищает слой 'text' и рисует заного  
  speedМagnifier() {  
    // Оператор += сначало прибавляет 0.1,        
    //а потом присваивает полученное значение    
    this.ball.speed += 0.1        
    // Оператор ++ прибавляет 1 к значению счетчика ударов
    this.set.ballHitScore++        
    // Отчищает слой 'text' и рисует новое значение счетчика
    // на игровом поле
    this.print.clear('text')
    this.print.drawBallHit()
  }

Далее идут довольно простые функции.

defaultSet() возвращает настройки мячика к дефолтным значениям, это требуется для перезапуска партии при забитии гола.

draw() просто вызывает функцию рисования мяча. Можно было обойтись без нее, вызывая рисование мяча напрямую из класса Printer везде где это необходимо, но так мне мой код нравится больше.

update() вызывает все функции, которые необходимо пересчитывать для отрисовки кадров, она вызывается в цикле анимации из класса Game.

  defaultSet() {
    // Ставит мячик на центр поля обнуляя координаты
    this.ball.x = this.set.ballXDefault
    this.ball.y = this.set.ballYDefault
    // Возвращает исходную скорость
    this.ball.speed = this.set.ballSpeed
  }
  
  // Функция отправляет запрос на отрисовку мячика.
  // Создана для удобства
  draw() {
    this.print.drawBall()    
  }
  
  // Функция вызывает функции проверки столкновений,
  // движения мячика и отрисовки. Создана для удобства,
  // вызывается из метода timeLoop() в классе Game
  update() {
    this.checkCollisionWithWalls()
    this.move()
    this.draw()
  }
}
Наверх

player.js

Модуль отвечает за платформу игрока, ее положение и взаимодействие с мячиком. Класса Player создается два, и в каждый передаются свойства разных игроков, это делается в классе Game.

Для начала рассмотрим конструктор класса Player:

export default class Player {
  // В Player мы передали весь класс Game и отдельно свойства игрока
  constructor(Game, playerSet) {
    // Передаем переменной set общие настройки     
    this.set = Game.set
    // Для удобства, выделим в отдельную переменную ball
    // все изменяемые настройки мячика, а через classBall,
    // мы будем получать доступ к классу Ball и его методам
    this.ball = Game.set.ball
    this.classBall = Game.ball
    // Для доступа к модулю Printer
    this.print = Game.print
    // Переменной player присваиваем свойсва игрока переданные
    // в класс Player, уникальные для каждого экземпляра Player
    this.player = playerSet
    // Создаем новый Map (это коллекция ключ/значение) из keys.
    // В данном случаем ключ - это номер клавиши, 
    // а значение - это строка с направлением движения
    // Пример => keys: [[87,'up'], [83,'down']], 
    this.keyMap = new Map(playerSet.keys)
    // Создаем два слушателя событий. В них передаем событие
    // (нажатие или отжатие клавиши) и слушателя
    document.addEventListener('keydown',
                             (e) => this.keyController(e, true))
    document.addEventListener('keyup',
                             (e) => this.keyController(e, false))
    // Две переменные, которые нужны для виртуального
    // расширения платформ во время движения, чтоб был шанс
    // отбить мяч "в последний момент" ╰(*°▽°*)╯
    // то как это работает, покажет функция support()
    this.shadowUp = 0
    this.shadowDown = 0
    // Переменная служит индикатором нахождения мяча в желтой зоне.
    // Это зона перед платформой.
    // Желтую зону также покажет функция support()
    this.yellowZone = true
    // Переменная служит для запрета разворота мячика, чтоб он
    // не мог менять направления чаще чем каждые полсекунды
    // используется в функции checkCollisionWithBall()
    this.ballReversStatus = true
  }

Функции ниже занимаются движением игроков. keyController() отрабатывает нажатия с клавиатуры, а move() выполняет движения в зависимости от нажатых клавиш.

Нужно помнить, что ось координат в JavaScript начинается с верхнего левого угла, как показано на рисунке ниже. Следовательно верхняя и левая стенки будут иметь координаты 0, а правая и нижняя равны длине и ширине поля.

  keyController(e, state) {
    // Метод has() показывает существует ли элемент
    // с указанным значением в объекте
    // И если нажата клавиша, которая есть в keyMap,
    // то он выдает true
    if(this.keyMap.has(e.keyCode)) {
      // get() возвращает связанный с ключем элемент, он
      // вернет 'up' или 'down' в зависимости от нажатой клавиши.
      // Создаем переменную с именем результата метода get()
      // и присваиваем ей статус true или false
      this[this.keyMap.get(e.keyCode)] = state
    }
  }
  
  // Двигает платформу игрока, прибавляя 1 к его координатам,
  // умножая на коэффециент скорости
  move() {
    // Переменные созданы только для уменьшения ширины кода,
    // чтоб он влез в статью без горизонтальной прокрутки¯\_(ツ)_/¯
    const plHeight = this.set.playerHeight
    const plSpeed = this.set.playerSpeed
    const plBorder = this.set.playerBorder
    const boxHeight = this.set.boxHeight
    
    // Если this.up = true, т.е. клавиша 'вверх' нажата, то
    if (this.up) {
      // Бордер это растояние от задней стены, до центра игрока.
      // На такое же растояние платформы 'недоезжают' до краев поля.
      // Если растояние от Y игрока (это верхний край платформы)
      // больше чем бордер, то
      if (this.player.y > plBorder) {
        // Мы уменьшаем текущую координату Y на скорость 
        // платформы игрока из настроек (по умолчанию это 10).
        // Т.е. двигаем игрока вверх на 10 пикселей
        this.player.y -= plSpeed
      // Если платформа достигла ограничения или перескочила его
      // (это возможно т.к. платформы движуться по 8 пикселей), то  
      } else {
        // Мы возвращаем платформу в последнее возможное положение,
        // на растояние бордера от края поля
        this.player.y = plBorder  
      }
      // Если this.up = true, т.е. клавиша 'вверх' нажата, то
      // присваиваем переменной shadowUp двойное значение скорости
      // это 16 пикселей
      this.shadowUp = (plSpeed * 2)
    }
    // Если this.down = true, т.е. клавиша 'вниз' нажата, то
    else if (this.down) {
      // Т.к. игрок это верхняя точка платформы, надо прибавить
      // длину игрока, для получения координаты нижней его точки
      if ((this.player.y + plHeight + plBorder) < boxHeight) {
        // Мы увеличиваем текущую координату Y на скорость 
        // платформы игрока из настроек (по умолчанию это 10).
        // Т.е. двигаем игрока вниз на 10 пикселей
        this.player.y += plSpeed
      } else {
        // Возвращаем платформу в последнее возможное положение,
        // на растояние бордера от нижнего края поля
        this.player.y = (boxHeight - plHeight - plBorder)
      }
      // Если this.down = true, т.е. клавиша 'вниз' нажата, то
      // присваиваем переменной shadowDown двойное значение скорости
      // это 16 пикселей
      this.shadowDown = (plSpeed * 2)
    // Если клавиши не нажаты, возвращаем нашу "тень" в ноль  
    } else {
      this.shadowUp = 0
      this.shadowDown = 0
    }
  }

функция checkYellowZone() проверяет находится ли мячик в желтой зоне перед игроком и присваивает переменной yellowZone значения true или false в зависимости от результата проверки.

Если мячик был отбит платформой находясь в желтой зоне, значит он был отбит плоскостью платформы. А если он задел платформу, но не был в желтой зоне, то значит это было ребро платформы.

  checkYellowZone() {
    // Длина игрока, растояние от его верхней до нижней точки  
    const plHeight = this.set.playerHeight
    
    // Если Y мячика больше (мячик ниже) верхней точки игрока
    // и Y мячика меньше (мячик выше) нижней точки игрока
    if (this.ball.y > (this.player.y - this.shadowUp)
    && this.ball.y < (this.player.y + plHeight + this.shadowDown)) {
      // Значит мячик находится перед игроком, в желтой зоне
      this.yellowZone = true
    // Если нет, то не в желтой ¯\_(ツ)_/¯  
    } else {
      this.yellowZone = false
    }
  }  

Функция checkCollisionWithBall() отвечает за столкновение мяча с платформой. Она вычисляет разницу между координатами x и y объектов, а затем вычисляет их фактическое расстояние d. И если сумма радиусов объектов меньше, чем расстояние между ними, значит имело место столкновение этих объектов и можно производить нужные нам действия, с помощью функции hitBall().

Формулу расчета столкновений я взял из этой статьи на хабре.

  checkCollisionWithBall() {
    // Длина игрока, растояние от его верхней до нижней точки
    const plHeight = this.set.playerHeight
    // Вычисляем разницу между координатами X мячика и Y игрока
    let dx = this.ball.x - this.player.x
    // Разница между Y мячика и Y игрока (верхним краем игрока),
    // также учитываем тень, если она есть
    let dy = this.ball.y - (this.player.y - this.shadowUp)
    // Разница координаты Y мячика и нижнего края игрока,
    // с учетом его тени, если она есть
    let dyF =this.ball.y -(this.player.y + plHeight + this.shadowDown)
    // Сумма радиусов мячика и платформы
    let radSum = this.set.ballRadius + this.set.playerRadius
    // Растояние от центра мячика, до края платформы.
    // Math.sqrt() вычисляет квадратный корень, а
    // Math.pow() возводит значение dx в степень 2 (в квадрат)
    let dY = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2))
    // Растояние от центра мячика, до нижнего края платформы
    let dYF = Math.sqrt(Math.pow(dx, 2) + Math.pow(dyF, 2))
    // Убрал из расчета кординату Y, чтобы видеть столкновение
    // координат Х независимо от положения мячика по Y.
    // Это надо для расчета удара о плоскость платформы
    let dX = Math.sqrt(Math.pow(dx, 2))
    
    // Если растояние между центрами объектов меньше суммы их
    // радиусов (в данном случае это Х мячика и Х платформы), то
    if (dX <= radSum) {
      // Если мячик находится в желтой зоне
      // и он не менял направление последние полсекунды, то
      if (this.yellowZone && this.ballReversStatus) {
        // Вызываем функцию hitBall() для разворота мяча
        // и передаем в нее только значение dx, т.к в желой зоне
        // мячик отбивается от плоскости платформы только по оси Х
        this.hitBall(this.ball.dx)
      }
    }
    // Если ускорение мячика положительное, т.е. мячик летит вниз, то
    if (this.ball.dy > 0) {
      // Если есть столкновение с верхним краем платформы, то
      if (dY <= radSum) {
        // Если мячик не в желтой зоне, то
        if (!this.yellowZone) {
          // Вызываем функцию hitBall() для разворота мяча по обои
          // осям, мячик развернутся на 180°
          this.hitBall(this.ball.dx, this.ball.dy)
        }
      }
    }
    // Если ускорение мячика отрицательное, т.е. мячик летит вверх, то
    if (this.ball.dy < 0) {
      // Если есть столкновение с нижним краем платформы, то
      if (dYF <= radSum) {
        // Если мячик не в желтой зоне, то
        if (!this.yellowZone) {
          // Вызываем функцию hitBall() для разворота мяча по обои
          // осям, мячик также развернутся на 180°
          this.hitBall(this.ball.dx, this.ball.dy)
        }
      }
    }
  }
  
  // Аргументом мы передаем только dx или dx и dy мячика
  hitBall(dx, dy) {
    // Разворачиваем мячик по оси Х с помощью метода reverseBall().
    // Он всегда разворачивается по оси Х при ударе о платформу
    this.ball.dx = this.classBall.reverseBall(dx)
    
    // Если мы передали в функцию значение dy, то
    if (dy) {
      // Разворачиваем мячик по оси Y. Это удар о ребро платформы
      this.ball.dy = this.classBall.reverseBall(dy)
    }
    // Т.к. мячик отбит платформой, запускаем функцию,
    // которая увеличит его скорость
    this.classBall.speedМagnifier()
    // Запрещаем мячику разворачиваться
    this.ballReversStatus = false
    // А через 500 милисекунд разрешаем мячику разворачиваться.
    // Такое поведение нужно, чтоб он не мог застрять в платформе
    // постоянно разворачиваясь
    setTimeout(() => {
      this.ballReversStatus = true
    }, 500)
  }

Далее идут довольно простые функции.

defaultSet() возвращает положение игрока к дефолтному значению, это требуется для перезапуска партии при забитии гола.

draw() просто вызывает функцию рисования игрока. Можно было обойтись без нее, вызывая рисование напрямую из класса Printer везде где это необходимо, но так мне мой код нравится больше.

update() вызывает все функции, которые необходимо пересчитывать для отрисовки кадров, она вызывается в цикле анимации из класса Game.

  // Функция обнуляет положение игрока,
  // так как X в процессе игры не меняется, а вторая координата Y
  // вычисляется из первой, достаточно обнулить только Y
  defaultSet() {
    this.player.y = this.set.playerYDefault
  }
  
  draw() {
    // Кордината верхней точки игрока
    let x = this.player.x
    let yStart = this.player.y
    // Кордината нижней точки игрока
    let yFinish = (this.player.y + this.set.playerHeight)
    // Цвет и толщина игрока (2 радиуса)
    const plColor = this.player.color
    const plWidth = (this.set.playerRadius * 2)
    
    // Т.к. игрок это векторная линия с закругленными краями,
    // передаем в принтер кординаты верхней и нижней точки,
    // толщину линии (2 радиуса) и цвет
    this.print.drawPlayer(x, yStart, yFinish, plWidth, plColor)
  }
  
  // Функция вызывает функции проверки желтой зоны, столкновений,
  // движения игрока и отрисовки. Создана для удобства.
  // Вызывается из метода timeLoop() в классе Game
  update() {
    this.checkYellowZone()
    this.checkCollisionWithBall()
    this.move()
    this.draw()
  }

Функция support() занимается визуальной отрисовкой желтой зоны для проверки ее работы. Вызывается из класса Game.

  support() {
    const plHeight = this.set.playerHeight
    let x = this.player.x        
    let yS = this.player.y - this.shadowUp
    let yF = this.player.y + this.set.playerHeight + this.shadowDown
    
    this.print.drawShadowPlayer(x, yS, yF)
    if (this.yellowZone) {
      this.print.drawYellowZone(x, yS, yF)
    }
  }
}
Наверх

index.html

В теге <body> прописан id="game" для создания канваса. Хотя можно было попробовать обойтись без него и посылать canvas прямо на тег <body>. Внутри тега находиться только скрипт.

<!DOCTYPE html>
<html lang="en">
<head>    
  <meta charset="UTF-8">    
  <title>Pong by Buninman.ru</title>    
  <meta name="description" content="Created by Buninman.ru">    
  <meta property="og:title" content="The best Pong game">    
  <meta property="og:image" content="img/pongOG.png">    
  <meta property="og:description" content="Created by Buninman.ru">  
    <meta http-equiv="expires" content="0">    
  <link rel="stylesheet" href="pong/style.css">    
  <link rel="apple-touch-icon" sizes="180x180" href="img/icon.png"> 
  <link rel="icon" type="image/png" sizes="32x32" href="img/fv32.png">
  <link rel="icon" type="image/png" sizes="16x16" href="img/fv16.png">
</head>

<body id="game">    
  <script src="pong/game.js" type="module"></script>
</body>

</html>
Наверх

style.css

В css минимальный набор стилей. Мы делаем весь <body> одним большим флекс-боксом во весь экран.

body {
  margin: 0px;    
  height: 100vh;    
  width: 100vw;    
  display: flex;    
  justify-content: center;    
  align-items: center;    
  background-color: #232323;    
}

#game canvas {  
  display: block;    
  position: absolute;
}
Наверх