furry.cat 23 ноября 2020

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

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

Тетрис на 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();
}
    

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

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

Источники

МЕРОПРИЯТИЯ

Разработка игр - интересный и эффективный способ изучения программирования. А как учитесь вы?

ВАКАНСИИ

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

BUG