Мои первые шаги в создании браузерных 2D игр на чистом JavaScript + Canvas. Часть 1. Прототип

—

от автора

Всем привет!

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

Статья представляет собой перевод одного англоязычного видеоурока. Если Вы хорошо владеете английским и Вам больше нравится видеоформат подачи материала — можете посмотреть видео. В статье же я буду вставлять участки кода и стараться также подробно как и автор видео — объяснять каждый свой шаг.

От Вас требуются лишь желание, базовое представление — для чего нужны HTML, CSS и JavaScript, редактор кода и более-менее современный компьютер с браузером. На самом деле знание HTML и CSS особо не понадобится, т.к. почти весь код будет написан на JavaScript.

Поиграть в игру можно здесь, а посмотреть исходники тут.

Содержание

Создание проекта

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

Создадим папку проекта и пустые js-файлы со следующей структурой:

Структура проекта

Папки выделенные красным — содержат всю необходимую графику (спрайт-шиты), с помощью которой мы будем анимировать наших персонажей. Можете скачать файлы .png из репозитория и добавить их в соответствующие разделы.

В index.html, style.css и script.js добавим нижеприведенный код:

Содержимое файлов
index.html
<!DOCTYPE html> <html lang="en">  <head>     <meta charset="UTF-8">     <meta http-equiv="X-UA-Compatible" content="IE=edge">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <title>JavaScript Game</title>     <link rel="preconnect" href="https://fonts.googleapis.com">     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>     <!-- Подключаем гугл-шрифт Bangers -->     <link href="https://fonts.googleapis.com/css2?family=Bangers&display=swap" rel="stylesheet">     <!-- Подключаем стили -->     <link rel="stylesheet" href="style.css"> </head>  <body>     <canvas id="canvas1"></canvas>      <!-- Characters -->     <img id="player" src="Assets/player.png">     <img id="angler1" src="Enemies/angler1.png">     <img id="angler2" src="Enemies/angler2.png">     <img id="lucky" src="Enemies/lucky.png">     <img id="hivewhale" src="Enemies/hivewhale.png">     <img id="drone" src="Enemies/drone.png">      <!-- Props -->     <img id="projectile" src="Assets/projectile.png">     <img id="gears" src="Assets/gears.png">     <img id="smokeExplosion" src="Collision Animations/smokeExplosion.png">     <img id="fireExplosion" src="Collision Animations/fireExplosion.png">      <!-- Environment -->     <img id="layer1" src="Layers/layer1.png">     <img id="layer2" src="Layers/layer2.png">     <img id="layer3" src="Layers/layer3.png">     <img id="layer4" src="Layers/layer4.png">       <script src="src/Projectile.js"></script>     <script src="src/Particle.js"></script>      <!-- InputHandler -->     <script src="src/InputHandler.js"></script>      <!-- Enemies -->     <script src="src/Enemies/Enemy.js"></script>     <script src="src/Enemies/Angler1.js"></script>     <script src="src/Enemies/Angler2.js"></script>     <script src="src/Enemies/Drone.js"></script>     <script src="src/Enemies/LuckyFish.js"></script>     <script src="src/Enemies/HiveWhale.js"></script>      <!-- UI -->     <script src="src/UI/UI.js"></script>     <script src="src/UI/Layer.js"></script>     <script src="src/UI/Background.js"></script>      <!-- Explosions -->     <script src="src/Explosions/Explosion.js"></script>     <script src="src/Explosions/SmokeExplosion.js"></script>     <script src="src/Explosions/FireExplosion.js"></script>      <script src="src/Player.js"></script>     <script src="src/Game.js"></script>      <!-- Main script file -->     <script src="script.js"></script>  </body>  </html>

style.css
* {     margin: 0;     padding: 0;     box-sizing: border-box; }  #canvas1 {     border: 5px solid black;     position: absolute;     top: 50%;     left: 50%;     transform: translate(-50%, -50%);     background: #4d79bc;     max-width: 100%;     max-height: 100%;     font-family: 'Bangers', cursive; /* Подключаем шрифт Bangers */ }  #layer1, #layer2, #layer3, #layer4, #player, #angler1, #angler2, #lucky, #projectile, #gears, #hivewhale, #drone, #smokeExplosion, #fireExplosion {     display: none; }

script.js
window.addEventListener('load', function () {     // canvas setup     const canvas = this.document.getElementById('canvas1');     const ctx = canvas.getContext('2d');     canvas.width = 1500;     canvas.height = 500;       })

В 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 установим ширину и высоту игрового поля, соответственно.

После выполнения всех вышеописанных процедур наше игровое поле будет выглядеть следующим образом:

Рисунок 1. Создание игрового поля

Рисунок 1. Создание игрового поля

Классы Game и Player. Анимационный цикл

Приступим к кодированию!

В файле Player.js создадим класс игрока:

class Player {     constructor(game) {         this.game = game;         this.width = 120;         this.height = 190;         this.x = 20;         this.y = 100;         this.speedY = 0;     }      update() {         this.y += this.speedY;     }      draw(context) {         context.fillRect(this.x, this.y, this.width, this.height);     } }

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

Все классы нашей игры должны будут иметь доступ к экземпляру класса Game, чтобы знать о ее состоянии, поэтому в конструктор класса Player передаем объект игры. Помимо этого, класс игрока включает в себя свойства width и height — ширина и высота, в пикселях, нашего персонажа, а также координаты его стартовой позиции на игровом полотне — свойства x и y (начало координат находится в левом верхнем углу). Свойство speedY будет отвечать за скорость перемещения (двигаться наш игрок сможет только по вертикали).

Метод update() нужен для обновления состояния нашего игрока, а draw() — для отрисовки персонажа на игровом полотне. Пока в теле метода update() реализуем лишь изменение скорости движения. А в метод draw() добавим код, который рисует черный прямоугольник вместо нашего игрока.

Класс Game имеет следующий вид:

class Game {     constructor(width, height) {         this.width = width;         this.height = height;         this.player = new Player(this);     }      update() {         this.player.update();     }      draw(context) {         this.player.draw(context);     } }

width и height представляют собой ширину и высоту игрового поля. Код new Player(this) создает для нас экземпляр (инстанс) нашего игрока, которого мы помещаем в свойство player. Отметим, что в качестве параметра в конструктор класса Player мы передаем здесь объект this — это ключевое слово говорит нам о том, что мы таким образом ссылаемся на текущий (этот) класс игры. А значит наш игрок будет также в курсе о всех изменениях состояния самой игры (игра завершена, остановлена, на паузе, время до окончания игры и т.д.).

Методы update() и draw() класса Game будут обновлять и рисовать, соответственно, все элементы игры: персонажей, взрывы, летящие «пули», разлетающиеся частицы от уничтоженных врагов и т.п. Отметим, что в методы draw во всех классах мы будем всегда передавать глобальную переменную контекста ctx, которую создали в предыдущем разделе.

В script.js добавим следующий код:

    const game = new Game(canvas.width, canvas.height);      // animation loop     function animate() {         ctx.clearRect(0, 0, canvas.width, canvas.height); // Очищаем игровое поле перед следующей анимацией         game.draw(ctx);         game.update();         requestAnimationFrame(animate);     }      animate();

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

Ключевым моментом игры является создание т.н. бесконечного анимационного цикла (animation loop). Для этого используется метод requestAnimationFrame(). Простыми словами, — этот метод посылает браузеру сигнал (1 раз!), чтобы тот выполнил перерисовку всего окна. Метод requestAnimationFrame() принимает в качестве аргумента функцию, которую нужно выполнить непосредственно перед перерисовкой. Для этого мы создали функцию animate(). А для того, чтобы наш цикл получился бесконечным — необходимо поместить метод requestAnimationFrame() внутрь функции animate(), таким образом мы и создадим бесконечный анимационный цикл, который будет обновлять состояние нашей игры.

Также в теле функции animate() мы очищаем игровое поле перед следующей отрисовкой и выполняем подряд методы draw() и update().

Скорость получившейся «анимации» зависит от мощности вашего компьютера, но в среднем составляет примерно 60 обновлений в секунду. Мы еще вернемся к методу animate() и дополним его для регулирования скорости анимации и создания т.н. «периодических событий».

На данный момент «игра» должна выглядеть следующим образом:

Рисунок 2. Черный прямоугольник вместо главного игрока

Рисунок 2. Черный прямоугольник вместо главного игрока

Обработка нажатий клавиш

Дадим возможность двигаться нашему игроку. Как я уже говорил — двигаться наш игрок будет только по вертикали.

В файл InputHandler.js добавим следующий код:

class InputHandler {     constructor(game) {         this.game = game;         window.addEventListener('keydown', (e) => {             if (((e.key === 'ArrowUp') || (e.key === 'ArrowDown')) && this.game.keys.indexOf(e.key) === -1) {                 this.game.keys.push(e.key);             }         });         window.addEventListener('keyup', (e) => {             if (this.game.keys.indexOf(e.key) > -1) {                 this.game.keys.splice(this.game.keys.indexOf(e.key), 1);             }         });     } }

а в конструкторе класса Game определим два новых свойства:

this.input = new InputHandler(this); this.keys = [];

Класс InputHandler будет отвечать за обработку событий нажатия (keydown) и отпускания (keyup) клавиш. Первый из них будет смотреть – какую клавишу нажал пользователь и, если это клавиша «вверх» или «вниз», обработчик будет добавлять эту клавишу в массив this.game.keys. Условие && this.game.keys.indexOf(e.key) === -1 запретит добавлять в массив клавишу, если та уже присутствует в нем. Обработчик события отпускания (keyup) — наоборот, сначала проверяет наличие клавиши в массиве и при ее наличии там — удаляет с помощью метода splice.

Рисунок 3. Движение игрока

Рисунок 3. Движение игрока

В класс Player добавим вспомогательное свойство maxSpeed, а в методе update() реализуем условия изменения скорости — с помощью метода includes() проверим наличие в массиве клавиш «вверх»/»вниз» и в зависимости от этого поменяем знак скорости, заставив «игрока» двигаться в том или ином направлении:

if (this.game.keys.includes('ArrowUp')) this.speedY = -this.maxSpeed else if (this.game.keys.includes('ArrowDown')) this.speedY = this.maxSpeed else this.speedY = 0;

Чтобы наш игрок не мог полностью «выйти» за границу игрового поля — добавим в метод update() следующее условие:

if (this.y > this.game.height - this.height * 0.5) this.y = this.game.     height - this.height * 0.5; else if (this.y < -this.height * 0.5) this.y = -this.height * 0.5;

Коэффициент 0.5 позволит выходить за границу только наполовину.

Снаряды

Чтобы наш игрок мог стрелять выполним следующие действия.

Первым делом создадим класс Projectile (файл Projectile.js):

class Projectile {     constructor(game, x, y) {         this.game = game;         this.x = x;         this.y = y;         this.width = 10;         this.height = 3;         this.speed = 3;         this.markedForDeletion = false;     }        update() {         this.x += this.speed;         if (this.x > this.game.width * 0.8) this.markedForDeletion = true;     }        draw(context) {         context.fillStyle = 'yellow';         context.fillRect(this.x, this.y, this.width, this.height);     } }

x и y — это стартовая позиция снаряда на игровом поле (откуда он появляется); width и height — его ширина и высота; speed — скорость полета; markedForDeletion — флаг для «удаления» снаряда, — необходим для удаления снаряда с игрового поля, если, например, снаряд сталкивается с противником, либо вылетает за обозначенные границы.

В методе update() заставляем снаряд лететь вправо путем увеличения координаты x на величину скорости, а также заставляем исчезать снаряд как только он пролетает 80% игрового поля (чтобы появляющиеся в будущем враги могли иметь фору и не получать сразу ранения). В методе draw() окрашиваем снаряд в желтый цвет и рисуем его на поле с помощью метода fillRect(). Но это еще не все…!

В класс Game добавим свойство ammo (количество боеприпасов), чтобы у игрока был лимит:

this.ammo = 20;

В классе Player определим свойство projectiles — массив снарядов:

this.projectiles = [];

В методе update() класса Player:

// handle projectiles this.projectiles.forEach(pr => { pr.update(); }); this.projectiles = this.projectiles.filter(pr => !pr.markedForDeletion);

с помощью метода forEach() пробежим по всем боеприпасам и вызовем у каждого метод update() (чтобы заставить их двигаться), а с помощью метода filter() удалим те, у которых флаг markedForDeletion = true.

в метод draw() класса Player добавим такие строки:

context.fillStyle = 'black'; this.projectiles.forEach(pr => { pr.draw(context); });

т.к. в классе Projectile мы определили цвет заливки контекста как желтый, то теперь необходимо явно поменять цвет игрока на черный, иначе он тоже будет желтым. Ну и вызовем также с помощью метода forEach() для каждого снаряда метод draw().

Реализуем в классе игрока метод shootTop():

shootTop() {     if (this.game.ammo > 0) {         this.projectiles.push(new Projectile(this.game, this.x + 80, this.y + 30));         this.game.ammo--;     }

здесь мы проверим наличие боеприпасов (this.game.ammo > 0) и если снаряды есть — добавим один снаряд в массив, в то же время уменьшив на единицу свойство this.game.ammo. Заметим, что стартовую позицию снаряда мы сдвинули правее на 80 и вниз на 30 пикселей (чтобы в будущем они вылетали из носа морского коня).

Осталось добавить в обработчик события нажатия (keydown) вызов метода shootTop() при нажатии пробела:

else if (e.key === ' ') {   this.game.player.shootTop();
Рисунок 4. Вот так выглядит стрельба

Рисунок 4. Вот так выглядит стрельба

Периодические события. FPS

Теперь наш игрок умеет стрелять и имеет целых 20 патронов. Но как Вы видите, эти 20 патронов расходуются очень быстро и много врагов ими не уничтожить. А что, если мы сделаем так, чтобы оружие у нашего игрока перезаряжалось автоматически, т.е. раз в какой-то промежуток времени боеприпасы сами пополнялись, скажем по одному патрону в секунду. Отсюда также вытекает задача иметь достоверную визуальную информацию о количестве патронов в текущий момент, но об этом чуть позже.

Немного доработаем основной скрипт нашей игры — script.js:

    let lastTime = 0; // stores a value of timestamp from the previous animation loop      // animation loop     function animate(currentTime) {   // В currentTime будет записан момент времени следующего вызова функции animate()         const deltaTime = currentTime - lastTime; // Разница, в миллисекундах, между итерациями анимационного цикла         ctx.clearRect(0, 0, canvas.width, canvas.height); // Очищаем игровое поле перед следующей анимацией         game.draw(ctx);         game.update(deltaTime); // Теперь обновление игры будет зависеть от частоты смены кадров         lastTime = currentTime; // Переприсваивание временных позиций         requestAnimationFrame(animate);     }      animate(0);  // Передаем 0 в качестве параметра (время первого вызова)

Воспользуемся еще одной фичей метода requestAnimationFrame(). Данный метод передает в аргумент коллбэк-функции (которую он вызывает) момент времени, когда он собирается ее вызывать. В нашем случае коллбэк-функция — это функция animate().

Определим переменную lastTime — она будет хранить момент времени «прошлого» вызова анимационного цикла. В функцию animate() добавим аргумент currentTime. Именно в currentTime метод requestAnimationFrame() будет записывать момент времени «текущего» вызова. Разность между «текущим» и «прошлым» временем вызова функции animate() мы запишем в переменную deltaTime, чтобы затем передать ее функции game.update() в качестве параметра. Именно на основе этой разности мы и будем строить периодические события — перезарядку снарядов и т.п.

Возможно, (я более чем уверен!), Вам, как и мне по началу, не очень понятны данные «махинации со временем». Но данная практика (с вычислением временной разности) является стандартной в разработке браузерных игр. По крайней мере так утверждает автор видео. Хотя это и логично, т.к. в зависимости от производительности Вашего компьютера — это значение может варьироваться, а значит необходимо чтобы игра могла «адаптироваться» к Вашей частоте смены кадров.

После вычисления deltaTime и ее использования — нужно перезаписать переменную lastTime, присвоив ей значение «текущего» currentTime, чтобы на следующей итерации анимационного цикла мы нашли новую deltaTime с корректными значениями.

еще немного про deltaTime

Добавим (только в целях эксперимента, а потом удалим!) в тело функции animate() вывод значения deltaTime:

console.log(deltaTime);

в консоли браузера мы увидим эти разности:

Вывод deltaTime

Вывод deltaTime

Если взять среднее значение — оно окажется, как и у автора видео, равным примерно 16.6 (миллисекунд). Это и есть разность между прошлой и текущей итерацией анимационного цикла. Поделив 1 секунду = 1000 мс на это значение — получим примерно 60. Т.е. как и было до этого сказано, метод requestAnimationFrame() перерисовывает окно со скоростью 60 раз в секунду
60 fps, frames per second).

При вызове функции animate() передадим ей в качестве параметра нулевое значение — время «первого» вызова.

Визуализация боеприпасов

Давайте же выведем на игровое полотно текущее количество патронов у нашего персонажа.

Создадим класс UI, главной задачей которого будет вывод игровых статусов и сообщений (файл UI.js):

class UI {     constructor(game) {         this.game = game;         this.fontSize = 25;         this.fontFamily = 'Helvetica';         this.color = 'yellow';     }      draw(context) {         context.fillStyle = this.color;         for (let i = 0; i < this.game.ammo; i++) {             context.fillRect(5 * i + 20, 50, 3, 20);         }     } }

Здесь я думаю пока все предельно понятно. Прокомментировать стоит лишь цикл, который рисует желтые прямоугольники, каждый из которых символизирует один патрон. Количество итераций в данном цикле равно текущему количеству боеприпасов this.game.ammo.

В класс Game добавим несколько свойств. ammoInterval — интервал перезарядки, в миллисекундах (пусть будет 500) — т.е. раз в полсекунды боеприпасы нашего игрока будет пополнять один новый патрон. Чтобы патроны не копились до бесконечности — введем свойство maxAmmo — максимальное количество патронов (сделаем равным 50); а также вспомогательное свойство ammoTimer, которое будет своего рода «временным счетчиком», меняющимся от 0 до значения ammoInterval.

В метод update() класса Game добавим следующие строки:

if (this.ammoTimer > this.ammoInterval) {     if (this.ammo < this.maxAmmo) this.ammo++;     this.ammoTimer = 0; } else {     this.ammoTimer += deltaTime; }

Теперь метод update() будет принимать в качестве параметра значение deltaTime, о котором мы подробно говорили в предыдущем разделе. Таким образом, на каждой итерации нашего анимационного цикла мы будем увеличивать значение ammoTimer на величину deltaTime . Как только ammoTimer превысит значение ammoInterval — мы сбросим ammoTimer в ноль и увеличим на единицу количество патронов (если оно меньше максимального количества maxAmmo).

В конструкторе класса Game мы также создадим свойство — экземпляр класса UI:

this.ui = new UI(this);

А в методе draw() вызовем соответствующий метод класса UI:

this.ui.draw(context);
Рисунок 5. Визуализация текущего количества боеприпасов

Рисунок 5. Визуализация текущего количества боеприпасов

Создаем врагов. Наследование

Раз мы вооружили нашего персонажа — пришло время создать и врагов. Также в этом разделе поговорим о наследовании — одной из трех ключевых концепций ООП.

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

Давайте создадим базовый класс Enemy, который затем будут наследовать производные классы:

class Enemy {     constructor(game) {         this.game = game;         this.x = this.game.width;         this.speedX = Math.random() * -1.5 - 2.5;         this.markedForDeletion = false;     }      update() {         // Обновляем x-координату врага (уменьшаем ее на величину speedX)         this.x += this.speedX;         // Помечаем врага как удаленного, если он полностью пересечет левую границу игрового поля         if (this.x + this.width < 0) this.markedForDeletion = true;     }      draw(context) {         // Устанавливаем цвет врага         context.fillStyle = this.color;         // На данном этапе наш враг будет представлять из себя         // просто прямоугольник определенного цвета         context.fillRect(this.x, this.y, this.width, this.height);     } }

Свойство x — начальная x-координата появления врага, которая равна правой границе игрового поля; speedX — отрицательная скорость движения врага, которая лежит в полуинтервале (-4, -2.5] (чтобы он двигался справа налево и при этом скорости каждого врага немного отличались) и стандартное уже свойство-флаг markedForDeletion для удаления врагов.

В методе update() мы обновляем x-координату нашего врага, а также помечаем его удаленным, если он полностью пересек левую границу игрового поля (это может случиться, если наш главный игрок не успел его уничтожить). В методе draw() установим цвет врага и нарисуем его (врага) в виде прямоугольника.

Как вы видите, свойств color и height в данном классе нет, но они будут определены в производных классах, т.к. для каждого типа врага они будут иметь свои уникальные значения (какие-то враги будут красными, другие зелеными, и по высоте тоже будут различаться). Поэтому вызывать метод draw() имеет смысл только на экземплярах производного класса.

Давайте создадим два типа врагов — Angler1 и Angler2:

Angler1.js
class Angler1 extends Enemy {     constructor(game) {         super(game);         this.width = 228 * 0.2;         this.height = 169 * 0.2;         this.y = Math.random() * (this.game.height * 0.95 - this.height);         this.color = 'red';     } }

Angler2.js
class Angler2 extends Enemy {     constructor(game) {         super(game);         this.width = 213 * 0.3;         this.height = 165 * 0.3;         this.y = Math.random() * (this.game.height * 0.95 - this.height);         this.color = 'green';     } }

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

В классах Angler1 и Angler2 мы определили ширину и высоту (width и height), цвет (color). Свойство y — y-координата врага определена таким образом, чтобы враг не мог появиться вне пределов игрового поля.

Осталось чуть-чуть доработать класс Game и мы увидим наших врагов на экране.

Добавим следующие свойства в класс Game:

this.enemies = []; this.enemyTimer = 0; this.enemyInterval = 1000; this.gameOver = false;

где enemies — массив врагов; enemyTimer и enemyInterval — переменные, которые по аналогии с переменными ammoTimer и ammoInterval из предыдущего раздела помогут нам создавать одного врага в секунду, появляющегося с правой стороны игрового полотна. Также введем свойство gameOver, чтобы иметь признак окончания игры (если игра завершена, враги появляться уже не должны).

В метод update() класса Game тоже внесем изменения:

this.enemies.forEach(enemy => enemy.update());  this.enemies = this.enemies.filter(enemy => !enemy.markedForDeletion);  if (this.enemyTimer > this.enemyInterval && !this.gameOver) {     this.addEnemy();     this.enemyTimer = 0; } else {     this.enemyTimer += deltaTime; }

как видите — эти изменения уже стандартные. Сначала в «цикле» forEach() мы вызываем метод update() у каждого врага, затем убираем «удаленных». Последний if...else... комментировать не буду, т.к. в предыдущем разделе подобное условие уже обсуждали (здесь добавил условие && !this.gameOver, чтобы враги не появлялись после окончания игры). Метод по добавлению врагов addEnemy() реализуем через пару мгновений.

В методе draw() нарисуем наших врагов:

this.enemies.forEach(enemy => enemy.draw(context));

В методе addEnemy() класса Game мы будем просто пушить в массив enemies врагов. А чтобы они появлялись с определенной вероятностью — воспользуемся псевдослучайным генератором. В будущем для корректировки сложности игры можно будет варьировать частоту появления того или иного типа противника.

addEnemy() {     const randomize = Math.random();     if (randomize < 0.5) this.enemies.push(new Angler1(this))     else this.enemies.push(new Angler2(this)); }

Вот что у нас получилось!

Рисунок 6. Враги

Рисунок 6. Враги

Теперь мы может стрелять по врагам, но…наши пули пролетают сквозь противника и не наносят им никакого урона. Нужно это исправить!

Обработка столкновений (коллизий)

Но не только пули пролетают сквозь врагов, также и враги, как вы заметили, пролетают сквозь главного персонажа.

Добавим совсем немного доработок в наш код, чтобы такого не было.

В класс Game добавим метод checkCollision():

checkCollision(rect1, rect2) {         return (             rect1.x < rect2.x + rect2.width &&             rect2.x < rect1.x + rect1.width &&             rect1.y < rect2.y + rect2.height &&             rect2.y < rect1.y + rect1.height) }

Наш главный персонаж, пули и враги — это все, по сути, прямоугольники. И у каждого из этих классов, а именно Player, Projectile и Enemy есть свойства, отвечающие за текущие координаты (x и y — левый верхний угол прямоугольника) и свойства ширины и высоты (width и height). Используя только эти четыре свойства, мы и составили условие выше, которое будет говорить нам, столкнулись ли наши прямоугольники или нет.

Для наглядности прикладываю ниже схематический чертеж, поясняющий вышеприведенное условие:

Рисунок 7. Условия столкновений прямоугольников. В синих рамочках неравенства, одновременное выполнение которых свидетельствует о столкновении прямоугольников. Кружочками отмечены стороны прямоугольников, участвующих в неравенствах

Рисунок 7. Условия столкновений прямоугольников. В синих рамочках неравенства, одновременное выполнение которых свидетельствует о столкновении прямоугольников. Кружочками отмечены стороны прямоугольников, участвующих в неравенствах

В методе update() класса Game в «цикле» forEach() для массива enemies добавим следующий код:

this.enemies.forEach(enemy => {     enemy.update();     // Проверим, не столкнолся ли враг с главным игроком (player)     if (this.checkCollision(this.player, enemy)) {         // если столкновение произошло, помечаем врага как удаленного         enemy.markedForDeletion = true;     }     // для всех активных пуль (projectiles) также проверим условие столкновения     // пули с врагом.      this.player.projectiles.forEach(projectile => {         if (this.checkCollision(projectile, enemy)) {             // если столкновение произошло, помечаем снаряд как удаленный             projectile.markedForDeletion = true;         }     }) });

Сначала мы проверяем условие столкновения игрока с врагом и после этого во вложенном «цикле» forEach() для каждой летящей пули на игровом поле мы проверяем не столкнулась ли она с врагом. Если столкновение произошло — переводим свойство markedForDeletion необходимых объектов в значение true, чтобы удалить их с игрового поля.

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

Теперь при столкновениях враги и пули просто исчезают с игрового поля:

Рисунок 8. Обработка столкновений. Видно, что пули после столкновения с врагами, а также враги после столкновения с главным игроком - исчезают с игрового поля

Рисунок 8. Обработка столкновений. Видно, что пули после столкновения с врагами, а также враги после столкновения с главным игроком — исчезают с игрового поля

Очки. Жизни. Условие победы

Отлично! Столкновения работают. Но наши враги от столкновения со снарядами хуже себя особо не чувствуют. Давайте это исправим.

В класс Enemy внесем следующие свойства:

this.lives = 5; this.score = this.lives;

где lives — жизни врага (т.е. чтобы уничтожить его – нужно будет выпустить в него 5 патронов); score — очки, которые мы получим за уничтожение врага (пусть пока их количество равно количеству жизней);

чтобы мы видели текущие жизни каждого врага — отобразим их рядом с врагом. В метод draw() класса Enemy добавим:

// отобразим у каждого врага его жизни context.fillStyle = 'black'; context.font = '20px Helvetica'; context.fillText(this.lives, this.x, this.y - 5);

В класс Game добавим свойства:

this.score = 0; this.winningScore = 30;

score — общее количество очков, полученных за уничтожение врагов; winningScore — количество очков для победы.

Теперь в методе update() класса Game при столкновении пули и врага мы будем не просто помечать пулю как удаленную:

// Если пуля попала в врага if (this.checkCollision(projectile, enemy)) {     enemy.lives--; // уменьшаем жизни врага на единицу     projectile.markedForDeletion = true; // удаляем пулю     // Проверяем, если у врага не осталось жизней     if (enemy.lives <= 0) {                 enemy.markedForDeletion = true; // удаляем врага                 this.score += enemy.score; // увеличиваем количество очков главного игрока                if (this.isWin()) this.gameOver = true;  // проверяем условие победы     } }

Для победы нужно будет выполнить следующее условие, проверку которого реализуем в методе isWin() класса Game:

isWin() {     return this.score >= this.winningScore; }

Немного приукрасим интерфейс. Доработаем метод draw() класса UI, теперь он будет выглядеть так:

Метод draw() класса UI
draw(context) {     context.save();     context.fillStyle = this.color;     context.shadowOffsetX = 2;     context.shadowOffsetY = 2;     context.shadowColor = 'black';     context.font = this.fontSize + 'px ' + this.fontFamily;     // очки     context.fillText('Score: ' + this.game.score, 20, 40);     // сообщения о победе или проигрыше     if (this.game.gameOver) {         context.textAlign = 'center';         let message1;         let message2;         if (this.game.isWin()) {             message1 = 'Победа!';             message2 = 'Отличная работа!';         } else {             message1 = 'Попробуй еще раз!';             message2 = 'В следующий раз все получится!';         }         context.font = '70px ' + this.fontFamily;         context.fillText(message1, this.game.width * 0.5, this.game.height * 0.5 - 20);         context.font = '25px ' + this.fontFamily;         context.fillText(message2, this.game.width * 0.5, this.game.height * 0.5 + 20);     }     for (let i = 0; i < this.game.ammo; i++) {         context.fillRect(5 * i + 20, 50, 3, 20);   }   context.restore(); }

Здесь мы воспользовались фичей элемента канвас — методами save() и restore() (эти методы всегда должны идти в паре). Простыми словами, метод save() как бы «замораживает» состояние контекста, чтобы все изменения, находящиеся между этими методами (save и restore) были применены только к элементам внутри этих методов. Таким образом, цвет текста, настройка теней, которую мы тут применили — отобразятся только для вывода общего количества очков и сообщений о победе/проигрыше. Попробуйте убрать методы save() и restore() и посмотрите что будет.

Давайте также увеличим скорость полета снарядов (класс Projectile, свойство speed):

this.speed = 10;

и поменяем цвет врага с зеленого на светло-зеленый (класс Angler2, свойство color):

this.color = 'lightgreen';
Рисунок 9. Очки, жизни врагов, победное сообщение

Рисунок 9. Очки, жизни врагов, победное сообщение

Игровой таймер

Добавим в нашу игру возможность следить за игровым временем.

В класс Game добавим два свойства:

this.gameTime = 0; this.timeLimit = 20 * 1000;

В gameTime будет храниться время, прошедшее с начала игры, а по истечении timeLimit будем определять выиграл или проиграл игрок в зависимости от набранных очков.

В самое начало метода update() класса Game добавим две строчки:

if (!this.gameOver) this.gameTime += deltaTime; if (this.gameTime > this.timeLimit) this.gameOver = true;

Первая строка будет увеличивать gameTime на значение deltaTime (при условии, что игра не завершена), а вторая — завершать игру, если мы достигли timeLimit.

В метод draw() класса UI добавим следующий код:

// таймер const formattedTime = (this.game.gameTime * 0.001).toFixed(1); context.fillText('Timer: ' + formattedTime, 20, 100);

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

А также исправим баг. В методе update() класса Game будем прибавлять очки за уничтоженных врагов только в том случае, если игра не завершена:

if (!this.gameOver) this.score += enemy.score;

Итого, для победы в игре необходимо успеть набрать 30 очков (уничтожить 6 врагов) за 20 секунд.

Рисунок 10. Игровой таймер

Рисунок 10. Игровой таймер

Заключение

Очень надеюсь Вам была полезна статья.

Не забываем ставить лайки и подписываться!

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

Ссылку на полную версию игры я дал в начале статьи. Что касается описанного в данной статье прототипа — «поиграть» в него можно здесь, а исходники лежат вот тут.

Всем добра и качественного кода!


ссылка на оригинал статьи https://habr.com/ru/articles/762132/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *