Всем привет!

Поделюсь с Вами моим первым опытом в создании двумерных браузерных игр. В деле этом я новичок, поэтому прошу не судить строго. Статья рассчитана в основном на изучающих 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. Игровой таймер

Заключение

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

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

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

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

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

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


  1. savostin
    12.10.2023 11:24

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

    А не лучше ли использовать Set?


    1. Green21 Автор
      12.10.2023 11:24

      Неплохая идея.

      Автор видео кстати не объяснил, почему мы здесь вообще массив используем. Нельзя ли просто по нажатию/отпусканию клавиши менять скорость игрока ?! Но я так понял это какая-то общепринятая практика.


      1. AndreyMI
        12.10.2023 11:24
        +1

        Чтобы привязаться к игровому времени. На скорости это не так очевидно, а вот со стрельбой становится понятнее, ведь мы можем наспамить 100500 событий выстрела за условные 16мс.


  1. LyuMih
    12.10.2023 11:24

    Привет)

    Игра из статьи с морским коньком не работает на мобилках - клавиши вверх и вниз к тачам не привязаны)


    1. Green21 Автор
      12.10.2023 11:24

      Можете поделиться кусочком кода. Доработаю)


  1. Ivan_I
    12.10.2023 11:24

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


    1. Green21 Автор
      12.10.2023 11:24

      Описали бы в статье. Народ подсказал бы)


  1. Overdozed
    12.10.2023 11:24

    Возможно будет более практичным использовать Intersection Observer для Collision Detection.


    1. Green21 Автор
      12.10.2023 11:24

      Возможно. Спасибо за идею!


  1. risejs
    12.10.2023 11:24

    необходимо чтобы игра могла "адаптироваться" к Вашей частоте смены кадров

    Но у вас это не реализовано, есть только расчет интервалов, что никак к адаптации не относится. Или вы что-то упустили?


    1. Green21 Автор
      12.10.2023 11:24

      Ну как я понял из оригинального видео - эти интервалы (deltaTime) могут отличаться на разных машинах. Поэтому мы и передаем его в качестве параметра в методы update(). По идее это и есть адаптация. Я думаю в любом случае это можно проверить - адаптируется игра или нет.

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