hard

Bomberman на JavaScript

Как на старой приставке из детства.

Время новых игр!

Сегодняшний проект будет отличаться от остальных игр тем, что мы добавляем в него отдельную анимацию для разных объектов, а также собираем все интерактивные объекты в одном месте. Если код покажется вам сложным, начните с других наших игр:

👉 Игра довольно объёмная по исполнению, да и в нашей версии это не совсем игра, а скорее первый набросок. Но, забегая немного вперёд, можно уже поиграть в первую версию. А даст бог — сделаем более сложную версию с врагами и секретами.

Что делаем

Делаем классическую игрушку из старых приставок — «Бомбардира», он же 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>
Как обычно, всё остальное мы нарисуем с помощью JavaScript

Подготовка и переменные

Игровое поле будет состоять из ячеек размером 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);
    }
  };
}

Сработала бомба

Мы сделали отдельно бомбу и отдельно взрыв, но ещё не продумали механику поведения объектов при взрыве. Исправим это.

Логика будет такая:

  1. Помечаем бомбу неактивной, чтобы не взорвать её ещё раз на следующем кадре общей анимации.
  2. Устанавливаем направления взрыва. Чтобы добавить игре непредсказуемости, можно выбирать направления случайным образом, но мы пока этого делать не будем. Просто знайте, что есть такая возможность.
  3. После этого обрабатываем каждое направление по очереди.
  4. Если взрыв задевает другую бомбу — взрываем и её.
  5. Убираем значок бомбы с игрового поля.

👉 Хитрость в том, что в момент взрыва мы не можем поставить всю игру на паузу и рисовать только пламя. Нам нужно как-то сказать игре, что на следующем кадре можно начинать разводить огонь в определённых ячейках. Для этого мы используем отдельный массив 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);

Если вы пока не понимаете, что вы только что прочитали, но хочется поиграть   — поиграйте.

Что дальше

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

Код

Стивен Ламбер


Текст

Михаил Полянин


Редактор

Максим Ильяхов


Художник

Даня Берковский


Корректор

Ирина Михеева


Вёрстка

Мария Дронова


Соцсети

Олег Вешкурцев

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Начать карьеру в ИТ
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
Еще по теме
hard