Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Всем привет!
Поделюсь с Вами моим первым опытом в создании двумерных браузерных игр. В деле этом я новичок, поэтому прошу не судить строго. Статья рассчитана в основном на изучающих JavaScript, а также тех, кто, как и я, делает первые шаги в мир игровой индустрии.
Статья представляет собой перевод одного англоязычного видеоурока. Если Вы хорошо владеете английским и Вам больше нравится видеоформат подачи материала — можете посмотреть видео. В статье же я буду вставлять участки кода и стараться также подробно как и автор видео — объяснять каждый свой шаг.
От Вас требуются лишь желание, базовое представление — для чего нужны HTML, CSS и JavaScript, редактор кода и более-менее современный компьютер с браузером. На самом деле знание HTML и CSS особо не понадобится, т.к. почти весь код будет написан на JavaScript.
Поиграть в игру можно здесь, а посмотреть исходники тут.
Содержание
Создание проекта
Классы Game и Player. Анимационный цикл
Обработка нажатий клавиш
Снаряды
Периодические события. FPS
Визуализация боеприпасов
Создаем врагов. Наследование
Обработка столкновений (коллизий)
Очки. Жизни. Условие победы
Игровой таймер
Заключение
Создание проекта
Автор видео пишет весь 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 установим ширину и высоту игрового поля, соответственно.
После выполнения всех вышеописанных процедур наше игровое поле будет выглядеть следующим образом:
Классы 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() и дополним его для регулирования скорости анимации и создания т.н. "периодических событий".
На данный момент "игра" должна выглядеть следующим образом:
Обработка нажатий клавиш
Дадим возможность двигаться нашему игроку. Как я уже говорил — двигаться наш игрок будет только по вертикали.
В файл 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.
В класс 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();
Периодические события. 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);
в консоли браузера мы увидим эти разности:
Если взять среднее значение — оно окажется, как и у автора видео, равным примерно 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);
Создаем врагов. Наследование
Раз мы вооружили нашего персонажа — пришло время создать и врагов. Также в этом разделе поговорим о наследовании — одной из трех ключевых концепций ООП.
У нас будет несколько типов врагов, каждый со своими свойствами/поведением (количество жизней, урон наносимый главному персонажу при столкновении, либо бонусы от столкновения и т.п.). Но при этом будут и общие свойства/методы присущие всем врагам.
Давайте создадим базовый класс 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));
}
Вот что у нас получилось!
Теперь мы может стрелять по врагам, но...наши пули пролетают сквозь противника и не наносят им никакого урона. Нужно это исправить!
Обработка столкновений (коллизий)
Но не только пули пролетают сквозь врагов, также и враги, как вы заметили, пролетают сквозь главного персонажа.
Добавим совсем немного доработок в наш код, чтобы такого не было.
В класс 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
). Используя только эти четыре свойства, мы и составили условие выше, которое будет говорить нам, столкнулись ли наши прямоугольники или нет.
Для наглядности прикладываю ниже схематический чертеж, поясняющий вышеприведенное условие:
В методе 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, чтобы удалить их с игрового поля.
В дальнейшем (во второй части статьи), добавим эффекты взрыва и разлетающиеся частицы при столкновениях игрока с врагами. Но сейчас оставим так, как есть.
Теперь при столкновениях враги и пули просто исчезают с игрового поля:
Очки. Жизни. Условие победы
Отлично! Столкновения работают. Но наши враги от столкновения со снарядами хуже себя особо не чувствуют. Давайте это исправим.
В класс 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';
Игровой таймер
Добавим в нашу игру возможность следить за игровым временем.
В класс 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 секунд.
Заключение
Очень надеюсь Вам была полезна статья.
Не забываем ставить лайки и подписываться!
В следующей части напишу про графику: спредшиты, анимацию персонажей, эффекты взрывов и разлетающиеся частицы при столкновениях, а также немного физики (гравитация и вращение).
Ссылку на полную версию игры я дал в начале статьи. Что касается описанного в данной статье прототипа — "поиграть" в него можно здесь, а исходники лежат вот тут.
Всем добра и качественного кода!