Всем привет!
Поделюсь с Вами моим первым опытом в создании двумерных браузерных игр. В деле этом я новичок, поэтому прошу не судить строго. Статья рассчитана в основном на изучающих 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 установим ширину и высоту игрового поля, соответственно.
После выполнения всех вышеописанных процедур наше игровое поле будет выглядеть следующим образом:
Классы 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 секунд.
Заключение
Очень надеюсь Вам была полезна статья.
Не забываем ставить лайки и подписываться!
В следующей части напишу про графику: спредшиты, анимацию персонажей, эффекты взрывов и разлетающиеся частицы при столкновениях, а также немного физики (гравитация и вращение).
Ссылку на полную версию игры я дал в начале статьи. Что касается описанного в данной статье прототипа — "поиграть" в него можно здесь, а исходники лежат вот тут.
Всем добра и качественного кода!
Комментарии (11)
Ivan_I
12.10.2023 11:24Делал похожую игрушку года 2 назад, базовый вариант реализовал. Кажется когда захотел добавить каких-то фич, не смог тогда решить проблему с большим количеством игровых объектов и расчетом их коллизий. У меня было много циклов, наверное с событиями мало работал. Какой-то архитектурный момент не проработал.
risejs
12.10.2023 11:24необходимо чтобы игра могла "адаптироваться" к Вашей частоте смены кадров
Но у вас это не реализовано, есть только расчет интервалов, что никак к адаптации не относится. Или вы что-то упустили?
Green21 Автор
12.10.2023 11:24Ну как я понял из оригинального видео - эти интервалы (deltaTime) могут отличаться на разных машинах. Поэтому мы и передаем его в качестве параметра в методы update(). По идее это и есть адаптация. Я думаю в любом случае это можно проверить - адаптируется игра или нет.
Еще в данной статье не было сказано про переменную которая отвечает за игровое время, но в полной версии игры она есть (speed в классе Game). Возможно вы ее имели ввиду. Во второй части напишу про нее.
savostin
А не лучше ли использовать Set?
Green21 Автор
Неплохая идея.
Автор видео кстати не объяснил, почему мы здесь вообще массив используем. Нельзя ли просто по нажатию/отпусканию клавиши менять скорость игрока ?! Но я так понял это какая-то общепринятая практика.
AndreyMI
Чтобы привязаться к игровому времени. На скорости это не так очевидно, а вот со стрельбой становится понятнее, ведь мы можем наспамить 100500 событий выстрела за условные 16мс.