Время новых игр!
Сегодняшний проект будет отличаться от остальных игр тем, что мы добавляем в него отдельную анимацию для разных объектов, а также собираем все интерактивные объекты в одном месте. Если код покажется вам сложным, начните с других наших игр:
👉 Игра довольно объёмная по исполнению, да и в нашей версии это не совсем игра, а скорее первый набросок. Но, забегая немного вперёд, можно уже поиграть в первую версию. А даст бог — сделаем более сложную версию с врагами и секретами.
Что делаем

Делаем классическую игрушку из старых приставок — «Бомбардира», он же 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);
Если вы пока не понимаете, что вы только что прочитали, но хочется поиграть — поиграйте.

Что дальше
Добавим второго игрока и посмотрим, кто победит. Подпишитесь, чтобы не пропустить :)
 
						
 
						 
						 
						 
					 
				 
				 
				 
				 
				 
				 
				 
				