🎮Тетрис на JavaScript: разбираем возможности языка через геймификацию
Чтобы провести разбор современных возможностей JavaScript, напишем собственную версию одной из самых популярных игр в мире - Тетриса.
Тетрис на JavaScript + изучение современных возможностей языка
Лучший способ узнавать новое и закреплять полученные знания – практика. Лучшая практика в программировании – создание игр. Лучшая игра в мире – Тетрис. Сегодня мы будем узнавать новое в процессе написания тетриса на javaScript.
В конце руководства у нас будет полностью функционирующая игра с уровнями сложности и системой очков. По ходу дела разберемся с важными игровыми концепциями, вроде графики и игрового цикла, а также научимся определять коллизии блоков и изучим возможности современного JavaScript (ES6):
Весь код проекта вы можете найти в github-репозитории.
Тетрис
Эта всемирно известная игра появилась в далеком 1984 году. Придумал ее русский программист Алексей Пажитнов. Правила очень просты и известны каждому. Сверху вниз падают фигурки разной формы, которые можно вращать и перемещать. Игрок должен складывать их внизу игрового поля. Если получается заполнить целый ряд, он пропадает. Игра заканчивается, когда башня из фигурок достигает верха игрового поля.
Тетрис – великолепный выбор для первого знакомства с гейм-разработкой. Он достаточно прост для программирования, но в то же время содержит все принципиальные игровые элементы. К тому же в нем максимально простая графика.
Структура проекта
Для удобства разобьем весь проект на отдельные файлы:
constants.js
– конфигурация и правила игры;board.js
- логика игрового поля:piece.js
– управление фрагментами-тетрамино;main.js
– инициализация и управление всей игрой;index.html
– веб-страница с html-кодом проекта и подключением ресурсов;styles.css
- стили для оформления игры;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
. В этом файле также укажем размер одного блока.
const COLS = 10; const ROWS = 20; const BLOCK_SIZE = 30;
Для отрисовки графики будем использовать холст – элемент HTML5 canvas. Добавим его в 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 для рисования:
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
:
<link href="https://fonts.googleapis.com/css?family=Press+Start+2P" rel="stylesheet" />
Теперь добавим основные стили в style.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
. Сразу же добавим пару важных методов:
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
:
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
для фигурок, чтобы отслеживать их положение на доске, а также хранить цвет и форму. Чтобы фигурки могли отрисовывать себя на поле, нужно передать им контекст рисования:
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
. Для отрисовки нужно перебрать все ячейки блока в двойном цикле:
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); } }); }); }
Итак, нарисуем первое тетрамино на поле:
function play() { board.reset(); let piece = new Piece(ctx); piece.draw(); board.piece = piece; }
Активная фигурка сохраняется в свойстве board.piece
для удобного доступа.
Управление с клавиатуры
Передвигать фигурки по полю (влево, вправо и вниз) можно с помощью клавиш-стрелок.
Добавим в класс Piece
метод move
, которые будет изменять текущие координаты тетрамино на поле.
move(p) { this.x = p.x; this.y = p.y; }
Перечисления
Коды клавиш будут храниться в файле constants.js
. Для этого удобно применять специальный тип данных – перечисление (enum). В JavaScript нет встроенных перечислений, поэтому мы воспользуемся обычным объектом:
const KEY = { LEFT: 37, RIGHT: 39, DOWN: 40 } Object.freeze(KEY);
Ключевое слово const
не работает с полями объектов, а лишь запрещает переприсваивание данных в переменную KEY
. Чтобы сделать константы в объекте неизменяемыми, мы используем метод Object.freeze(). При этом важно помнить две вещи:
- Для правильной работы этого метода нужен строгий режим выполнения;
- Метод работает только на один уровень вложенности.
Вычисляемые имена свойств
Теперь нужно сопоставить коды клавиш и действия, которые следует выполнить при их нажатии.
ES6 позволяет добавлять в объекты свойства с вычисляемыми именами. Другими словами, в имени свойства можно использовать переменные и даже выражения.
Для установки такого свойства нужны квадратные скобки:
const X = 'x'; const a = { [X]: 5 }; console.log(a.x); // 5
Для перемещения тетрамино мы будем стирать старое отображение и копировать его в новых координатах. Чтобы получить эти новые координаты, сначала скопируем текущие, а затем изменим нужную (x
или y
) на единицу.
Так как координаты являются примитивными значениями, мы можем использовать spread-оператор, чтобы перенести их в новый объект. В ES6 существует еще один механизм копирования: Object.assign().
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:
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()
будет реализован в следующем разделе. Его задача – определять допустимость новых координат на игровом поле.
valid(p) { return true; }
Обнаружение столкновений
Если бы фигурки тетриса могли проходить сквозь друг друга, а также сквозь пол и стены игрового поля, игра не имела бы смысла. Важно проверить возможные столкновения элементов перед изменением их положения.
Возможные столкновения одного тетрамино:
- с полом при движении вниз;
- со стенками игрового поля при движении вправо или влево;
- с другими тетрамино, уже размещенными на поле.
Фигурки можно будет вращать, поэтому при вращении тоже нужно учитывать возможные столкновения.
Мы уже умеем вычислять новую позицию фигурки на поле при нажатии клавиш-стрелок. Теперь нужно добавить проверку на ее допустимость. Для этого мы должны проверить все клетки, которые будет занимать тетрамино в новом положении.
Для такой проверки удобно использовать метод массива every(). Для каждой клетки в матрице тетрамино нужно определить абсолютные координаты на игровом поле, а затем проверить, свободно ли это место и не выходит ли оно за границы поля.
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) фигурок при нажатии на пробел. Тетрамино при этом будет падать пока не столкнется с чем-нибудь.
const KEY = { SPACE: 32, // ... }
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 это выглядит так:
// Транспонирование матрицы тетрамино 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-строку, а затем распарсим ее.
rotate(p){ // Клонирование матрицы let clone = JSON.parse(JSON.stringify(p)); // алгоритм вращения return clone; }
Теперь при нажатии на клавишу Вверх, активная фигурка будет вращаться:
const KEY = { UP: 38, // ... }
const moves = { [KEY.UP]: (p) => board.rotate(p), // ... }
Случайный выбор фигурок
Чтобы каждый раз появлялись разные фигурки, придется реализовать рандомизацию, следуя стандарту SRS (Super Rotation System).
Добавим цвета и формы фигурок в файл 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]] ];
Теперь нужно случайным образом выбрать порядковый номер тетрамино:
// параметр noOfTypes - количество вариантов randomizeTetrominoType(noOfTypes) { return Math.floor(Math.random() * noOfTypes); }
На этом этапе мы можем выбирать тип фигурки случайным образом при создании.
Добавим в класс Piece
метод spawn
:
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 и немного модифицируем его.
Для начала создадим объект для хранения нужной информации:
const time = { start: 0, elapsed: 0, level: 1000 };
В цикле мы будем обновлять это состояние и отрисовывать текущее отображение:
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(); }
draw() { this.piece.draw(); } drop() { let p = moves[KEY.DOWN](this.piece); if (this.valid(p)) { this.piece.move(p); } }
Заморозка состояния
При достижении активной фигуркой низа игрового поля, ее нужно «заморозить» в текущем положении и создать новое активное тетрамино.
Для этого добавим классу Board
метод freeze
. Он будет сохранять положение фигурки в матрице игрового поля:
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(); } }
Теперь при достижении фигуркой низа поля, мы увидим в консоли, что матрица самого поля изменилась:
Добавим метод для отрисовки целого поля (с уже «замороженными» тетрамино):
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); } }); }); }
Обратите внимание, что теперь объекту игрового поля тоже нужен контекст рисования, не забудьте передать его:
let board = new Board(ctx);
Очистка линий
Главная задача игры – собирать из блоков целые ряды, которые должны пропадать с поля, освобождая место для новых фигурок.
Добавим в класс Board метод для проверки, не собрана ли целая линия, которую можно удалить, и удаления всех таких линий:
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)); } }); }
Его нужно вызывать каждый раз после «заморозки» активного тетрамино при достижении низа игрового поля:
drop() { // ... this.freeze(); this.clearLines(); // ... }
Система баллов
Чтобы сделать игру еще интереснее, нужно добавить баллы за сбор целых рядов.
const POINTS = { SINGLE: 100, DOUBLE: 300, TRIPLE: 500, TETRIS: 800, SOFT_DROP: 1, HARD_DROP: 2 } Object.freeze(POINTS);
Чем больше рядов собрано за один цикл, тем больше будет начислено очков.
Для хранения информации создадим новый объект accountValues
.
let accountValues = { score: 0, }
При каждом изменении счета нужно обновлять данные на экране. Для этого мы обратимся к возможностям метапрограммирования в JavaScript – Proxy.
Прокси позволяет отслеживать обращение к свойствам объекта, например, для их чтения (get) или обновления (set) и реализовывать собственную логику:
// Обновление данных на экране 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
:
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; } }
и в метод очистки собранных рядов:
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); } }
Уровни
Чем лучше вы играете в тетрис, тем быстрее должны падать фигурки, чтобы вам не стало скучно. Придется добавить уровни сложности в нашу игру, постепенно увеличивая частоту фреймов игрового цикла.
const LINES_PER_LEVEL = 10; const LEVEL = { 0: 800, 1: 720, 2: 630, 3: 550, // ... } Object.freeze(LEVEL);
Информация на экране о количестве собранных линий и текущем уровне должна обновляться при каждом изменении. Для этого добавим два новых поля (lines
и levels
) в объект accountValues
.
let accountValues = { score: 0, lines: 0, level: 0 }
Они будут обновляться по той же схеме, что и score
, с помощью прокси-объекта.
Напишем отдельную функцию resetGame, в которую поместим всю логику для начала новой игры:
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(); }
Теперь нужно немного обновить логику начисления очков за собранные линии. С каждым уровнем очков должно быть больше.
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-координата тетрамино равна нулю, значит, ему уже некуда падать.
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
. Также нужно показать пользователю сообщение.
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); }
Следующая фигура
Для удобства игрока мы можем добавить подсказку – какая фигурка будет следующей. Для этого используем еще один холст меньшего размера:
<canvas id="next" class="next"></canvas>
Получим его контекст для рисования и установим размеры:
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
:
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(); }
Теперь игрок знает, какое тетрамино будет следующим, и может выстраивать стратегию игры.