Тетрис на JavaScript

Отме­няй­те все дела, пере­но­си­те встре­чи. Сего­дня мы дела­ем тет­рис, в кото­рый мож­но играть в бра­у­зе­ре и на кото­рый мож­но потра­тить весь день. 

В чём идея

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

Наша зада­ча — как мож­но доль­ше про­дер­жать­ся, что­бы экран не запол­нил­ся и было место, куда падать новым фигу­рам. 

Если вдруг не зна­е­те, как это рабо­та­ет, вот фраг­мент с чем­пи­о­на­та мира по тет­ри­су:

Код не мой

Код, кото­рый мы раз­би­ра­ем в этом про­ек­те, напи­сал аме­ри­кан­ский раз­ра­бот­чик Сти­вен Лам­берт: 

Неожиданная сложность

Самое глав­ное при про­грам­ми­ро­ва­нии такой игры — это как-то хра­нить содер­жи­мое игро­во­го экра­на и учи­ты­вать дви­же­ние фигур.

Если бы мы писа­ли эту игру на Unreal Engine или Unity, пер­вым инту­и­тив­ным реше­ни­ем было бы сде­лать для бло­ков какую-то сущ­ность типа объ­ек­та. У него были бы свой­ства — напри­мер, кон­фи­гу­ра­ция. Может быть, мы бы захо­те­ли потом сде­лать взры­ва­ю­щи­е­ся объ­ек­ты или объ­ек­ты с замо­роз­кой, объ­ек­ты с повы­шен­ной ско­ро­стью, отрав­лен­ные объ­ек­ты или что-то ещё в таком духе. 

Но есть нюанс: смысл объ­ек­та в том, что он неде­ли­мый. А в «Тет­ри­се» все объ­ек­ты запро­сто делят­ся, когда мы «закры­ва­ем линию». У какой-нибудь Т-образной фигу­ры может запро­сто про­пасть хво­стик, а у Z-образной фигу­ры — ниж­няя пере­кла­ди­на. 

Полу­ча­ет­ся, что фигу­ра в тет­ри­се выгля­дит как объ­ект, ино­гда ведёт себя как объ­ект, но не обла­да­ет свой­ства­ми объ­ек­та. Поэто­му объ­ект­ный под­ход нам здесь не под­хо­дит. 

Реше­ние — пред­ста­вить игро­вое поле в виде дву­мер­но­го мас­си­ва нулей и еди­ниц. Ноль озна­ча­ет, что клет­ка сво­бод­на, а еди­ни­ца — что заня­та какой-то частью фигу­ры. Хра­нить и обра­ба­ты­вать дву­мер­ный мас­сив доволь­но про­сто, поэто­му реше­ние кажет­ся логич­ным. 

Сами фигу­ры тоже пред­ста­вим в виде дву­мер­но­го мас­си­ва из нолей и еди­ниц, но осо­бым обра­зом — в виде квад­ра­та, где еди­ни­цы отве­ча­ют за части фигу­ры, а ноли — за пустое место:

Если вме­сто квад­ра­та про­сто взять фак­ти­че­ские раз­ме­ры фигу­ры и загнать их в мас­сив, то при вра­ще­нии они не вле­зут в исход­ный мас­сив. А внут­ри квад­ра­та их мож­но вра­щать как угод­но — раз­мер мас­си­ва от это­го не изме­нит­ся:

Полу­ча­ет­ся, что если мы доба­вим в общий мас­сив с игро­вым цве­том пара­метр, кото­рый отве­ча­ет за цвет, то можем рисо­вать каж­дую фигу­ру сво­им цве­том. Так и сде­ла­ем.

Подготовка страницы

Игра будет рабо­тать на HTML-странице с помо­щью эле­мен­та Canvas — это холст, на кото­ром мы можем рисо­вать про­из­воль­ные фигу­ры через JavaScript.

Возь­мём пустую стра­ни­цу и сра­зу нари­су­ем на ней игро­вое поле. Сра­зу сде­ла­ем чёр­ный фон, игро­вое поле поста­вим по цен­тру, а его рам­ки сде­ла­ем белы­ми:

<!DOCTYPE html>
<html>
<head>
  <title>Тетрис</title>
  <style>
  html, body {
    height: 100%;
    margin: 0;
  }
  /*делаем чёрный фон и выравниваем всё по центру*/
  body {
    background: black;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  /*толщина холста — 1 пиксель, белый цвет*/
  canvas {
    border: 1px solid white;
  }
</style>
</head>

<body>
  <!-- рисуем поле для игры -->
  <canvas width="320" height="640" id="game"></canvas>
</body>
</html>

Всё осталь­ное сде­ла­ем скрип­том. Доба­вим тэг <script>..</script> сра­зу после того, как нари­со­ва­ли холст, и нач­нём писать содер­жи­мое скрип­та.

Заводим переменные и константы

Пока что всё про­сто: 

  • дела­ем мас­сив для игро­во­го поля и запол­ня­ем его;
  • дела­ем мас­си­вы, кото­рые хра­нят наши фигу­ры и их цве­та;
  • в отдель­ном мас­си­ве будем хра­нить новые фигу­ры, кото­рые появят­ся сле­ду­ю­щи­ми;
  • дела­ем флаг оста­нов­ки игры. Пока он не сра­бо­та­ет — мож­но играть.

// получаем доступ к холсту
const canvas = document.getElementById('game');
const context = canvas.getContext('2d');
// размер квадратика
const grid = 32;
// массив с последовательностями фигур, на старте — пустой
var tetrominoSequence = [];

// с помощью двумерного массива следим за тем, что находится в каждой клетке игрового поля
// размер поля — 10 на 20, и несколько строк ещё находится за видимой областью
var playfield = [];

// заполняем сразу массив пустыми ячейками
for (let row = -2; row < 20; row++) {
  playfield[row] = [];

  for (let col = 0; col < 10; col++) {
    playfield[row][col] = 0;
  }
}

// задаём формы для каждой фигуры
const tetrominos = {
  'I': [
    [0,0,0,0],
    [1,1,1,1],
    [0,0,0,0],
    [0,0,0,0]
  ],
  'J': [
    [1,0,0],
    [1,1,1],
    [0,0,0],
  ],
  'L': [
    [0,0,1],
    [1,1,1],
    [0,0,0],
  ],
  'O': [
    [1,1],
    [1,1],
  ],
  'S': [
    [0,1,1],
    [1,1,0],
    [0,0,0],
  ],
  'Z': [
    [1,1,0],
    [0,1,1],
    [0,0,0],
  ],
  'T': [
    [0,1,0],
    [1,1,1],
    [0,0,0],
  ]
};

// цвет каждой фигуры
const colors = {
  'I': 'cyan',
  'O': 'yellow',
  'T': 'purple',
  'S': 'green',
  'Z': 'red',
  'J': 'blue',
  'L': 'orange'
};

// счётчик
let count = 0;
// текущая фигура в игре
let tetromino = getNextTetromino();
// следим за кадрами анимации, чтобы если что — остановить игру
let rAF = null;  
// флаг конца игры, на старте — неактивный
let gameOver = false;

Генерируем выпадающие фигуры

Пер­вое, что нам пона­до­бит­ся для это­го, — функ­ция, кото­рая выда­ёт слу­чай­ное чис­ло в задан­ном диа­па­зоне. По это­му чис­лу мы будем выби­рать фигу­ры.

// Функция возвращает случайное число в заданном диапазоне
// https://stackoverflow.com/a/1527820/2124254
function getRandomInt(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);

  return Math.floor(Math.random() * (max - min + 1)) + min;
}

Теперь мы можем создать после­до­ва­тель­ность из выпа­да­ю­щих фигур. Логи­ка будет такая:

  1. Зада­ём обыч­ную после­до­ва­тель­ность доступ­ных фигур.
  2. Слу­чай­ным обра­зом заби­ра­ем отту­да фигу­ру и поме­ща­ем в игро­вую после­до­ва­тель­ность.
  3. Так дела­ем до тех пор, пока от обыч­ной после­до­ва­тель­но­сти ниче­го не оста­нет­ся.
  4. У нас полу­чи­лась слу­чай­ная игро­вая после­до­ва­тель­ность фигур, с кото­ры­ми мы будем рабо­тать даль­ше.

// создаём последовательность фигур, которая появится в игре
function generateSequence() {
  // тут — сами фигуры
  const sequence = ['I', 'J', 'L', 'O', 'S', 'T', 'Z'];

  while (sequence.length) {
    // случайным образом находим любую из них
    const rand = getRandomInt(0, sequence.length - 1);
    const name = sequence.splice(rand, 1)[0];
    // помещаем выбранную фигуру в игровой массив с последовательностями
    tetrominoSequence.push(name);
  }
}

Послед­ний этап в этом бло­ке — полу­чить из игро­вой после­до­ва­тель­но­сти, кото­рую мы толь­ко что сде­ла­ли, сле­ду­ю­щую фигу­ру, кото­рая у нас появит­ся. Мы долж­ны знать, что это за фигу­ра; как она рису­ет­ся; отку­да она начи­на­ет дви­же­ние. Обра­ти­те вни­ма­ние: на выхо­де мы полу­ча­ем не толь­ко дву­мер­ный мас­сив с фигу­рой, а ещё и назва­ние и её коор­ди­на­ты. Назва­ние нам нуж­но для того, что­бы знать, каким цве­том рисо­вать фигу­ру.

// получаем следующую фигуру
function getNextTetromino() {
  // если следующей нет — генерируем
  if (tetrominoSequence.length === 0) {
    generateSequence();
  }
  // берём первую фигуру из массива
  const name = tetrominoSequence.pop();
  // сразу создаём матрицу, с которой мы отрисуем фигуру
  const matrix = tetrominos[name];

  // I и O стартуют с середины, остальные — чуть левее
  const col = playfield[0].length / 2 - Math.ceil(matrix[0].length / 2);

  // I начинает с 21 строки (смещение -1), а все остальные — со строки 22 (смещение -2)
  const row = name === 'I' ? -1 : -2;

  // вот что возвращает функция 
  return {
    name: name,      // название фигуры (L, O, и т.д.)
    matrix: matrix,  // матрица с фигурой
    row: row,        // текущая строка (фигуры стартуют за видимой областью холста)
    col: col         // текущий столбец
  };
}

Движение, вращение и установка фигуры на место

В тет­ри­се мы можем вра­щать каж­дую фигу­ру на 90 гра­ду­сов по часо­вой стрел­ке сколь­ко угод­но раз. А так как у нас фигу­ра — это дву­мер­ный мас­сив из чисел, то быст­ро най­дём в интер­не­те гото­вый код для пово­ро­та чис­ло­вой мат­ри­цы:

// поворачиваем матрицу на 90 градусов
// https://codereview.stackexchange.com/a/186834
function rotate(matrix) {
  const N = matrix.length - 1;
  const result = matrix.map((row, i) =>
    row.map((val, j) => matrix[N - j][i])
  );
  // на входе матрица, и на выходе тоже отдаём матрицу
  return result;
}

После каж­до­го пово­ро­та и при каж­дой смене пози­ции нам нуж­но про­ве­рить, а может ли в прин­ци­пе фигу­ра так дви­гать­ся? Если дви­же­нию или вра­ще­нию меша­ют стен­ки поля или дру­гие фигу­ры, то нуж­но сооб­щить про­грам­ме, что такое дви­же­ние делать нель­зя. Даль­ше мы будем делать эту про­вер­ку перед тем, как что-то отри­со­вы­вать на экране.

// проверяем после появления или вращения, может ли матрица (фигура) быть в этом месте поля или она вылезет за его границы
function isValidMove(matrix, cellRow, cellCol) {
  // проверяем все строки и столбцы
  for (let row = 0; row < matrix.length; row++) {
    for (let col = 0; col < matrix[row].length; col++) {
      if (matrix[row][col] && (
          // если выходит за границы поля…
          cellCol + col < 0 ||
          cellCol + col >= playfield[0].length ||
          cellRow + row >= playfield.length ||
          // …или пересекается с другими фигурами
          playfield[cellRow + row][cellCol + col])
        ) {
        // то возвращаем, что нет, так не пойдёт
        return false;
      }
    }
  }
  // а если мы дошли до этого момента и не закончили раньше — то всё в порядке
  return true;
}

Если про­вер­ка не про­шла, то мы не дела­ем послед­нее дви­же­ние, и фигу­ра про­сто про­дол­жа­ет падать вниз. Если ей неку­да падать и она упёр­лась в дру­гие, то нам нуж­но зафик­си­ро­вать это в игро­вом поле. Это зна­чит, что мы запи­сы­ва­ем в мас­сив, кото­рый отве­ча­ет за поле, нашу мат­ри­цу фигу­ры, про­пус­кая ноли и запи­сы­вая толь­ко еди­ни­цы.

Как толь­ко фигу­ра вста­ла, нам нуж­но про­ве­рить, полу­чил­ся целый ряд или нет. Если полу­чил­ся — сдви­га­ем на один ряд вниз всё, что свер­ху. Такую про­вер­ку дела­ем каж­дый раз при уста­нов­ке фигу­ры и начи­на­ем с ниж­не­го ряда, под­ни­ма­ясь наверх.

// когда фигура окончательна встала на своё место
function placeTetromino() {
  // обрабатываем все строки и столбцы в игровом поле
  for (let row = 0; row < tetromino.matrix.length; row++) {
    for (let col = 0; col < tetromino.matrix[row].length; col++) {
      if (tetromino.matrix[row][col]) {

        // если край фигуры после установки вылезает за границы поля, то игра закончилась
        if (tetromino.row + row < 0) {
          return showGameOver();
        }
        // если всё в порядке, то записываем в массив игрового поля нашу фигуру
        playfield[tetromino.row + row][tetromino.col + col] = tetromino.name;
      }
    } 
  }

  // проверяем, чтобы заполненные ряды очистились снизу вверх
  for (let row = playfield.length - 1; row >= 0; ) {
    // если ряд заполнен
    if (playfield[row].every(cell => !!cell)) {

      // очищаем его и опускаем всё вниз на одну клетку
      for (let r = row; r >= 0; r--) {
        for (let c = 0; c < playfield[r].length; c++) {
          playfield[r][c] = playfield[r-1][c];
        }
      }
    }
    else {
      // переходим к следующему ряду
      row--;
    }
  }
  // получаем следующую фигуру
  tetromino = getNextTetromino();
}

Что будет, когда мы проиграем

Когда фигу­ра при окон­ча­тель­ной уста­нов­ке выле­за­ет за гра­ни­цы игро­во­го поля, это зна­чит, что мы про­иг­ра­ли. За это у нас отве­ча­ет флаг gameOver, и его зада­ча — оста­но­вить ани­ма­цию игры.

Что­бы было понят­но, что игра закон­че­на, выве­дем над­пись GAME OVER! пря­мо поверх игро­во­го поля:

// показываем надпись Game Over
function showGameOver() {
  // прекращаем всю анимацию игры
  cancelAnimationFrame(rAF);
  // ставим флаг окончания
  gameOver = true;
  // рисуем чёрный прямоугольник посередине поля
  context.fillStyle = 'black';
  context.globalAlpha = 0.75;
  context.fillRect(0, canvas.height / 2 - 30, canvas.width, 60);
  // пишем надпись белым моноширинным шрифтом по центру
  context.globalAlpha = 1;
  context.fillStyle = 'white';
  context.font = '36px monospace';
  context.textAlign = 'center';
  context.textBaseline = 'middle';
  context.fillText('GAME OVER!', canvas.width / 2, canvas.height / 2);
}

Обрабатываем нажатия на клавиши

Всё как в обыч­ном тет­ри­се: стрел­ки вле­во и впра­во дви­га­ют фигу­ру, стрел­ка вверх пово­ра­чи­ва­ет её на 90 гра­ду­сов, а стрел­ка вниз уско­ря­ет паде­ние.

Един­ствен­ное, о чём нуж­но не забыть — после каж­до­го нажа­тия вызвать про­вер­ку, мож­но ли так дви­гать фигу­ру или нет.

// следим за нажатиями на клавиши
document.addEventListener('keydown', function(e) {
  // если игра закончилась — сразу выходим
  if (gameOver) return;

  // стрелки влево и вправо
  if (e.which === 37 || e.which === 39) {
    const col = e.which === 37
      // если влево, то уменьшаем индекс в столбце, если вправо — увеличиваем
      ? tetromino.col - 1
      : tetromino.col + 1;

    // если так ходить можно, то запоминаем текущее положение 
    if (isValidMove(tetromino.matrix, tetromino.row, col)) {
      tetromino.col = col;
    }
  }

  // стрелка вверх — поворот
  if (e.which === 38) {
    // поворачиваем фигуру на 90 градусов
    const matrix = rotate(tetromino.matrix);
    // если так ходить можно — запоминаем
    if (isValidMove(matrix, tetromino.row, tetromino.col)) {
      tetromino.matrix = matrix;
    }
  }

  // стрелка вниз — ускорить падение
  if(e.which === 40) {
    // смещаем фигуру на строку вниз
    const row = tetromino.row + 1;
    // если опускаться больше некуда — запоминаем новое положение
    if (!isValidMove(tetromino.matrix, row, tetromino.col)) {
      tetromino.row = row - 1;
      // ставим на место и смотрим на заполненные ряды
      placeTetromino();
      return;
    }
    // запоминаем строку, куда стала фигура
    tetromino.row = row;
  }
});

Запускаем движения и анимацию

Смысл глав­но­го цик­ла игры такой:

  • на каж­дом кад­ре мы очи­ща­ем игро­вое поле и отри­со­вы­ва­ем его зано­во с учё­том упав­ших фигур;
  • рису­ем теку­щую фигу­ру в том месте, где она нахо­дит­ся в дан­ный момент.

Так как кад­ры меня­ют­ся быст­ро, мы не заме­тим посто­ян­но­го очи­ще­ния и отри­сов­ки. Нам будет казать­ся, что фигу­ра про­сто дви­жет­ся вниз и реа­ги­ру­ет на наши дей­ствия.

// главный цикл игры
function loop() {
  // начинаем анимацию
  rAF = requestAnimationFrame(loop);
  // очищаем холст
  context.clearRect(0,0,canvas.width,canvas.height);

  // рисуем игровое поле с учётом заполненных фигур
  for (let row = 0; row < 20; row++) {
    for (let col = 0; col < 10; col++) {
      if (playfield[row][col]) {
        const name = playfield[row][col];
        context.fillStyle = colors[name];

        // рисуем всё на один пиксель меньше, чтобы получился эффект «в клетку»
        context.fillRect(col * grid, row * grid, grid-1, grid-1);
      }
    }
  }

  // рисуем текущую фигуру
  if (tetromino) {

    // фигура сдвигается вниз каждые 35 кадров
    if (++count > 35) {
      tetromino.row++;
      count = 0;

      // если движение закончилось — рисуем фигуру в поле и проверяем, можно ли удалить строки
      if (!isValidMove(tetromino.matrix, tetromino.row, tetromino.col)) {
        tetromino.row--;
        placeTetromino();
      }
    }

    // не забываем про цвет текущей фигуры
    context.fillStyle = colors[tetromino.name];

    // отрисовываем её
    for (let row = 0; row < tetromino.matrix.length; row++) {
      for (let col = 0; col < tetromino.matrix[row].length; col++) {
        if (tetromino.matrix[row][col]) {

          // и снова рисуем на один пиксель меньше
          context.fillRect((tetromino.col + col) * grid, (tetromino.row + row) * grid, grid-1, grid-1);
        }
      }
    }
  }
}

Послед­нее, что нам оста­лось сде­лать, — запу­стить игру:

// старт игры rAF = requestAnimationFrame(loop);

Гото­вый резуль­тат мож­но посмот­реть на стра­ни­це с игрой.

Готовый код

<!DOCTYPE html>
<html>
<head>
  <title>Тетрис</title>
    <style>
      /*настройки стилей страницы*/

      html, body {
        height: 100%;
        margin: 0;
      }
      /*делаем чёрный фон и выравниваем всё по центру*/
      body {
        background: black;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      /*толщина холста — 1 пиксель, белый цвет*/
      canvas {
        border: 1px solid white;
      }
    </style>
</head>

<body>
  <!-- рисуем поле для игры -->
  <canvas width="320" height="640" id="game"></canvas>
  <script>
    // License CC0 1.0 Universal 
    // https://gist.github.com/straker/3c98304f8a6a9174efd8292800891ea1
    // https://tetris.fandom.com/wiki/Tetris_Guideline

    // получаем доступ к холсту
    const canvas = document.getElementById('game');
    const context = canvas.getContext('2d');
    // размер квадратика
    const grid = 32;
    // массив с последовательностями фигур, на старте — пустой
    var tetrominoSequence = [];

    // с помощью двумерного массива следим за тем, что находится в каждой клетке игрового поля
    // размер поля — 10 на 20, и несколько строк ещё находится за видимой областью
    var playfield = [];

    // заполняем сразу массив пустыми ячейками
    for (let row = -2; row < 20; row++) {
      playfield[row] = [];

      for (let col = 0; col < 10; col++) {
        playfield[row][col] = 0;
      }
    }

    // как рисовать каждую фигуру
    // https://tetris.fandom.com/wiki/SRS
    const tetrominos = {
      'I': [
        [0,0,0,0],
        [1,1,1,1],
        [0,0,0,0],
        [0,0,0,0]
      ],
      'J': [
        [1,0,0],
        [1,1,1],
        [0,0,0],
      ],
      'L': [
        [0,0,1],
        [1,1,1],
        [0,0,0],
      ],
      'O': [
        [1,1],
        [1,1],
      ],
      'S': [
        [0,1,1],
        [1,1,0],
        [0,0,0],
      ],
      'Z': [
        [1,1,0],
        [0,1,1],
        [0,0,0],
      ],
      'T': [
        [0,1,0],
        [1,1,1],
        [0,0,0],
      ]
    };

    // цвет каждой фигуры
    const colors = {
      'I': 'cyan',
      'O': 'yellow',
      'T': 'purple',
      'S': 'green',
      'Z': 'red',
      'J': 'blue',
      'L': 'orange'
    };

    // счётчик
    let count = 0;
    // текущая фигура в игре
    let tetromino = getNextTetromino();
    // следим за кадрами анимации, чтобы если что — остановить игру
    let rAF = null;  
    // флаг конца игры, на старте — неактивный
    let gameOver = false;


    // Функция возвращает случайное число в заданном диапазоне
    // https://stackoverflow.com/a/1527820/2124254
    function getRandomInt(min, max) {
      min = Math.ceil(min);
      max = Math.floor(max);

      return Math.floor(Math.random() * (max - min + 1)) + min;
    }

    // создаём последовательность фигур, которая появится в игре
    //https://tetris.fandom.com/wiki/Random_Generator
    function generateSequence() {
      // тут — сами фигуры
      const sequence = ['I', 'J', 'L', 'O', 'S', 'T', 'Z'];

      while (sequence.length) {
        // случайным образом находим любую из них
        const rand = getRandomInt(0, sequence.length - 1);
        const name = sequence.splice(rand, 1)[0];
        // помещаем выбранную фигуру в игровой массив с последовательностями
        tetrominoSequence.push(name);
      }
    }

    // получаем следующую фигуру
    function getNextTetromino() {
      // если следующей нет — генерируем
      if (tetrominoSequence.length === 0) {
        generateSequence();
      }
      // берём первую фигуру из массива
      const name = tetrominoSequence.pop();
      // сразу создаём матрицу, с которой мы отрисуем фигуру
      const matrix = tetrominos[name];

      // I и O стартуют с середины, остальные — чуть левее
      const col = playfield[0].length / 2 - Math.ceil(matrix[0].length / 2);

      // I начинает с 21 строки (смещение -1), а все остальные — со строки 22 (смещение -2)
      const row = name === 'I' ? -1 : -2;

      // вот что возвращает функция 
      return {
        name: name,      // название фигуры (L, O, и т.д.)
        matrix: matrix,  // матрица с фигурой
        row: row,        // текущая строка (фигуры стартую за видимой областью холста)
        col: col         // текущий столбец
      };
    }

    // поворачиваем матрицу на 90 градусов
    // https://codereview.stackexchange.com/a/186834
    function rotate(matrix) {
      const N = matrix.length - 1;
      const result = matrix.map((row, i) =>
        row.map((val, j) => matrix[N - j][i])
      );
      // на входе матрица, и на выходе тоже отдаём матрицу
      return result;
    }

    // проверяем после появления или вращения, может ли матрица (фигура) быть в этом месте поля или она вылезет за его границы
    function isValidMove(matrix, cellRow, cellCol) {
      // проверяем все строки и столбцы
      for (let row = 0; row < matrix.length; row++) {
        for (let col = 0; col < matrix[row].length; col++) {
          if (matrix[row][col] && (
              // если выходит за границы поля…
              cellCol + col < 0 ||
              cellCol + col >= playfield[0].length ||
              cellRow + row >= playfield.length ||
              // …или пересекается с другими фигурами
              playfield[cellRow + row][cellCol + col])
            ) {
            // то возвращаем, что нет, так не пойдёт
            return false;
          }
        }
      }
      // а если мы дошли до этого момента и не закончили раньше — то всё в порядке
      return true;
    }

    // когда фигура окончательна встала на своё место
    function placeTetromino() {
      // обрабатываем все строки и столбцы в игровом поле
      for (let row = 0; row < tetromino.matrix.length; row++) {
        for (let col = 0; col < tetromino.matrix[row].length; col++) {
          if (tetromino.matrix[row][col]) {

            // если край фигуры после установки вылезает за границы поля, то игра закончилась
            if (tetromino.row + row < 0) {
              return showGameOver();
            }
            // если всё в порядке, то записываем в массив игрового поля нашу фигуру
            playfield[tetromino.row + row][tetromino.col + col] = tetromino.name;
          }
        } 
      }

      // проверяем, чтобы заполненные ряды очистились снизу вверх
      for (let row = playfield.length - 1; row >= 0; ) {
        // если ряд заполнен
        if (playfield[row].every(cell => !!cell)) {

          // очищаем его и опускаем всё вниз на одну клетку
          for (let r = row; r >= 0; r--) {
            for (let c = 0; c < playfield[r].length; c++) {
              playfield[r][c] = playfield[r-1][c];
            }
          }
        }
        else {
          // переходим к следующему ряду
          row--;
        }
      }
      // получаем следующую фигуру
      tetromino = getNextTetromino();
    }

      // показываем надпись Game Over
      function showGameOver() {
        // прекращаем всю анимацию игры
        cancelAnimationFrame(rAF);
        // ставим флаг окончания
        gameOver = true;
        // рисуем чёрный прямоугольник посередине поля
        context.fillStyle = 'black';
        context.globalAlpha = 0.75;
        context.fillRect(0, canvas.height / 2 - 30, canvas.width, 60);
        // пишем надпись белым моноширинным шрифтом по центру
        context.globalAlpha = 1;
        context.fillStyle = 'white';
        context.font = '36px monospace';
        context.textAlign = 'center';
        context.textBaseline = 'middle';
        context.fillText('GAME OVER!', canvas.width / 2, canvas.height / 2);
      }

    

    // главный цикл игры
    function loop() {
      // начинаем анимацию
      rAF = requestAnimationFrame(loop);
      // очищаем холст
      context.clearRect(0,0,canvas.width,canvas.height);

      // рисуем игровое поле с учётом заполненных фигур
      for (let row = 0; row < 20; row++) {
        for (let col = 0; col < 10; col++) {
          if (playfield[row][col]) {
            const name = playfield[row][col];
            context.fillStyle = colors[name];

            // рисуем всё на один пиксель меньше, чтобы получился эффект «в клетку»
            context.fillRect(col * grid, row * grid, grid-1, grid-1);
          }
        }
      }

      // рисуем текущую фигуру
      if (tetromino) {

        // фигура сдвигается вниз каждые 35 кадров
        if (++count > 35) {
          tetromino.row++;
          count = 0;

          // если движение закончилось — рисуем фигуру в поле и проверяем, можно ли удалить строки
          if (!isValidMove(tetromino.matrix, tetromino.row, tetromino.col)) {
            tetromino.row--;
            placeTetromino();
          }
        }

        // не забываем про цвет текущей фигуры
        context.fillStyle = colors[tetromino.name];

        // отрисовываем её
        for (let row = 0; row < tetromino.matrix.length; row++) {
          for (let col = 0; col < tetromino.matrix[row].length; col++) {
            if (tetromino.matrix[row][col]) {

              // и снова рисуем на один пиксель меньше
              context.fillRect((tetromino.col + col) * grid, (tetromino.row + row) * grid, grid-1, grid-1);
            }
          }
        }
      }
    }

    // следим за нажатиями на клавиши
    document.addEventListener('keydown', function(e) {
      // если игра закончилась — сразу выходим
      if (gameOver) return;

      // стрелки влево и вправо
      if (e.which === 37 || e.which === 39) {
        const col = e.which === 37
          // если влево, то уменьшаем индекс в столбце, если вправо — увеличиваем
          ? tetromino.col - 1
          : tetromino.col + 1;

        // если так ходить можно, то запоминаем текущее положение 
        if (isValidMove(tetromino.matrix, tetromino.row, col)) {
          tetromino.col = col;
        }
      }

      // стрелка вверх — поворот
      if (e.which === 38) {
        // поворачиваем фигуру на 90 градусов
        const matrix = rotate(tetromino.matrix);
        // если так ходить можно — запоминаем
        if (isValidMove(matrix, tetromino.row, tetromino.col)) {
          tetromino.matrix = matrix;
        }
      }

      // стрелка вниз — ускорить падение
      if(e.which === 40) {
        // смещаем фигуру на строку вниз
        const row = tetromino.row + 1;
        // если опускаться больше некуда — запоминаем новое положение
        if (!isValidMove(tetromino.matrix, row, tetromino.col)) {
          tetromino.row = row - 1;
          // ставим на место и смотрим на заполненные ряды
          placeTetromino();
          return;
        }
        // запоминаем строку, куда стала фигура
        tetromino.row = row;
      }
    });

    // старт игры
    rAF = requestAnimationFrame(loop);
  </script>
</body>
</html>

Что дальше

У нас есть игра, но нет важ­ных эле­мен­тов:

  • под­счё­та очков и ста­ти­сти­ки;
  • запи­си имён, что­бы понять, кто набрал боль­ше очков;
  • зву­ко­вых эффек­тов;
  • уско­ре­ния паде­ния после каж­дых, напри­мер, 10 собран­ных рядов.

Сде­ла­ем это в дру­гой вер­сии игры, а пока отме­няй­те пла­ны, сего­дня мы игра­ем в бес­ко­неч­ный тет­рис.

Код:
Сти­вен Лам­бер

Текст и иллю­стра­ции:
Миша Поля­нин

Редак­тор:
Мак­сим Илья­хов

Худож­ник:
Даня Бер­ков­ский

Кор­рек­тор:
Ира Михе­е­ва

Вёрст­ка:
Маша Дро­но­ва

Соц­се­ти:
Олег Веш­кур­цев