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

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

Повторил

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

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

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

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

Учитывая мой совсем маленький опыт программирования, чуть меньше 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
    // Толщина линий
    this.lineColor = '#232323'
    // Цвет линий. Темно-серый, как основной фон окна брузера в CSS
    this.textColor = '#EBEBEB'
    // Светло-серый цвет, используется для текста таймера и бегунка
    this.supportColorRed = '#FA0556'
    this.supportColorYellow = '#FAC405'
    // Вспомогательные красный и желтый цвета,
    // используются для подсветки технических нюансов

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

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

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

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

Наверх


canvas.js

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

export default class Canvas {
  constructor(setting) {
    this.set = setting
    // Передаем переменной set общие настройки
    this.canvas = document.createElement('canvas')
    // Создаем элемент canvas и переменную для доступа к нему
    this.ctx = this.canvas.getContext('2d')
    // Создаем в канвасе 2d-контекст, нужен для рисования фигур
    this.canvas.width = this.set.boxWidth
    this.canvas.height = this.set.boxHeight
    // Задаем канвасу высоту и ширину
    document.querySelector('#game').appendChild(this.canvas)
    // Находим в html тег game (id="game") и как дочерний эллемент
    // создаем в нем наш 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'
    // Указываем, что линия будет с закруглениями на концах
    this.ctx.beginPath() 
    // 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) {
    this.ctx.beginPath()
    // 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) {
    this.ctx.beginPath()
    // beginPath() начинает вектор
    this.ctx.arc(x, y, radius, 0, Math.PI * 2)
    // Создаем арку. Агругументами выступают координаты
    // центра окружности, радиус, начальный угол в радианах
    // и конечный угол в радианах.
    // Math.PI*2 это число Пи умноженное на 2, дает замкнутый круг. 
    this.ctx.fillStyle = fillColor
    // Указываем цвет заливки
    this.ctx.fill()
    // Создаем заливку
    if (stroke) {
    // Если нам не нужна обводка, то аргументам мы передаем false,
    // а по умолчанию обводка есть
      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)
    
    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

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

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

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

export default class Printer {
  constructor(setting) {    
  // В принтер передаем только настройки
    this.set = setting
    this.ball = setting.ball
    // Передаем общие настройки переменной set,
    // а переменной ball настройки мячика, для удобства
    
    this.canvas = new Map([        
    // Создаем новый Map с пятью классами Canvas под разные нужды,
    // порядок будущих слоев влияет на их видимость, первый будет 
    // перекрывать вторым и т.д.        
      ['background', new Canvas(this.set)],            
      // На слое background рисуется неподвижные и необновляемые
      // элементы игры. Игровое поле            
      ['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)]            
      // Слой для игровых элементов с постоянной перерисовкой,       
      // таких как мячик и игроки            
      ])        
    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')        
    // Для сокращения ширины кода, помещаю пути обращения
    // к Canvas в переменные, можно этого не делать
    
    this.ballDirectionAngle = 0        
    // Переменная ballDirectionAngle нужна передачи значения угла,
    // под которым требуется рисовать белый бегунок, отображающий    
    // направление броска мячика    
  }

Функция ниже рисует игровое поле, это фон с закругленными краями и разные темные линии. Рисуется на специальном слое канваса - '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)
    // Рисуем вертикальную линию посередине
    
    this.bgCan.drawCircle((width / 2), (height / 2),
                                    (height / 4), boxColor)
    // Рисуем круг посередине, с радиусом в 1/4 высоты поля
    
    this.bgCan.drawLine(plSpace, plBorder, plSpace,
                             (height - plBorder), lineW, lineColor)
    this.bgCan.drawLine((width - plSpace), plBorder,
          (width - plSpace), (height - plBorder), lineW, lineColor)
    // Рисуем 2 линии под игроками с отступами от края
  }

Функция drawBriefing() рисует инструкцию с клавишами управления.

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

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

  drawBriefing() {
    const plLColor = this.set.playerL.color
    const plRColor = this.set.playerR.color
    // Цвета левого и правого игроков
    const controlXL = (this.set.playerSpace * 2)
    const controlXR = this.set.boxWidth - (this.set.playerSpace * 2)
    const controlY = (this.set.boxHeight / 17)
    // Координаты x и y для текста с инструкцией
    // Немного сложно, но все координаты отталкиваются от статичных
    // значений и масштабируются с игровым полем

    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')
    // Параметр 'left' рисует текст справа от точки координат,
    // а параметр '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
    // Значение количества очков каждого игрока
    const scoreXL = (this.set.boxWidth / 9 * 4)
    const scoreXR = (this.set.boxWidth / 9 * 5)
    const scoreY = (this.set.boxHeight / 20)
    // Координаты x и y для отбражения очков игроков
    // отталкиваются от статичных значений ширины и высоты поля
    
    this.scoreCan.drawText(plLScore, scoreXL, scoreY, '40px',
                                         plLColor, 'right', 'top')
    this.scoreCan.drawText(plRScore, scoreXR, scoreY, '40px',
                                          plRColor, 'left', 'top')
    // Рисуем текст счета. Для каждого игрока свой цвет,
    // и свое выравнивание
  }

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

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

  drawBallDirection(int = 2) {   
  // В функцию передается переменная int, которая содержит
  // корректировщик коээфициента значения угла. Она может иметь
  // значение 2 для таймера после гола и 4 для начального таймера

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

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

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

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

  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)
  } 
  
  drawPlayer(xS, yS, yF, lineWidth, color) {    
  // Рисует игрока используя его текущее местоположение.    
  // Принимает координату Х и две координаты Y, верхнюю и нижнюю    
  // Рисует между ними линию. Также принимает толщину линии
  // и цвет игрока
    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)
  }
  
  drawYellowZone(x, yS, yF) {    
  // Если мячик находится в желтой зоне (перед игроком),
  // то он отскакивает от координаты X игрока. Тут мы подсвечиваем
  // эту желтую зону
    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 отвечает за запуск самой игры. В нем создаются почти все классы и запускается цикл отрисовки кадров анимации. Он также является хабом, через который модули взаимодействую друг с другом.

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

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

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

  firstLaunch() { 
    this.print.drawBackground()        
    // Рисуем игровое поле    
  
    this.support()        
    // Вспомогательные функции. Вызвав ее здесь, мы увидим
    // 4 возможных направления для полета мячика
  
    this.playerL.draw()        
    this.playerR.draw()        
    // Рисуем игроков
  
    this.print.drawScore()        
    // Рисуем цифры счета, они пока равны 0        
  
    this.print.drawBriefing()        
    // Функция drawBriefing() рисует инструкцию по управлению        
  
    this.ball.dropBall()        
    // dropBall() выбирает рандомное направление для мячика        
  
    this.print.drawBallDirection(4)        
    // drawBallDirection(4) проверяет какое направление для мяча 
    // выбрано и запускает цикл рисования белой полосочки, 
    // бегающей по кругу
  
    this.print.centerText('3')        
    // Рисуем цифру 3. Передаем значение в метод класса Print        
  
    setTimeout(() => {
      this.print.clear('text'),
      this.print.centerText('2') }, '800')
      // Отчищаем слой с цифрой 3 и рисуем цифру 2
      // с помощью встроенной в JS функции setTimeout(),
      // делаем задержку выполнений блока {} кода в 0.8 секунд, 
      // или '800' милисекунд
  
    setTimeout(() => {            
      this.print.clear('text'),            
      this.print.centerText('1') }, '1600')            
      // Каждое следующее действие происходит еще на '800' милисекунд
      // позднее предыдущего        
  
    setTimeout(() => {
      this.print.clear('text'),
      this.print.centerText('Go')}, '2400')
      // Стираем цифру и пишем "Go"
  
    setTimeout(() => {
      this.print.clear('text'),
      this.print.clear('other')
      this.start(this.reqId) }, '3200')
      // На последней функции отчищаем слои и запускаем игру.
      // В функцию start() передаем переменную this.reqId, 
      // значение которой изначально стоит true
    }

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

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

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

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

  reStart(align) {
    this.reqId = false        
    // присваиваем reqId значение false, это останавливает анимацию  
        
    setTimeout(() => {        
    // Делаем задержку в 0.8 секунд, и выполняем следующее:
      this.print.clear('gamelayer')            
      // Отчищаем игровой слой            

      this.playerL.defaultSet()            
      this.playerR.defaultSet()            
      this.ball.defaultSet()            
      // Возвращаем игрокам и мячику значения позиций по умолчанию   

      this.playerL.draw()
      this.playerR.draw()
      this.ball.draw()
      // Снова рисуем игроков и мячик, уже в стартовых позициях   

      this.support()            
      // Вспомогательные функции. Вызвав ее здесь, мы увидим
      // 4 возможных направления для полета мячика

      this.ball.dropBall(align)            
      // dropBall() выбирает рандомное направление для мячика 
      // Значение align укажет направление броска в забившего
      // предыдущий гол. Рандомость будет заключатся только в 
      // напрвлении вверх или вниз

      this.print.drawBallDirection()            
      // Функция запускает белый бегунок по направлению,
      // определенному выше в dropBall()

    }, '800')
    setTimeout(() => {
    // Следующие действия произойдут уже через 1.6 секунды
      this.print.clear('other')
      // Отчищаем слой other, это удалит белый бегунок с экрана
      this.reqId = true
      this.start(this.reqId)
      // Снова присваиваем reqId значение true
      // и перезапускаем игровой цикл
      // Эти действия произойдут уже через 1.6 секунды,
      // после предыдущего setTimeout()
    }, '2400')
  }

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

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

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

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

Наверх


ball.js

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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()    
  }
  
  update() {    
  // Функция вызывает функции проверки столкновений,
  // движения мячика и отрисовки. Создана для удобства,
  // вызывается из метода timeLoop() в классе Game
  this.checkCollisionWithWalls()
  this.move()
  this.draw()
  }
}

Наверх


player.js

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

export default class Player {
  constructor(Game, playerSet) {
  // В Player мы передали весь класс Game и отдельно свойства игрока
    this.set = Game.set
    // Передаем переменной set общие настройки        

    this.ball = Game.set.ball
    this.classBall = Game.ball
    // Для удобства, выделим в отдельную переменную ball
    // все изменяемые настройки мячика, а через classBall,
    // мы будем получать доступ к классу Ball и его методам

    this.print = Game.print
    // Для доступа к модулю Printer

    this.player = playerSet
    // Переменной player присваиваем свойсва игрока переданные
    // в класс Player, уникальные для каждого экземпляра Player 

    this.keyMap = new Map(playerSet.keys)
    // Создаем новый Map (это коллекция ключ/значение) из keys.
    // В данном случаем ключ - это номер клавиши, 
    // а значение - это строка с направлением движения
    // Пример => keys: [[87,'up'], [83,'down']],

    document.addEventListener('keydown',
                             (e) => this.keyController(e, true))
    document.addEventListener('keyup',
                             (e) => this.keyController(e, false))
    // Создаем два слушателя событий. В них передаем событие
    // (нажатие или отжатие клавиши) и слушателя

    this.shadowUp = 0
    this.shadowDown = 0
    // Две переменные, которые нужны для виртуального
    // расширения платформ во время движения, чтоб был шанс
    // отбить мяч "в последний момент" ╰(*°▽°*)╯
    // то как это работает, покажет функция support()

    this.yellowZone = true
    // Переменная служит индикатором нахождения мяча в желтой зоне.
    // Это зона перед платформой.
    // Желтую зону также покажет функция support()
    
    this.ballReversStatus = true
    // Переменная служит для запрета разворота мячика, чтоб он
    // не мог менять направления чаще чем каждые полсекунды
    // используется в функции checkCollisionWithBall()
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

  defaultSet() {
  // Функция обнуляет положение игрока,
  // так как X в процессе игры не меняется, а вторая координата Y
  // вычисляется из первой, достаточно обнулить только Y
    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)
    // Кордината нижней точки игрока
    const plColor = this.player.color
    const plWidth = (this.set.playerRadius * 2)
    
    this.print.drawPlayer(x, yStart, yFinish, plWidth, plColor)
    // Т.к. игрок это векторная линия с закругленными краями,
    // передаем в принтер кординаты верхней и нижней точки,
    // а также толщину линии (2 радиуса) и цвет
  }
  
  update() {
  // Функция вызывает функции проверки желтой зоны, столкновений,
  // движения игрока и отрисовки. Создана для удобства.
  // Вызывается из метода timeLoop() в классе Game
    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;
}

Наверх

Спасибо за чтение!

Заходите на мой телеграм канал

Комментарии (11)


  1. scronheim
    08.11.2022 07:00
    +1

    setTimeout(() => {
    this.ballReversStatus = true
    }, '500')

    Я видел что Вы недавно начали программировать, поэтому чисто ради развития
    https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#non-number_delay_values_are_silently_coerced_into_numbers


    1. Buninman Автор
      08.11.2022 10:44

      Спасибо! Исправлюсь)

      Такие ошибки вылазят когда учишься непонятно у кого из интернета))


  1. zhuk15
    08.11.2022 10:34

    Видимо игра для спортсменов азиатов


    1. Buninman Автор
      08.11.2022 10:55

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


  1. Dredlock
    08.11.2022 10:34

    Смотрится красиво.

    Если не секрет, сколько времени ушло на разработку?


    1. Buninman Автор
      08.11.2022 10:38

      Спасибо)

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


  1. khegay
    08.11.2022 13:40

    Все клево, но комментарии внизу?


    1. Buninman Автор
      08.11.2022 15:05

      Мне показалось так логичнее¯\_(ツ)_/¯ Прочитал строку кода и затем пояснение к ней



  1. elishagg
    09.11.2022 14:59

    Супер. Есть правда вопросы к неймингу переменных некоторых, но в остальном просто пушка.


    1. Buninman Автор
      09.11.2022 15:01

      Благодарю!

      Думаю, вопросы к неймингу чужих переменных есть всегда))) Но тут я согласен, некоторые мне самому не нравятся, но ничего лучше не придумал в тот момент. ༼ つ ◕_◕ ༽つ