Свой тетрис на JavaScript: прокачиваем проект
Тетрис на JavaScript
Свой тетрис на JavaScript: прокачиваем проект

Как-то раз мы писа­ли соб­ствен­ный тет­рис на JavaScript. Мы закон­чи­ли на том, что у нас есть про­стое игро­вое поле, фигу­ры и базо­вая логи­ка игры. Ещё игра уме­ет оста­нав­ли­вать­ся, когда для фигур боль­ше нет места. Но это­го недо­ста­точ­но, что­бы счи­тать­ся пол­но­цен­ной игрой. 

Что­бы это испра­вить, вот что мы доба­вим сего­дня в игру:

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

Всё это неслож­но, но тре­бу­ет вни­ма­ния. Что­бы дора­бо­тать игру, будем исполь­зо­вать код из пер­вой части проекта:

Тетрис на JavaScrip

<!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>

Отображаем текущий уровень и набранные очки

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

<!-- добавляем область для вывода информации о текущей игре -->

  <canvas style="border: 1px" width="320" height="100" id="score"></canvas>

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

Заметь­те: теперь у нас на стра­ни­це есть два объ­ек­та типа canvas — то есть два хол­ста. В одном рису­ет­ся игра, в дру­гом выво­дят­ся очки. Теперь мы смо­жем обра­щать­ся к этим хол­стам отдель­но и рисо­вать в каж­дом то, что нам нуж­но, неза­ви­си­мо друг от друга. 

Вот как полу­чить доступ к ново­му холсту: 

// получаем доступ к холсту с игровой статистикой

    const canvasScore = document.getElementById('score');

    const contextScore = canvasScore.getContext('2d');

Доба­вим в скрипт новые пере­мен­ные — они нам сра­зу при­го­дят­ся на сле­ду­ю­щем этапе:

// количество набранных очков на старте
let score = 0;
// рекорд игры
let record = 0;
// текущий уровень сложности
let level = 1;
// имя игрока с наибольшим рейтингом
let recordName = '';

Теперь выве­дем на экран всю игро­вую ста­ти­сти­ку. Для это­го перед глав­ным цик­лом игры сде­ла­ем новую функ­цию showScore():

function showScore() {
  contextScore.clearRect(0,0,canvasScore.width,canvasScore.height);  
  contextScore.globalAlpha = 1;
  contextScore.fillStyle = 'white';
  contextScore.font = '18px Courier New';
  contextScore.fillText('Уровень: ' + level, 15, 20);
  contextScore.fillText('Очков:   ' + score, 15, 50);
  contextScore.fillText('Чемпион: ' + recordName, 160, 20);
  contextScore.fillText('Рекорд:  ' + record, 160, 50);

}

Циф­ры вро­де 15, 20, 50, 160 — это коор­ди­на­ты по вер­ти­ка­ли и гори­зон­та­ли, где долж­ны нахо­дить­ся наши тек­сто­вые бло­ки. Без этих коор­ди­нат они все нале­зут друг на друга. 

Послед­нее, что нам оста­лось сде­лать — вызвать эту функ­цию внут­ри основ­ной функ­ции loop() коман­дой showScore().

Функция внутри основной функции loop() командой showScore() У нас выве­лась ста­ти­сти­ка, но про­грам­ма пока никак её не счи­та­ет. Испра­вим это. 

Спрашиваем имя игрока

Что­бы знать, кому при­над­ле­жит рекорд игры, будем спра­ши­вать имя при каж­дом запус­ке игры:

    name = prompt("Ваше имя", "");

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

Считаем очки

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

Для под­счё­та очков нахо­дим в функ­ции placeTetromino() про­вер­ку, запол­нен ли ряд цели­ком, и сра­зу пер­вым дей­стви­ем в этой про­вер­ке пишем:

score += 10;

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

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

// если игрок побил прошлый рекорд
if (score > record) {
  // ставим его очки как рекорд
  record = score;
  // меняем имя чемпиона
  recordName = name;
}

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

Сей­час у игры есть суще­ствен­ный недо­ста­ток — если открыть стра­ни­цу зано­во в том же бра­у­зе­ре, она не вспом­нит имя чем­пи­о­на. Когда мы дела­ли свой туду-лист на JavaScript, то исполь­зо­ва­ли для это­го локаль­ное хра­ни­ли­ще бра­у­зе­ра — localstorage. При новом откры­тии стра­ни­цы мож­но будет взять дан­ные о рекор­де оттуда.

Возь­мём из той ста­тьи наш код для локаль­но­го хра­ни­ли­ща и попра­вим его под нынеш­нюю зада­чу — хра­нить имя игро­ка и его рекорд. Поста­вим этот код сра­зу после того, как спро­сим имя игро­ка на старте:

// Узнаём размер хранилища
var Storage_size = localStorage.length;
// Если в хранилище уже что-то есть…
if (Storage_size > 0) {
  // …то достаём оттуда значение рекорда и имя чемпиона
  record = localStorage.record;
  recordName = localStorage.recordName;
}

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

// если игрок побил прошлый рекорд
if (score > record) {
  // ставим его очки как рекорд
  record = score;
  // заносим в хранилище значение рекорда
  localStorage.record = record;
  // меняем имя чемпиона
  recordName = name;
  // заносим в хранилище его имя
  localStorage.recordName = recordName;

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

Свой тетрис на JavaScript: прокачиваем проект

Добавляем сложность и уровни

Что­бы играть было инте­рес­нее, сде­ла­ем так: с каж­дым новым уров­нем фигу­ры будут падать всё быст­рее. Для это­го нахо­дим в коде строч­ку, кото­рая отве­ча­ет за ско­рость дви­же­ния фигур вниз и пра­вим её сле­ду­ю­щим образом:

// фигура сдвигается вниз каждые 36 кадров минус значение текущего уровня. Чем больше уровень, тем быстрее падает.

if (++count > (36 - level)) {

Логи­ка тут такая: чем выше уро­вень, тем мень­ше интер­вал обнов­ле­ния меж­ду сдви­га­ми, что даёт нам ощу­ще­ние повы­шен­ной ско­ро­сти. Хотя на самом деле фигу­ры не пада­ют быст­рее, про­сто они мень­ше тупят.

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

Сами уров­ни будем счи­тать там же, где и очки. Что­бы перей­ти на новый уро­вень, нуж­но набрать 100 очков. Зная это, лег­ко полу­чить зна­че­ние уров­ня — доста­точ­но раз­де­лить наце­ло коли­че­ство очков на 100 и при­ба­вить 1. Такой под­ход и даст нам нуж­ный уровень.

Запи­шем это там же, где и идёт под­счёт очков:

// считаем уровень

level = Math.floor(score/100) + 1;

Теперь всё в поряд­ке. Може­те ско­пи­ро­вать код себе или поиг­рать на стра­ни­це про­ек­та.

Готовый код

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <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>

  <!-- добавляем область для вывода информации о текущей игре -->
  <canvas style="border: 0px" width="320" height="100" id="score"></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 canvasScore = document.getElementById('score');
    const contextScore = canvasScore.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;

    // количество набранных очков на старте
    let score = 0;
    // рекорд игры
    let record = 0;
    // текущий уровень сложности
    let level = 1;
    // имя игрока с наибольшим рейтингом
    let recordName = '';

    // спрашиваем имя игрока при запуске
    name = prompt("Ваше имя", "");


    // Узнаём размер хранилища
    var Storage_size = localStorage.length;
    // Если в хранилище уже что-то есть…
    if (Storage_size > 0) {
      // …то достаём оттуда значение рекорда и имя чемпиона
      record = localStorage.record;
      recordName = localStorage.recordName;
    }

    // Функция возвращает случайное число в заданном диапазоне
    // 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)) {

          score += 10;
          // считаем уровень
          level = Math.floor(score/100) + 1;
          // если игрок побил прошлый рекорд
          if (score > record) {
            // ставим его очки как рекорд
            record = score;
            // заносим в хранилище значение рекорда
            localStorage.record = record;
            // меняем имя чемпиона
            recordName = name;
            // заносим в хранилище его имя
            localStorage.recordName = recordName;
          }

          // очищаем его и опускаем всё вниз на одну клетку
          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 showScore() {
      contextScore.clearRect(0,0,canvasScore.width,canvasScore.height);
      contextScore.globalAlpha = 1;
      contextScore.fillStyle = 'white';
      contextScore.font = '18px Courier New';
      contextScore.fillText('Уровень: ' + level, 15, 20);
      contextScore.fillText('Очков:   ' + score, 15, 50);
      contextScore.fillText('Чемпион: ' + recordName, 160, 20);
      contextScore.fillText('Рекорд:  ' + record, 160, 50);

    }

    // главный цикл игры
    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);
          }
        }
      }

      // выводим статистику
      showScore();

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

        // фигура сдвигается вниз каждые 36 кадров минус значение текущего уровня. Чем больше уровень, тем быстрее падает.
        if (++count > (36 - level)) {
          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>

Код:

Сти­вен Лам­бер и Миха­ил Полянин

Текст:

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

Редак­тор:

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

Худож­ник:

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

Кор­рек­тор:

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

Вёрст­ка:

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

Соц­се­ти:

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