Время новых игр!
Сегодняшний проект будет отличаться от остальных игр тем, что мы добавляем в него отдельную анимацию для разных объектов, а также собираем все интерактивные объекты в одном месте. Если код покажется вам сложным, начните с других наших игр:
👉 Игра довольно объёмная по исполнению, да и в нашей версии это не совсем игра, а скорее первый набросок. Но, забегая немного вперёд, можно уже поиграть в первую версию. А даст бог — сделаем более сложную версию с врагами и секретами.
Что делаем
Делаем классическую игрушку из старых приставок — «Бомбардира», он же Bomberman. Правила и логика такие:
- у нас есть игровое поле, за границы которого выходить нельзя;
- на этом поле есть два вида стен — каменные и кирпичные;
- каменные стены разрушить нельзя, а кирпичные можно;
- игрок может ходить и ставить бомбы в любом свободном месте;
- когда бомба взрывается, она может уничтожить кирпичные стены
- каменные стены и игрока бомба не трогает;
- цель игры в нашей версии — очистить игровое поле от кирпичных стен. В оригинале были вариации — то ли уничтожить всех врагов, толи успеть всё за определённое время.
Управление стрелками на клавиатуре. Чтобы поставить бомбу, нажимаем пробел.
Писать игру будем на JavaScript, а запускать — в браузере. Для этого нам понадобится шаблон страницы, который будем постепенно заполнять.
<!DOCTYPE html>
<html>
<head>
<title>Бомбардир на JavaScript</title>
<meta charset="UTF-8">
<style>
</style>
</head>
<body>
</body>
</html>
Настраиваем стили
Нарисуем зелёное игровое поле на чёрном фоне и поместим его по центру страницы. Для этого добавим код в стили:
html, body {
/* общие настройки страницы */
height: 100%;
margin: 0;
}
/* настройки внешнего вида страницы и положения элементов */
body {
background: black;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
/* делаем зелёный фон у холста */
background: forestgreen;
}
Сразу нарисуем холст в основном разделе страницы <body> и добавим раздел с пустым скриптом:
<!— рисуем холст с нужными размерами —>
<canvas width=»960″ height=»832″ id=»game»></canvas>
<!— главный скрипт игры —>
<script>
<!— содержимое скрипта —>
</script>
Подготовка и переменные
Игровое поле будет состоять из ячеек размером 64 на 64 пикселя. Всего таких ячеек будет 15 в ширину и 13 в высоту. Каждая ячейка может содержать:
- разрушаемую кирпичную стену
- неразрушаемую каменную стену
- бомбу
- игрока
- вообще ничего не содержать, тогда по этой ячейке можно двигаться.
Чтобы получить имитацию кирпичной стены, нам нужно нарисовать в ячейке три ряда кирпичей. Для этого рисуем несколько прямоугольников с чёрным контуром и заливаем их серым цветом:
Для каменной стены достаточно одного большого прямоугольника с контуром. Единственное, что мы в него добавим — белые полосы, чтобы имитировать объём:
Чтобы понять, что в какой ячейке находится, сделаем отдельный массив с картой и легендой к ней. Так, ▉ — это будет каменная ячейка, 1 — кирпичная, а 2 будет означать, что в ячейке находится бомба. Если элемент массива ничего не содержит, значит, там просто пустое место.
Последнее, что нам понадобится, — отдельный массив для игровых сущностей, за которыми нужно следить.
Дело в том, что кроме игрока у нас появляются бомбы и взрывы, которые действуют и срабатывают отдельно от всего. Когда игрок ставит бомбу, она начинает работать автономно, и наша задача — обеспечить эту автономность. Для этого мы помещаем такие объекты в массив и постоянно его просматриваем. Если объект созрел для действий — выполняем их параллельно с основной программой.
👉 Массив с сущностями — важное отличие этой игры от остальных, которые мы делали. Без него не получилось бы сделать так, чтобы все нужные объекты в игре работали самостоятельно.
Теперь запишем это в виде JavaScript-кода:
// переменная для работы с холстом
const canvas = document.getElementById('game');
// содержимое холста
const context = canvas.getContext('2d');
// размеры одной клетки, количество строк и столбцов
const grid = 64;
const numRows = 13;
const numCols = 15;
// создаём кирпичные стены, которые потом расставим по всему полю и будем взрывать. На них будет кирпичный рисунок. Наша задача — нарисовать на стене этот рисунок.
const softWallCanvas = document.createElement('canvas');
const softWallCtx = softWallCanvas.getContext('2d');
// размер квадратика стены равен размеру клетки игрового поля
softWallCanvas.width = softWallCanvas.height = grid;
// цвет швов между кирпичами
softWallCtx.fillStyle = 'black';
// закрашиваем ими всю клетку
softWallCtx.fillRect(0, 0, grid, grid);
// цвет кирпича
softWallCtx.fillStyle = '#a9a9a9';
// первый ряд кирпичей
softWallCtx.fillRect(1, 1, grid - 2, 20);
// второй ряд кирпичей
softWallCtx.fillRect(0, 23, 20, 18);
softWallCtx.fillRect(22, 23, 42, 18);
// третий ряд кирпичей
softWallCtx.fillRect(0, 43, 42, 20);
softWallCtx.fillRect(44, 43, 20, 20);
// теперь создадим неразрушаемые блоки — их нельзя будет уничтожить
const wallCanvas = document.createElement('canvas');
const wallCtx = wallCanvas.getContext('2d');
// тоже размером с игровую клетку
wallCanvas.width = wallCanvas.height = grid;
// цвет тени
wallCtx.fillStyle = 'black';
wallCtx.fillRect(0, 0, grid, grid);
// цвет верхнего освещения — для объёма
wallCtx.fillStyle = 'white';
wallCtx.fillRect(0, 0, grid - 2, grid - 2);
// цвет стены
wallCtx.fillStyle = '#a9a9a9';
wallCtx.fillRect(2, 2, grid - 4, grid - 4);
// сопоставляем объекты со значениями на карте
const types = {
wall: '▉',
softWall: 1,
bomb: 2
};
// создаём карту игрового поля
// ▉ означает, что здесь будет неразрушаемый блок
// x означает, что здесь не могут появиться кирпичные блоки. Эти места нам нужны, чтобы в них мог появиться сам игрок и проходить к углам карты.
let cells = [];
const template = [
['▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉'],
['▉','x','x', , , , , , , , , ,'x','x','▉'],
['▉','x','▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉','x','▉'],
['▉','x', , , , , , , , , , , ,'x','▉'],
['▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉'],
['▉', , , , , , , , , , , , , ,'▉'],
['▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉'],
['▉', , , , , , , , , , , , , ,'▉'],
['▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉'],
['▉','x', , , , , , , , , , , ,'x','▉'],
['▉','x','▉', ,'▉', ,'▉', ,'▉', ,'▉', ,'▉','x','▉'],
['▉','x','x', , , , , , , , , ,'x','x','▉'],
['▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉','▉']
];
// здесь будем отслеживать все игровые сущности, которые нужно будет обработать
let entities = [];
Заполняем уровень
Для заполнения уровня напишем отдельную функцию, которая пройдёт по всем ячейкам и поставит там каменный или кирпичный блок. Для этого она возьмёт шаблон уровня и сделает так:
- если в шаблоне стоит ▉, то в этой ячейке будет каменный блок;
- если в шаблоне ничего не стоит, то с вероятностью 90% там будет каменный блок (или ячейка останется пустой в оставшиеся 10%);
- если в шаблоне стоит крестик, то на этом месте ничего не ставим.
// заполняем уровень каменными и кирпичными блоками
function generateLevel() {
// на старте пока уровень пустой
cells = [];
// cначала считаем строки
for (let row = 0; row < numRows; row++) {
cells[row] = [];
// потом столбцы
for (let col = 0; col < numCols; col++) {
// с вероятностью 90% в этой ячейке будет кирпичная стена
if (!template[row][col] && Math.random() < 0.90) {
cells[row][col] = types.softWall;
}
else if (template[row][col] === types.wall) {
cells[row][col] = types.wall;
}
}
}
}
Игрок
Для простоты сделаем игрока в виде белого круга. В следующих версиях нарисуем что-то другое, сейчас пока будет так.
У игрока есть такие характеристики:
- размер,
- количество бомб, которые он может ставить,
- расстояние, на которое взрывается бомба.
👉 Чтобы можно было отрисовывать игрока в любой момент, мы добавим к характеристикам функцию отрисовки. Получается, что одно из свойств игрока будет отвечать за то, чтобы нарисовать его на экране, и не придётся писать для этого отдельную функцию. Такое мы тоже используем первый раз.
// как выглядит и что умеет игрок (пока игрок — это простой белый круг)
const player = {
row: 1,
col: 1,
// сколько может ставить бомб
numBombs: 1,
// длина взыва бомбы
bombSize: 3,
// размер игрока
radius: grid * 0.35,
// отрисовываем белый круг в нужной позиции
render() {
const x = (this.col + 0.5) * grid;
const y = (this.row + 0.5) * grid;
context.save();
context.fillStyle = 'white';
context.beginPath();
context.arc(x, y, this.radius, 0, 2 * Math.PI);
context.fill();
}
}
Бомба
Бомба уже посложнее игрока, и характеристик у неё больше:
- координаты;
- размер;
- кто поставил бомбу (это нам пригодится, когда будем делать мультиплеер);
- длина действия взрыва;
- активна она или нет;
- отдельное свойство, которое говорит, что перед нами именно бомба;
- таймер.
Сделаем здесь то же самое, что и у игрока: создадим свойство-функцию, которое отвечает за отрисовку бомбы. Чтобы сразу видеть, что бомба активна и скоро взорвётся, сделаем её анимированной. Для этого каждые полсекунды с момента установки будем увеличивать и уменьшать её размеры, чтобы она как будто пульсировала в ячейке. Здесь нам понадобится таймер и отслеживание времени, про которые поговорим позже.
// функция, которая отвечает за создание бомбы
function Bomb(row, col, size, owner) {
// координаты
this.row = row;
this.col = col;
// радиус бомбы
this.radius = grid * 0.4;
// длина взрыва
this.size = size;
// кто поставил бобму
this.owner = owner;
// активируем её
this.alive = true;
// ставим признак, что в этой ячейке — бомба
this.type = types.bomb;
// взывается через 3 секунды
this.timer = 3000;
// обновляем таймер на каждом кадре анимации
this.update = function(dt) {
this.timer -= dt;
// когда таймер закончится — взрываем бомбу
if (this.timer <= 0) {
return blowUpBomb(this);
}
// меняем размер бомбы каждые полсекунды
// для этого мы каждые 500 миллисекунд увеличиваем и уменьшаем радиус
// на нечётном шаге интервала времени мы увеличиваем бомбу, а на нечётном — уменьшаем
const interval = Math.ceil(this.timer / 500);
if (interval % 2 === 0) {
this.radius = grid * 0.4;
}
else {
this.radius = grid * 0.5;
}
};
// отрисовка бомбы
this.render = function() {
// координаты
const x = (this.col + 0.5) * grid;
const y = (this.row + 0.5) * grid;
// рисуем бомбу
context.fillStyle = 'black';
context.beginPath();
context.arc(x, y, this.radius, 0, 2 * Math.PI);
context.fill();
// рисуем фитиль, который зависит от размера бомбы
const fuseY = (this.radius === grid * 0.5 ? grid * 0.15 : 0);
context.strokeStyle = 'white';
context.lineWidth = 5;
context.beginPath();
context.arc(
(this.col + 0.75) * grid,
(this.row + 0.25) * grid - fuseY,
10, Math.PI, -Math.PI / 2
);
context.stroke();
};
}
Взрыв
То, как бомба взрывается, нужно прописать отдельно, потому что у взрыва своя анимация и своё время срабатывания. Сам взрыв на экране показывается всего на треть секунды и начинается в тот момент, когда у бомбы заканчивается таймер.
У взрыва, как и у других элементов в игре, есть свои характеристики:
- длительность,
- координаты центра взрыва,
- направления.
Сразу добавим сюда функцию проверки времени, чтобы ограничить длительность анимации, и функцию отрисовки языков пламени. Сделаем их пока в виде красно-оранжевых полос, чтобы не тратить на них много времени.
// задаём характеристики взрыва
function Explosion(row, col, dir, center) {
// координаты и направление
this.row = row;
this.col = col;
this.dir = dir;
this.alive = true;
// взыв длится 300 миллисекунд
this.timer = 300;
// обновляем таймер длительности взрыва на каждом шаге анимации
this.update = function(dt) {
this.timer -= dt;
if (this.timer <=0) {
this.alive = false;
}
};
// отрисовка взрыва
this.render = function() {
const x = this.col * grid;
const y = this.row * grid;
const horizontal = this.dir.col;
const vertical = this.dir.row;
// создаём эффект огня из красных, оранжевых и жёлтых полос
// у каждого цвета — свой размер такой полосы
// красная
context.fillStyle = '#D72B16';
context.fillRect(x, y, grid, grid);
// оранжевая
context.fillStyle = '#F39642';
// определяем, как нам рисовать линии — по горизонтали или по вертикали
// на центре отрисовываем в обоих направлениях
if (center || horizontal) {
context.fillRect(x, y + 6, grid, grid - 12);
}
if (center || vertical) {
context.fillRect(x + 6, y, grid - 12, grid);
}
// жёлтая
context.fillStyle = '#FFE5A8';
// точно так же выбираем направления
if (center || horizontal) {
context.fillRect(x, y + 12, grid, grid - 24);
}
if (center || vertical) {
context.fillRect(x + 12, y, grid - 24, grid);
}
};
}
Сработала бомба
Мы сделали отдельно бомбу и отдельно взрыв, но ещё не продумали механику поведения объектов при взрыве. Исправим это.
Логика будет такая:
- Помечаем бомбу неактивной, чтобы не взорвать её ещё раз на следующем кадре общей анимации.
- Устанавливаем направления взрыва. Чтобы добавить игре непредсказуемости, можно выбирать направления случайным образом, но мы пока этого делать не будем. Просто знайте, что есть такая возможность.
- После этого обрабатываем каждое направление по очереди.
- Если взрыв задевает другую бомбу — взрываем и её.
- Убираем значок бомбы с игрового поля.
👉 Хитрость в том, что в момент взрыва мы не можем поставить всю игру на паузу и рисовать только пламя. Нам нужно как-то сказать игре, что на следующем кадре можно начинать разводить огонь в определённых ячейках. Для этого мы используем отдельный массив entities — в нём хранится всё, что нам нужно обработать дальше, но не прямо сейчас. Туда мы помещаем бомбы и пламя с их анимацией. Главное — не забыть обработать этот массив в главном цикле игры.
// взываем бомбу и разрушаем соседние ячейки
function blowUpBomb(bomb) {
// если бомба уже взорвалась — выходим, чтобы не взывать её снова :)
if (!bomb.alive) return;
// ставим флаг, что бомба взорвалась
bomb.alive = false;
// убираем бомбу с ячейки
cells[bomb.row][bomb.col] = null;
// устанавливаем направления взыва
const dirs = [{
// верх
row: -1,
col: 0
}, {
// низ
row: 1,
col: 0
}, {
// слева
row: 0,
col: -1
}, {
// справа
row: 0,
col: 1
}];
// обрабатываем каждое направление
dirs.forEach((dir) => {
for (let i = 0; i < bomb.size; i++) {
// помечаем каждую такую ячейку своими цифрами
const row = bomb.row + dir.row * i;
const col = bomb.col + dir.col * i;
const cell = cells[row][col];
// останавливаем взрыв, если он достиг неразрушаемой стены
if (cell === types.wall) {
return;
}
// начало анимации взыва всегда в месте установки бомбы
// отправляем то, что нужно взорвать, в массив с сущностями
entities.push(new Explosion(row, col, dir, i === 0 ? true : false));
// очищаем взорванную ячейку
cells[row][col] = null;
// если бомба при взрыве задевает другую бобму — взрываем и её
if (cell === types.bomb) {
// отправляем следующую бомбу в массив с остальными объектами на обработку
const nextBomb = entities.find((entity) => {
return (
entity.type === types.bomb &&
entity.row === row && entity.col === col
);
});
// и взываем её
blowUpBomb(nextBomb);
}
// если взорвали всё доступное — останавливаемся
if (cell) {
return;
}
}
});
}
Управление
Управление стандартное — используем стрелки клавиатуры для движения игрока, а при нажатии на пробел ставим бомбу.
С бомбой используем тот же трюк, что и со взрывом — помещаем её не сразу на поле, а в массив с сущностями. Когда будет отрисовываться следующий кадр, появится бомба. Так мы делаем для того, чтобы не дублировать код, а сразу обрабатывать все поведения в одном месте.
// обрабатываем нажатия на клавиши для управления игрой
document.addEventListener('keydown', function(e) {
// по умолчанию клавиши управляют движением игрока по строкам и столбцам игрового поля
let row = player.row;
let col = player.col;
// влево
if (e.which === 37) {
col--;
}
// вверх
else if (e.which === 38) {
row--;
}
// вправо
else if (e.which === 39) {
col++;
}
// вниз
else if (e.which === 40) {
row++;
}
// если пробел — ставим бомбу
else if (
e.which === 32 && !cells[row][col] &&
// считаем количество бомб, которые ставит игрок. Если бомб хватает — ставим.
entities.filter((entity) => {
return entity.type === types.bomb && entity.owner === player
}).length < player.numBombs
) {
// ставим бомбу в текущую позицию
const bomb = new Bomb(row, col, player.bombSize, player);
// отправляем бомбу в массив, чтобы игра её нарисовала на следующем кадре
entities.push(bomb);
cells[row][col] = types.bomb;
}
// двигаем игрока только в пустые ячейки
if (!cells[row][col]) {
player.row = row;
player.col = col;
}
});
Главный цикл игры
Задача главного цикла — по очереди обрабатывать все события, которые происходят в игре и обновлять происходящее на экране. Для этого на каждом шаге мы стираем всё, что было на поле и рисуем заново, при этом не забывая обрабатывать массив entities — в нём хранится всё, что нужно нарисовать и сделать на этом шаге.
👉 Последнее новшество в игре — таймер и отслеживание времени. Так как у нас анимация зависит от времени установки и срабатывания бомбы, то нам нужно запоминать время, прошедшее с момента последней отрисовки, и смотреть, сколько миллисекунд прошло. Это нам поможет точно управлять временем анимации бомбы и взрыва.
// главный цикл игры
// время последней отрисовки кадра
let last;
// сколько прошло времени с момента последней отрисовки
let dt;
function loop(timestamp) {
requestAnimationFrame(loop);
// очищаем холст
context.clearRect(0,0,canvas.width,canvas.height);
// считаем разницу во времени с момента последней отрисовки.
// эти параметры нам нужны для анимации пульсации бомбы и длительности взрыва
if (!last) {
last = timestamp;
}
dt = timestamp - last;
last = timestamp;
// заново рисуем всё на игровом поле
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
switch(cells[row][col]) {
case types.wall:
context.drawImage(wallCanvas, col * grid, row * grid);
break;
case types.softWall:
context.drawImage(softWallCanvas, col * grid, row * grid);
break;
}
}
}
// обновляем и отрисовываем все игровые сущности
entities.forEach((entity) => {
entity.update(dt);
entity.render();
});
// удаляем отработанные сущности, например, взорванные бомбы
entities = entities.filter((entity) => entity.alive);
// рисуем игрока
player.render();
}
Запуск
Для запуска сгенерируем новый уровень и запустим общую анимацию:
// запускаем игру
generateLevel();
requestAnimationFrame(loop);
Если вы пока не понимаете, что вы только что прочитали, но хочется поиграть — поиграйте.
Что дальше
Добавим второго игрока и посмотрим, кто победит. Подпишитесь, чтобы не пропустить :)