Всем привет! Поделюсь с Вами моим первым опытом в создании двумерных браузерных игр. В деле этом я новичок, поэтому прошу не судить строго. Статья рассчитана в основном на изучающих JavaScript, а также тех, кто, как и я, делает первые шаги в мир игровой индустрии. Статья представляет собой перевод одного англоязычного видеоурока. Если Вы хорошо владеете английским и Вам больше нравится видеоформат подачи материала — можете посмотреть видео. В статье же я буду вставлять участки кода и стараться также подробно как   и автор видео — объяснять каждый свой шаг. От Вас требуются лишь желание, базовое представление — для чего нужны HTML, CSS и JavaScript, редактор кода и более-менее современный компьютер с браузером. На самом деле знание HTML и CSS особо не понадобится, т.к. почти весь код будет написан на JavaScript. Поиграть в игру можно здесь, а посмотреть исходники тут. Создание проекта Классы Game и Player. Анимационный цикл Обработка нажатий клавиш Снаряды Периодические события. FPS Визуализация боеприпасов Создаем врагов. Наследование Обработка столкновений (коллизий) Очки. Жизни. Условие победы Игровой таймер Заключение Автор видео пишет весь JavaScript код в одном единственном файле script.js. Хотя я по началу делал точно также, чтобы быстрей вносить доработки и синхронизировать с ним свои действия, в этой статье я разобью весь код на отдельные файлы и папки. Создадим папку проекта и пустые js-файлы со следующей структурой: Папки выделенные красным — содержат всю необходимую графику (спрайт-шиты), с помощью которой мы будем анимировать наших персонажей. Можете скачать файлы .png из репозитория и добавить их в соответствующие разделы. В index.html, style.css и script.js добавим нижеприведенный код:    В index.html мы добавили элемент canvas (далее — игровое "полотно", полотно, канвас, холст и т.п.), на котором и будет происходить все действо игры. Данный элемент является краеугольным камнем как нашей, так и многих браузерных игр. Все действия наших персонажей: столкновения, взрывы, физику мы будем программировать на JavaScript путем отрисовывания тех или иных элементов на данном полотне. Также в html-файле в нашу игру мы "подтянем" всю необходимую графику (в тэгах img) и подключим скрипты (тэг script). В файле style.css присвоим нашему полотну "абсолютную позицию", выровняем его по центру, добавим рамку и т.д. А также всем img-элементам установим свойство display в значение none, чтобы скрыть их отображение в окне браузера. Если не понятно для чего это нужно — можете удалить этот блок из файла style.css, либо какой-то конкретный элемент, например, #player, и посмотреть что из этого выйдет. P.S> также мы подключили для нашей игры экзотический гугл-шрифт "Bangers", о чем говорят соответствующие строки. О том как это сделать — будет отдельный раздел. В файле script.js напишем обработчик события load, которое срабатывает после загрузки всех ресурсов (картинок, скриптов, стилей и т.д.). Весь код игры будет находиться внутри данного обработчика. В строках 3 и 4 получим объект canvas, который ранее определили в html-файле и определим глобальную переменную контекста ctx. Переменная ctx содержит все необходимые методы для отрисовки необходимых нам элементов на игровом полотне. В строках 5 и 6 установим ширину и высоту игрового поля, соответственно. После выполнения всех вышеописанных процедур наше игровое поле будет выглядеть следующим образом: Приступим к кодированию! В файле Player.js создадим класс игрока: Про классы, пожалуй, я не буду подробно рассказывать, т.к. на эту тему написано и отснято довольно много материала. На интуитивном уровне я думаю можно понять, что это "тип данных", который представляет нашего главного персонажа (в будущем — механизированный морской конь), у которого есть свои свойства и поведение. Все классы нашей игры должны будут иметь доступ к экземпляру класса Game, чтобы знать о ее состоянии, поэтому в конструктор класса Player передаем объект игры. Помимо этого, класс игрока включает в себя свойства width и height — ширина и высота, в пикселях, нашего персонажа, а также координаты его стартовой позиции на игровом полотне — свойства x и y (начало координат находится в левом верхнем углу). Свойство speedY будет отвечать за скорость перемещения (двигаться наш игрок сможет только по вертикали). Метод update() нужен для обновления состояния нашего игрока, а draw() — для отрисовки персонажа на игровом полотне. Пока в теле метода update() реализуем лишь изменение скорости движения. А в метод draw() добавим код, который рисует черный прямоугольник вместо нашего игрока. Класс Game имеет следующий вид: width и height представляют собой ширину и высоту игрового поля. Код new Player(this) создает для нас экземпляр (инстанс) нашего игрока, которого мы помещаем в свойство player. Отметим, что в качестве параметра в конструктор класса Player мы передаем здесь объект this — это ключевое слово говорит нам о том, что мы таким образом ссылаемся на текущий (этот) класс игры. А значит наш игрок будет также в курсе о всех изменениях состояния самой игры (игра завершена, остановлена, на паузе, время до окончания игры и т.д.). Методы update() и draw() класса Game будут обновлять и рисовать, соответственно, все элементы игры: персонажей, взрывы, летящие "пули", разлетающиеся частицы от уничтоженных врагов и т.п. Отметим, что в методы draw во всех классах мы будем всегда передавать глобальную переменную контекста ctx, которую создали в предыдущем разделе. В script.js добавим следующий код: Здесь мы создаем экземпляр класса игры, передавая ему ширину и высоту канваса. Ключевым моментом игры является создание т.н. бесконечного анимационного цикла (animation loop). Для этого используется метод requestAnimationFrame(). Простыми словами, — этот метод посылает браузеру сигнал (1 раз!), чтобы тот выполнил перерисовку всего окна. Метод requestAnimationFrame() принимает в качестве аргумента функцию, которую нужно выполнить непосредственно перед перерисовкой. Для этого мы создали функцию animate(). А для того, чтобы наш цикл получился бесконечным — необходимо поместить метод requestAnimationFrame() внутрь функции animate(), таким образом мы и создадим бесконечный анимационный цикл, который будет обновлять состояние нашей игры. Также в теле функции animate() мы очищаем игровое поле перед следующей отрисовкой и выполняем подряд методы draw() и update(). Скорость получившейся "анимации" зависит от мощности вашего компьютера, но в среднем составляет примерно 60 обновлений в секунду. Мы еще вернемся к методу animate() и дополним его для регулирования скорости анимации и создания т.н. "периодических событий". На данный момент "игра" должна выглядеть следующим образом: Дадим возможность двигаться нашему игроку. Как я уже говорил — двигаться наш игрок будет только по вертикали. В файл InputHandler.js добавим следующий код: а в конструкторе класса Game определим два новых свойства: Класс InputHandler будет отвечать за обработку событий нажатия (keydown) и отпускания (keyup) клавиш. Первый из них будет смотреть – какую клавишу нажал пользователь и, если это клавиша "вверх" или "вниз", обработчик будет добавлять эту клавишу в массив this.game.keys. Условие && this.game.keys.indexOf(e.key) === -1 запретит добавлять в массив клавишу, если та уже присутствует в нем. Обработчик события отпускания (keyup) — наоборот, сначала проверяет наличие клавиши в массиве и при ее наличии там — удаляет с помощью метода splice. В класс Player добавим вспомогательное свойство maxSpeed, а в методе update() реализуем условия изменения скорости — с помощью метода includes() проверим наличие в массиве клавиш "вверх"/"вниз" и в зависимости от этого поменяем знак скорости, заставив "игрока" двигаться в том или ином направлении: Чтобы наш игрок не мог полностью "выйти" за границу игрового поля — добавим в метод update() следующее условие: Коэффициент 0.5 позволит выходить за границу только наполовину. Чтобы наш игрок мог стрелять выполним следующие действия. Первым делом создадим класс Projectile (файл Projectile.js): x и y — это стартовая позиция снаряда на игровом поле (откуда он появляется); width и height — его ширина и высота; speed — скорость полета; markedForDeletion — флаг для "удаления" снаряда, — необходим для удаления снаряда с игрового поля, если, например, снаряд сталкивается с противником, либо вылетает за обозначенные границы. В методе update() заставляем снаряд лететь вправо путем увеличения координаты x на величину скорости, а также заставляем исчезать снаряд как только он пролетает 80% игрового поля (чтобы появляющиеся в будущем враги могли иметь фору и не получать сразу ранения). В методе draw() окрашиваем снаряд в желтый цвет и рисуем его на поле с помощью метода fillRect(). Но это еще не все...! В класс Game добавим свойство ammo (количество боеприпасов), чтобы у игрока был лимит: В классе Player определим свойство projectiles — массив снарядов: В методе update() класса Player: с помощью метода forEach() пробежим по всем боеприпасам и вызовем у каждого метод update() (чтобы заставить их двигаться), а с помощью метода filter() удалим те, у которых флаг markedForDeletion = true. в метод draw() класса Player добавим такие строки: т.к. в классе Projectile мы определили цвет заливки контекста как желтый, то теперь необходимо явно поменять цвет игрока на черный, иначе он тоже будет желтым. Ну и вызовем также с помощью метода forEach() для каждого снаряда метод draw().  Реализуем в классе игрока метод shootTop(): здесь мы проверим наличие боеприпасов (this.game.ammo > 0) и если снаряды есть — добавим один снаряд в массив, в то же время уменьшив на единицу свойство this.game.ammo. Заметим, что стартовую позицию снаряда мы сдвинули правее на 80 и вниз на 30 пикселей (чтобы в будущем они вылетали из носа морского коня). Осталось добавить в обработчик события нажатия (keydown) вызов метода shootTop() при нажатии пробела: Теперь наш игрок умеет стрелять и имеет целых 20 патронов. Но как Вы видите, эти 20 патронов расходуются очень быстро и много врагов ими не уничтожить. А что, если мы сделаем так, чтобы оружие у нашего игрока перезаряжалось автоматически, т.е. раз в какой-то промежуток времени боеприпасы сами пополнялись, скажем по одному патрону в секунду. Отсюда также вытекает задача иметь достоверную визуальную информацию о количестве патронов в текущий момент, но об этом чуть позже. Немного доработаем основной скрипт нашей игры — script.js: Воспользуемся еще одной фичей метода requestAnimationFrame(). Данный метод передает в аргумент коллбэк-функции (которую он вызывает) момент времени, когда он собирается ее вызывать. В нашем случае коллбэк-функция — это функция animate(). Определим переменную lastTime — она будет хранить момент времени "прошлого" вызова анимационного цикла. В функцию animate() добавим аргумент currentTime. Именно в currentTime метод requestAnimationFrame() будет записывать момент времени "текущего" вызова. Разность между "текущим" и "прошлым" временем вызова функции animate() мы запишем в переменную deltaTime, чтобы затем передать ее функции game.update() в качестве параметра. Именно на основе этой разности мы и будем строить периодические события — перезарядку снарядов и т.п. Возможно, (я более чем уверен!), Вам, как и мне по началу, не очень понятны данные "махинации со временем". Но данная практика (с вычислением временной разности) является стандартной в разработке браузерных игр. По крайней мере так утверждает автор видео. Хотя это и логично, т.к. в зависимости от производительности Вашего компьютера — это значение может варьироваться, а значит необходимо чтобы игра могла "адаптироваться" к Вашей частоте смены кадров. После вычисления deltaTime и ее использования — нужно перезаписать переменную lastTime, присвоив ей значение "текущего" currentTime, чтобы на следующей итерации анимационного цикла мы нашли новую deltaTime с корректными значениями. Добавим (только в целях эксперимента, а потом удалим!) в тело функции animate() вывод значения deltaTime: в консоли браузера мы увидим эти разности: Если взять среднее значение — оно окажется, как и у автора видео, равным примерно 16.6 (миллисекунд). Это и есть разность между прошлой и текущей итерацией анимационного цикла. Поделив 1 секунду = 1000 мс на это значение — получим примерно 60. Т.е. как и было до этого сказано, метод requestAnimationFrame() перерисовывает окно со скоростью 60 раз в секунду 60 fps, frames per second). При вызове функции animate() передадим ей в качестве параметра нулевое значение — время "первого" вызова. Давайте же выведем на игровое полотно текущее количество патронов у нашего персонажа. Создадим класс UI, главной задачей которого будет вывод игровых статусов и сообщений (файл UI.js): Здесь я думаю пока все предельно понятно. Прокомментировать стоит лишь цикл, который рисует желтые прямоугольники, каждый из которых символизирует один патрон. Количество итераций в данном цикле равно текущему количеству боеприпасов this.game.ammo. В класс Game добавим несколько свойств. ammoInterval — интервал перезарядки, в миллисекундах (пусть будет 500)  — т.е. раз в полсекунды боеприпасы нашего игрока будет пополнять один новый патрон. Чтобы патроны не копились до бесконечности — введем свойство maxAmmo — максимальное количество патронов (сделаем равным 50); а также вспомогательное свойство ammoTimer, которое будет своего рода "временным счетчиком", меняющимся от 0 до значения ammoInterval. В метод update() класса Game добавим следующие строки: Теперь метод update() будет принимать в качестве параметра значение deltaTime, о котором мы подробно говорили в предыдущем разделе. Таким образом, на каждой итерации нашего анимационного цикла мы будем увеличивать значение ammoTimer на величину deltaTime . Как только ammoTimer превысит значение ammoInterval — мы сбросим ammoTimer в ноль и увеличим на единицу количество патронов (если оно меньше максимального количества maxAmmo). В конструкторе класса Game мы также создадим свойство — экземпляр класса UI: А в методе draw() вызовем соответствующий метод класса UI: Раз мы вооружили нашего персонажа — пришло время создать и врагов. Также в этом разделе поговорим о наследовании  — одной из трех ключевых концепций ООП. У нас будет несколько типов врагов, каждый со своими свойствами/поведением (количество жизней, урон наносимый главному персонажу при столкновении, либо бонусы от столкновения и т.п.). Но при этом будут и общие свойства/методы присущие всем врагам. Давайте создадим базовый класс Enemy, который затем будут наследовать производные классы: Свойство x — начальная x-координата появления врага, которая равна правой границе игрового поля; speedX — отрицательная скорость движения врага, которая лежит в полуинтервале (-4, -2.5] (чтобы он двигался справа налево и при этом скорости каждого врага немного отличались) и стандартное уже свойство-флаг markedForDeletion для удаления врагов. В методе update() мы обновляем x-координату нашего врага, а также помечаем его удаленным, если он полностью пересек левую границу игрового поля (это может случиться, если наш главный игрок не успел его уничтожить). В методе draw() установим цвет врага и нарисуем его (врага) в виде прямоугольника. Как вы видите, свойств color и height в данном классе нет, но они будут определены в производных классах, т.к. для каждого типа врага они будут иметь свои уникальные значения (какие-то враги будут красными, другие зелеными, и по высоте тоже будут различаться). Поэтому вызывать метод draw() имеет смысл только на экземплярах производного класса. Давайте создадим два типа врагов — Angler1 и Angler2:   Данные классы созданы с помощью ключевого слово extends (расширение), которое говорит, что эти классы являются производными (дочерними) от класса Enemy, а значит могут с легкостью использовать функционал базового класса (Enemy). Также в конструкторе этих классов используется ключевое слово super, которое в данном случае вызывает конструктор базового класса (Enemy), а затем уже определяются свойства, уникальные для данных классов. В классах Angler1 и Angler2 мы определили ширину и высоту (width и height), цвет (color). Свойство y — y-координата врага определена таким образом, чтобы враг не мог появиться вне пределов игрового поля. Осталось чуть-чуть доработать класс Game и мы увидим наших врагов на экране. Добавим следующие свойства в класс Game: где enemies — массив врагов; enemyTimer и enemyInterval — переменные, которые по аналогии с переменными ammoTimer и ammoInterval из предыдущего раздела помогут нам создавать одного врага в секунду, появляющегося с правой стороны игрового полотна. Также введем свойство gameOver, чтобы иметь признак окончания игры (если игра завершена, враги появляться уже не должны). В метод update() класса Game тоже внесем изменения: как видите — эти изменения уже стандартные. Сначала в "цикле" forEach() мы вызываем метод update() у каждого врага, затем убираем "удаленных". Последний if...else... комментировать не буду, т.к. в предыдущем разделе подобное условие уже обсуждали (здесь добавил условие && !this.gameOver, чтобы враги не появлялись после окончания игры). Метод по добавлению врагов addEnemy() реализуем через пару мгновений. В методе draw() нарисуем наших врагов: В методе addEnemy() класса Game мы будем просто пушить в массив enemies врагов. А чтобы они появлялись с определенной вероятностью — воспользуемся псевдослучайным генератором. В будущем для корректировки сложности игры можно будет варьировать частоту появления того или иного типа противника. Вот что у нас получилось! Теперь мы может стрелять по врагам, но...наши пули пролетают сквозь противника и не наносят им никакого урона. Нужно это исправить! Но не только пули пролетают сквозь врагов, также и враги, как вы заметили, пролетают сквозь главного персонажа. Добавим совсем немного доработок в наш код, чтобы такого не было. В класс Game добавим метод checkCollision():   Наш главный персонаж, пули и враги — это все, по сути, прямоугольники. И у каждого из этих классов, а именно Player, Projectile и Enemy есть свойства, отвечающие за текущие координаты (x и y — левый верхний угол прямоугольника) и свойства ширины и высоты (width и height). Используя только эти четыре свойства, мы и составили условие выше, которое будет говорить нам, столкнулись ли наши прямоугольники или нет. Для наглядности прикладываю ниже схематический чертеж, поясняющий вышеприведенное условие: В методе update() класса Game в "цикле" forEach() для массива enemies добавим следующий  код: Сначала мы проверяем условие столкновения игрока с врагом и после этого во вложенном "цикле" forEach() для каждой летящей пули на игровом поле мы проверяем не столкнулась ли она с врагом. Если столкновение произошло — переводим свойство markedForDeletion необходимых объектов в значение true, чтобы удалить их с игрового поля. В дальнейшем (во второй части статьи), добавим эффекты взрыва и разлетающиеся частицы при столкновениях игрока с врагами. Но сейчас оставим так, как есть. Теперь при столкновениях враги и пули просто исчезают с игрового поля: Отлично! Столкновения работают. Но наши враги от столкновения со снарядами хуже себя особо не чувствуют. Давайте это исправим. В класс Enemy внесем следующие свойства: где lives — жизни врага (т.е. чтобы уничтожить его – нужно будет выпустить в него 5 патронов); score — очки, которые мы получим за уничтожение врага (пусть пока их количество равно количеству жизней); чтобы мы видели текущие жизни каждого врага — отобразим их рядом с врагом. В метод draw() класса Enemy добавим: В класс Game добавим свойства: score — общее количество очков, полученных за уничтожение врагов; winningScore — количество очков для победы. Теперь в методе update() класса Game при столкновении пули и врага мы будем не просто помечать пулю как удаленную: Для победы нужно будет выполнить следующее условие, проверку которого реализуем в методе isWin() класса Game: Немного приукрасим интерфейс. Доработаем метод draw() класса UI, теперь он будет выглядеть так:  Здесь мы воспользовались фичей элемента канвас — методами save() и restore() (эти методы всегда должны идти в паре). Простыми словами, метод save() как бы "замораживает" состояние контекста, чтобы все изменения, находящиеся между этими методами (save и restore) были применены только к элементам внутри этих методов. Таким образом, цвет текста, настройка теней, которую мы тут применили — отобразятся только для вывода общего количества очков и сообщений о победе/проигрыше. Попробуйте убрать методы save() и restore() и посмотрите что будет. Давайте также увеличим скорость полета снарядов (класс Projectile, свойство speed): и поменяем цвет врага с зеленого на светло-зеленый (класс Angler2, свойство color): Добавим в нашу игру возможность следить за игровым временем. В класс Game добавим два свойства: В gameTime будет храниться время, прошедшее с начала игры, а по истечении timeLimit будем определять выиграл или проиграл игрок в зависимости от набранных очков. В самое начало метода update() класса Game добавим две строчки: Первая строка будет увеличивать gameTime на значение deltaTime (при условии, что игра не завершена), а вторая — завершать игру, если мы достигли timeLimit. В метод draw() класса UI добавим следующий код: Выведем значение текущего времени, переведя его в секунды и взяв только первую цифру после запятой с помощью метода toFixed(). А также исправим баг. В методе update() класса Game будем прибавлять очки за уничтоженных врагов только в том случае, если игра не завершена: Итого, для победы в игре необходимо успеть набрать 30 очков (уничтожить 6 врагов) за 20 секунд. Очень надеюсь Вам была полезна статья.  Не забываем ставить лайки и подписываться! В следующей части напишу про графику: спредшиты, анимацию персонажей, эффекты взрывов и разлетающиеся частицы при столкновениях, а также немного физики (гравитация и вращение). Ссылку на полную версию игры я дал в начале статьи. Что касается описанного в данной статье прототипа — "поиграть" в него можно здесь, а исходники лежат вот тут. Всем добра и качественного кода!