🎮Тетрис на JavaScript: разбираем возможности языка через геймификацию

Чтобы провести разбор современных возможностей JavaScript, напишем собственную версию одной из самых популярных игр в мире - Тетриса.

Тетрис на JavaScript + изучение современных возможностей языка

Лучший способ узнавать новое и закреплять полученные знания – практика. Лучшая практика в программировании – создание игр. Лучшая игра в мире – Тетрис. Сегодня мы будем узнавать новое в процессе написания тетриса на javaScript.

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

  1. Классы
  2. Стрелочные функции
  3. Деструктуризация
  4. Let и const
  5. Прокси

Весь код проекта вы можете найти в github-репозитории.

Тетрис

Эта всемирно известная игра появилась в далеком 1984 году. Придумал ее русский программист Алексей Пажитнов. Правила очень просты и известны каждому. Сверху вниз падают фигурки разной формы, которые можно вращать и перемещать. Игрок должен складывать их внизу игрового поля. Если получается заполнить целый ряд, он пропадает. Игра заканчивается, когда башня из фигурок достигает верха игрового поля.

Тетрис – великолепный выбор для первого знакомства с гейм-разработкой. Он достаточно прост для программирования, но в то же время содержит все принципиальные игровые элементы. К тому же в нем максимально простая графика.

Структура проекта

Для удобства разобьем весь проект на отдельные файлы:

Файловая структура проекта
  1. constants.js – конфигурация и правила игры;
  2. board.js - логика игрового поля:
  3. piece.js – управление фрагментами-тетрамино;
  4. main.js – инициализация и управление всей игрой;
  5. index.html – веб-страница с html-кодом проекта и подключением ресурсов;
  6. styles.css - стили для оформления игры;
  7. README.md.

Сразу же подключим все нужное в index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>JavaScript Tetris</title>
  <link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
 

  <script type="text/javascript" src="constants.js"></script>
  <script type="text/javascript" src="board.js"></script>
  <script type="text/javascript" src="piece.js"></script>
  <script type="text/javascript" src="main.js"></script>
</body>
</html>

Создание каркаса

Игровое поле состоит из 10 колонок и 20 рядов. Эти значения будут часто использоваться, поэтому мы сделаем их константами и вынесем в отдельный файл constants.js. В этом файле также укажем размер одного блока.

constants.js
const COLS = 10;
const ROWS = 20;
const BLOCK_SIZE = 30;

Для отрисовки графики будем использовать холст – элемент HTML5 canvas. Добавим его в html-файл с инфраструктурой будущей игры:

index.html
<div class="grid">
  <canvas id="board" class="game-board"></canvas>
  <div class="right-column">
    <div>
      <h1>TETRIS</h1>
      <p>Score: <span id="score">0</span></p>
      <p>Lines: <span id="lines">0</span></p>
      <p>Level: <span id="level">0</span></p>
      <canvas id="next" class="next"></canvas>
    </div>
    <button onclick="play()" class="play-button">Play</button>
  </div>
</div>

Теперь в главном скрипте проекта main.js нужно найти элемент холста и получить контекст 2D для рисования:

main.js
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');

// Устанавливаем размеры холста
ctx.canvas.width = COLS * BLOCK_SIZE;
ctx.canvas.height = ROWS * BLOCK_SIZE;

// Устанавливаем масштаб
ctx.scale(BLOCK_SIZE, BLOCK_SIZE);

Здесь мы используем установленные ранее константные значения.

Метод scale используется, чтобы избежать постоянного умножения всех значений на BLOCK_SIZE и упростить код.

Оформление

Для оформления такой ретро-игры идеально подходит пиксельный стиль, поэтому мы будем использовать шрифт Press Start 2P. Подключите его в секции head:

index.html
<link 
  href="https://fonts.googleapis.com/css?family=Press+Start+2P" 
  rel="stylesheet"
/>

Теперь добавим основные стили в style.css:

styles.css
* {
  font-family: 'Press Start 2P', cursive;
}

.grid {
  display: grid;
  grid-template-columns: 320px 200px;
}

.right-column {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.game-board {
  border: solid 2px;
}

.play-button {
  background-color: #4caf50;
  font-size: 16px;
  padding: 15px 30px;
  cursor: pointer;
}

Для разметки используются системы CSS Grid и Flexbox.

Вот, что у нас получилось:

Пустое игровое поле

Игровое поле

Поле состоит из клеточек, у которых есть два состояния: занята и свободна. Можно было бы просто представить клетку булевым значением, но мы собираемся раскрасить каждую фигурку в свой цвет. Лучше использовать числа: пустая клетка – 0, а занятая – от 1 до 7, в зависимости от цвета.

Само поле будет представлено в виде двумерного массива (матрицы). Каждый ряд – массив клеток, а массив рядов – это, собственно, поле.

Создадим отдельный класс для представления игрового поля – Board и разместим его в файле board.js. Сразу же добавим пару важных методов:

board.js
class Board {
  constructor() {
    this.piece = null;
  }
  
  // Сбрасывает игровое поле перед началом новой игры
  reset() {
    this.grid = this.getEmptyBoard();
  }
  
  // Создает матрицу нужного размера, заполненную нулями
  getEmptyBoard() {
    return Array.from(
      {length: ROWS}, () => Array(COLS).fill(0)
    );
  }
}

Для создания пустой матрицы поля и заполнения ее нулями используются методы массивов: Array.from() и Array.fill().

Теперь создадим экземпляр класса Board в основном файле игры.

Функция play будет вызвана при нажатии на кнопку Play. Она очистит игровое поле с помощью метода reset:

main.js
let board = new Board();

function play() {  
  board.reset();  
  // наглядное представление матрицы игрового поля
  console.table(board.grid);  
}

Для наглядного представления матрицы удобно использовать метод console.table:

Тетрамино

Каждая фигурка в тетрисе состоит из четырех блоков и называется тетрамино. Всего комбинаций семь – дадим каждой из них имя (I, J, L, O, S, T, Z) и свой цвет:

Для удобства вращения каждое тетрамино будет представлено в виде квадратной матрицы 3х3. Например, J-тетрамино выглядит так:

[2, 0, 0],
[2, 2, 2],
[0, 0, 0];

Для представления I-тетрамино потребуется матрица 4x4.

Заведем отдельный класс Piece для фигурок, чтобы отслеживать их положение на доске, а также хранить цвет и форму. Чтобы фигурки могли отрисовывать себя на поле, нужно передать им контекст рисования:

piece.js
class Piece {  
  constructor(ctx) {
    this.ctx = ctx;
    this.color = 'blue';
    this.shape = [
      [2, 0, 0], 
      [2, 2, 2], 
      [0, 0, 0]
    ];
    
    // Начальная позиция
    this.x = 3;
    this.y = 0;
  }
}

Блок фигурки занимает несколько клеток на поле, но раскрашены должны быть только те, значение которых больше 0. Для отрисовки нужно перебрать все ячейки блока в двойном цикле:

piece.js
draw() {
  this.ctx.fillStyle = this.color;
  this.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      // this.x, this.y - левый верхний угол фигурки на игровом поле
      // x, y - координаты ячейки относительно матрицы фигурки (3х3)
      // this.x + x - координаты ячейки на игровом поле
      if (value > 0) {
        this.ctx.fillRect(this.x + x, this.y + y, 1, 1);
      }
    });
  });
}

Итак, нарисуем первое тетрамино на поле:

main.js
function play() {
  board.reset();
  let piece = new Piece(ctx);
  piece.draw();
  
  board.piece = piece;
}

Активная фигурка сохраняется в свойстве board.piece для удобного доступа.

Первое тетрамино на поле

Управление с клавиатуры

Передвигать фигурки по полю (влево, вправо и вниз) можно с помощью клавиш-стрелок.

Добавим в класс Piece метод move, которые будет изменять текущие координаты тетрамино на поле.

piece.js
move(p) {
  this.x = p.x;
  this.y = p.y;
}

Перечисления

Коды клавиш будут храниться в файле constants.js. Для этого удобно применять специальный тип данных – перечисление (enum). В JavaScript нет встроенных перечислений, поэтому мы воспользуемся обычным объектом:

constants.js
const KEY = {
  LEFT: 37,
  RIGHT: 39,
  DOWN: 40
}
Object.freeze(KEY);

Ключевое слово const не работает с полями объектов, а лишь запрещает переприсваивание данных в переменную KEY. Чтобы сделать константы в объекте неизменяемыми, мы используем метод Object.freeze(). При этом важно помнить две вещи:

  1. Для правильной работы этого метода нужен строгий режим выполнения;
  2. Метод работает только на один уровень вложенности.

Вычисляемые имена свойств

Теперь нужно сопоставить коды клавиш и действия, которые следует выполнить при их нажатии.

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

Для установки такого свойства нужны квадратные скобки:

const X = 'x';
const a = { [X]: 5 };
console.log(a.x); // 5

Для перемещения тетрамино мы будем стирать старое отображение и копировать его в новых координатах. Чтобы получить эти новые координаты, сначала скопируем текущие, а затем изменим нужную (x или y) на единицу.

Так как координаты являются примитивными значениями, мы можем использовать spread-оператор, чтобы перенести их в новый объект. В ES6 существует еще один механизм копирования: Object.assign().

main.js
const moves = {
  [KEY.LEFT]:  p => ({ ...p, x: p.x - 1 }),
  [KEY.RIGHT]: p => ({ ...p, x: p.x + 1 }),
  [KEY.DOWN]:    p => ({ ...p, y: p.y + 1 })
};

В объекте moves теперь хранятся функции вычисления новых координат для каждой клавиши. Получить их можно так:

const p = this.moves[event.key](this.piece);

Очень важно, что при этом не меняются текущие координаты самого тетрамино, так как нажатие клавиши не всегда будет приводить к реальному изменению положения.

Теперь добавим обработчик для события keydown:

main.js
document.addEventListener('keydown', event => {
  if (moves[event.keyCode]) {  
    // отмена действий по умолчанию
    event.preventDefault();
    
    // получение новых координат фигурки
    let p = moves[event.keyCode](board.piece);
    
    // проверка нового положения
    if (board.valid(p)) {    
      // реальное перемещение фигурки, если новое положение допустимо
      board.piece.move(p);
      
      // стирание старого отображения фигуры на холсте
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
      
      board.piece.draw();
    }
  }
});

Метод board.valid() будет реализован в следующем разделе. Его задача – определять допустимость новых координат на игровом поле.

board.js
valid(p) {
  return true;
}
Управление с клавиатуры

Обнаружение столкновений

Если бы фигурки тетриса могли проходить сквозь друг друга, а также сквозь пол и стены игрового поля, игра не имела бы смысла. Важно проверить возможные столкновения элементов перед изменением их положения.

Возможные столкновения одного тетрамино:

  1. с полом при движении вниз;
  2. со стенками игрового поля при движении вправо или влево;
  3. с другими тетрамино, уже размещенными на поле.

Фигурки можно будет вращать, поэтому при вращении тоже нужно учитывать возможные столкновения.

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

Для такой проверки удобно использовать метод массива every(). Для каждой клетки в матрице тетрамино нужно определить абсолютные координаты на игровом поле, а затем проверить, свободно ли это место и не выходит ли оно за границы поля.

board.js
insideWalls(x) {
  return x >= 0 && x < COLS;
}

aboveFloor(y) {
  return y <= ROWS;
}

// не занята ли клетка поля другими фигурками
notOccupied(x, y) {
  return this.grid[y] && this.grid[y][x] === 0;
}

valid(p) {
  return p.shape.every((row, dy) => {
    return row.every((value, dx) => {
      let x = p.x + dx;
      let y = p.y + dy;
      return value === 0 ||
          (this.insideWalls(x) && this.aboveFloor(y) && this.notOccupied(x, y));
    });
  });
}

Пустые клетки матрицы тетрамино при этом не учитываются.

Если проверка прошла удачно, передвигаем фигурку в новое место.

if (this.valid(p)) {
  this.piece.move(p);
}
Обнаружение столкновений
***

Теперь мы можем добавить возможность ускоренного падения (hard drop) фигурок при нажатии на пробел. Тетрамино при этом будет падать пока не столкнется с чем-нибудь.

constants.js
const KEY = {  
  SPACE: 32,
  // ...
}
main.js
moves = {  
  [KEY.SPACE]: p => ({ ...p, y: p.y + 1 })
  // ...
};

// В обработчике события keydown
if (event.keyCode === KEY.SPACE) {
  // Жесткое падение
  while (board.valid(p)) {
    board.piece.move(p);   

    // стирание старого отображения фигуры на холсте
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    board.piece.draw();

    p = moves[KEY.DOWN](board.piece);
  }
} else if (board.valid(p)) {
  // ...
}

Вращение

Фигурки можно вращать относительно их «центра масс»:

Вращение тетрамино относительно центра

Чтобы реализовать такую возможность, нам понадобятся базовые знания линейной алгебры. Мы должны транспонировать матрицу, а затем умножить ее на матрицу преобразования, которая изменит порядок столбцов.

Вращение тетрамино в двумерном пространстве

На JavaScript это выглядит так:

board.js
// Транспонирование матрицы тетрамино
for (let y = 0; y < p.shape.length; ++y) {
  for (let x = 0; x < y; ++x) {
    [p.shape[x][y], p.shape[y][x]] = 
    [p.shape[y][x], p.shape[x][y]];
  }
}

// Изменение порядка колонок
p.shape.forEach(row => row.reverse());

Эту функцию можно использовать для вращения фигурок, но перед началом манипуляций с матрицей, ее нужно скопировать, чтобы не допускать мутаций. Вместо spread-оператора, который работает лишь на один уровень в глубину, мы используем трюк с сериализацией – превратим матрицу в JSON-строку, а затем распарсим ее.

board.js
rotate(p){
  // Клонирование матрицы
  let clone = JSON.parse(JSON.stringify(p));
  
  // алгоритм вращения
  
  return clone;
}

Теперь при нажатии на клавишу Вверх, активная фигурка будет вращаться:

constants.js
const KEY = {  
  UP: 38,
  // ...
}
main.js
const moves = {
  [KEY.UP]: (p) => board.rotate(p),
  // ...
}
Вращение фигурки при нажатии на клавишу Вверх

Случайный выбор фигурок

Чтобы каждый раз появлялись разные фигурки, придется реализовать рандомизацию, следуя стандарту SRS (Super Rotation System).

Добавим цвета и формы фигурок в файл constants.js:

constants.js
const COLORS = [  
  'cyan',
  'blue',
  'orange',
  'yellow',
  'green',
  'purple',
  'red'
];

const SHAPES = [
  [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
  [[2, 0, 0], [2, 2, 2], [0, 0, 0]],
  [[0, 0, 3], [3, 3, 3], [0, 0, 0]],
  [[4, 4], [4, 4]],
  [[0, 5, 5], [5, 5, 0], [0, 0, 0]],
  [[0, 6, 0], [6, 6, 6], [0, 0, 0]],
  [[7, 7, 0], [0, 7, 7], [0, 0, 0]]
];

Теперь нужно случайным образом выбрать порядковый номер тетрамино:

piece.js
// параметр noOfTypes - количество вариантов
randomizeTetrominoType(noOfTypes) {
  return Math.floor(Math.random() * noOfTypes);
}

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

Добавим в класс Piece метод spawn:

piece.js
constructor(ctx) {
  this.ctx = ctx;
  this.spawn();
}

spawn() {
  this.typeId = this.randomizeTetrominoType(COLORS.length - 1);
  this.shape = SHAPES[this.typeId];
  this.color = COLORS[this.typeId];
  this.x = 0;
  this.y = 0;
}

// расположить фигурку в центре поля
setStartPosition() {
  this.x = this.typeId === 4 ? 4 : 3;
}

Игровой цикл

Почти во всех играх есть одна главная функция, которая постоянно делает что-то, даже если игрок пассивен – это игровой цикл. Нам он тоже понадобится, чтобы фигурки постоянно генерировались и падали сверху вниз на игровом поле.

RequestAnimationFrame

Для совершения циклических действий удобно использовать метод requestAnimationFrame. Он сообщает браузеру о том, что нужно сделать, а браузер выполняет это во время следующей перерисовки экрана.

Мы создадим функцию animate, которая будет производить все необходимые перерисовки для одного цикла анимации, а затем рекурсивно вызовет сама себя для выполнения перерисовок следующего цикла. Самый первый вызов функции animate будет происходить внутри функции play.

animate() {
  board.piece.draw();
  requestAnimationFrame(this.animate.bind(this));
}

Таймер

Нам также потребуется таймер, чтобы в каждом фрейме анимации "ронять" активное тетрамино вниз. Возьмем готовый пример с MDN и немного модифицируем его.

Для начала создадим объект для хранения нужной информации:

main.js
const time = { start: 0, elapsed: 0, level: 1000 };

В цикле мы будем обновлять это состояние и отрисовывать текущее отображение:

main.js
function animate(now = 0) {
  // обновить истекшее время
  time.elapsed = now - time.start;
  
  // если время отображения текущего фрейма прошло 
  if (time.elapsed > time.level) {
  
    // начать отсчет сначала
    time.start = now;   
    
    // "уронить" активную фигурку
    board.drop();  
  }
  
  // очистить холст для отрисовки нового фрейма
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
  
  // отрисовать игровое поле 
  board.draw();  
  requestAnimationFrame(animate);
}

function play() {
  board.reset();
  let piece = new Piece(ctx);
  board.piece = piece;
  board.piece.setStartPosition();
  animate();
}
board.js
draw() {
  this.piece.draw();
}

drop() {
  let p = moves[KEY.DOWN](this.piece);
  if (this.valid(p)) {
    this.piece.move(p);
  }
}

Заморозка состояния

При достижении активной фигуркой низа игрового поля, ее нужно «заморозить» в текущем положении и создать новое активное тетрамино.

Для этого добавим классу Board метод freeze. Он будет сохранять положение фигурки в матрице игрового поля:

board.js
freeze() {
  this.piece.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.grid[y + this.piece.y][x + this.piece.x] = value;
      }
    });
  });
}

drop() {
  let p = moves[KEY.DOWN](this.piece);
  if (this.valid(p)) {
    this.piece.move(p);
  } else {
    this.freeze();
    console.table(this.grid);

    this.piece = new Piece(this.ctx);
    this.piece.setStartPosition();
  }
}

Теперь при достижении фигуркой низа поля, мы увидим в консоли, что матрица самого поля изменилась:

Добавим метод для отрисовки целого поля (с уже «замороженными» тетрамино):

board.js
constructor(ctx) {
  this.ctx = ctx; 
  this.piece = null;
}

draw() {
  this.piece.draw();
  this.drawBoard();
}

drawBoard() {
  this.grid.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.ctx.fillStyle = COLORS[value];
        this.ctx.fillRect(x, y, 1, 1);
      }
    });
  });
}

Обратите внимание, что теперь объекту игрового поля тоже нужен контекст рисования, не забудьте передать его:

main.js
let board = new Board(ctx);
Отрисовка уже размещенных тетрамино

Очистка линий

Главная задача игры – собирать из блоков целые ряды, которые должны пропадать с поля, освобождая место для новых фигурок.

Добавим в класс Board метод для проверки, не собрана ли целая линия, которую можно удалить, и удаления всех таких линий:

board.js
clearLines() {
  let lines = 0;

  this.grid.forEach((row, y) => {
    // Если все клетки в ряду заполнены
    if (row.every(value => value > 0)) {
      lines++;

      // Удалить этот ряд
      this.grid.splice(y, 1);

      // Добавить наверх поля новый пустой ряд клеток
      this.grid.unshift(Array(COLS).fill(0));
    }
  });
}

Его нужно вызывать каждый раз после «заморозки» активного тетрамино при достижении низа игрового поля:

board.js
drop() {
  // ...
  this.freeze();
  this.clearLines();
  // ...
}
Удаление собранных рядов

Система баллов

Чтобы сделать игру еще интереснее, нужно добавить баллы за сбор целых рядов.

constants.js
const POINTS = {
  SINGLE: 100,
  DOUBLE: 300,
  TRIPLE: 500,
  TETRIS: 800,
  SOFT_DROP: 1,
  HARD_DROP: 2
}
Object.freeze(POINTS);

Чем больше рядов собрано за один цикл, тем больше будет начислено очков.

Для хранения информации создадим новый объект accountValues.

main.js
let accountValues = {
  score: 0,
}

При каждом изменении счета нужно обновлять данные на экране. Для этого мы обратимся к возможностям метапрограммирования в JavaScript – Proxy.

Прокси позволяет отслеживать обращение к свойствам объекта, например, для их чтения (get) или обновления (set) и реализовывать собственную логику:

main.js
// Обновление данных на экране
function updateAccount(key, value) {
  let element = document.getElementById(key);
  if (element) {
    element.textContent = value;
  }
}

// Проксирование доступа к свойствам accountValues
let account = new Proxy(accountValues, {
  set: (target, key, value) => {
    target[key] = value;
    updateAccount(key, value);
    return true;
  }
});

Теперь при каждом изменении свойств объекта account будет вызываться функция updateAccount.

Добавим логику начисления очков в обработчик события keydown:

main.js
if (event.keyCode === KEY.SPACE) {
  while (board.valid(p)) {
    account.score += POINTS.HARD_DROP;
    board.piece.move(p);
    p = moves[KEY.DOWN](board.piece);
  }
} else if (board.valid(p)) {
  board.piece.move(p);
  if (event.keyCode === KEY.DOWN) {
    account.score += POINTS.SOFT_DROP;
  }
}

и в метод очистки собранных рядов:

board.js
getLineClearPoints(lines) {  
  return lines === 1 ? POINTS.SINGLE :
         lines === 2 ? POINTS.DOUBLE :  
         lines === 3 ? POINTS.TRIPLE :     
         lines === 4 ? POINTS.TETRIS : 
         0;
}

clearLines() {
  // ...

  if (lines > 0) {    
    // Добавить очки за собранные линии
    account.score += this.getLineClearPoints(lines);  
  }
}

Уровни

Чем лучше вы играете в тетрис, тем быстрее должны падать фигурки, чтобы вам не стало скучно. Придется добавить уровни сложности в нашу игру, постепенно увеличивая частоту фреймов игрового цикла.

constants.js
const LINES_PER_LEVEL = 10;

const LEVEL = {
  0: 800,
  1: 720,
  2: 630,
  3: 550,
  // ...
}

Object.freeze(LEVEL);

Информация на экране о количестве собранных линий и текущем уровне должна обновляться при каждом изменении. Для этого добавим два новых поля (lines и levels) в объект accountValues.

main.js
let accountValues = {
  score: 0,
  lines: 0,
  level: 0
}

Они будут обновляться по той же схеме, что и score, с помощью прокси-объекта.

Напишем отдельную функцию resetGame, в которую поместим всю логику для начала новой игры:

main.js
function resetGame() {
  account.score = 0;
  account.lines = 0;
  account.level = 0;
  board.reset();
  let piece = new Piece(ctx);
  board.piece = piece;
  board.piece.setStartPosition();
}

function play() {
  resetGame();



  animate();
}

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

board.js
getLinesClearedPoints(lines, level) {
  const lineClearPoints =
    lines === 1
      ? POINTS.SINGLE
      : lines === 2
      ? POINTS.DOUBLE
      : lines === 3
      ? POINTS.TRIPLE
      : lines === 4
      ? POINTS.TETRIS
      : 0;

  return (level + 1) * lineClearPoints;
}

clearLines() {
  // ...
  if (lines > 0) {
    // Добавить очки за собранные линии
    account.score += this.getLinesClearPoints(lines, account.level);
    account.lines += lines;

    // Если собрано нужное кол-во линий, перейти на новый уровень
    if (account.lines >= LINES_PER_LEVEL) {
      // увеличить уровень
      account.level++;
      // сбросить счетчик линий
      account.lines -= LINES_PER_LEVEL;
      // увеличить скорость
      time.level = LEVEL[account.level];
    }
  }
}

При сборке каждых десяти рядов, уровень будет повышаться, а скорость – увеличиваться.

Завершение игры

Игра завершается, когда пирамида фигурок достигает самого верха игрового поля.

Мы должны добавить проверку внутри методаboard.drop. Если y-координата тетрамино равна нулю, значит, ему уже некуда падать.

board.js
drop() {
  let p = moves[KEY.DOWN](this.piece);
  if (this.valid(p)) {
    this.piece.move(p);
  } else {
    this.freeze();
    this.clearLines();
    console.table(this.grid);

    if (this.piece.y === 0) {
      // Game over
      return false;
    }

    this.piece = new Piece(this.ctx);
    this.piece.setStartPosition();
  }

  return true;
}

Теперь метод drop возвращает false, если игру пора останавливать, и true – в остальных случаях.

Чтобы прервать игровой цикл, нужно отменить последний вызов requestAnimationFrame. Также нужно показать пользователю сообщение.

main.js
let requestId;

function gameOver() {
  cancelAnimationFrame(requestId);
  this.ctx.fillStyle = 'black';
  this.ctx.fillRect(1, 3, 8, 1.2);
  this.ctx.font = '1px Arial';
  this.ctx.fillStyle = 'red';
  this.ctx.fillText('GAME OVER', 1.8, 4);
}

function animate(now = 0) {
  time.elapsed = now - time.start;
  if (time.elapsed > time.level) {
    time.start = now;

    if (!board.drop()) {
      gameOver();
      return;
    }
  }

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  board.draw();
  requestId = requestAnimationFrame(animate);
}
Сообщение об окончании игры

Следующая фигура

Для удобства игрока мы можем добавить подсказку – какая фигурка будет следующей. Для этого используем еще один холст меньшего размера:

index.html
<canvas id="next" class="next"></canvas>

Получим его контекст для рисования и установим размеры:

main.js
const canvasNext = document.getElementById('next');
const ctxNext = canvasNext.getContext('2d');

ctxNext.canvas.width = 4 * BLOCK_SIZE;
ctxNext.canvas.height = 4 * BLOCK_SIZE;
ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE);

let board = new Board(ctx, ctxNext);

Осталось внести изменения в метод board.drop:

board.js
constructor(ctx, ctxNext) {
  this.ctx = ctx;
  this.ctxNext = ctxNext;
  this.piece = null;
  this.next = null;
}

reset() {
  this.grid = this.getEmptyBoard();
  this.piece = new Piece(this.ctx);
  this.piece.setStartPosition();
  this.getNewPiece();
}

getNewPiece() {
  this.next = new Piece(this.ctxNext);
  this.ctxNext.clearRect(
    0,
    0, 
    this.ctxNext.canvas.width, 
    this.ctxNext.canvas.height
  );
  this.next.draw();
}

drop() {
  // ...
  this.freeze();
  // ...

  this.piece = this.next;
  this.piece.ctx = this.ctx;
  this.piece.setStartPosition();
  this.getNewPiece();
}

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

Подсказка о следующей фигурке

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
10 июня 2018

Лайфхак: в какой последовательности изучать JavaScript

Огромный инструментарий JS и тонны материалов по нему. С чего начать? Расск...
admin
29 января 2017

Изучаем алгоритмы: полезные книги, веб-сайты, онлайн-курсы и видеоматериалы

В этой подборке представлен список книг, веб-сайтов и онлайн-курсов, дающих...