Learn Object-Oriented Programming In JavaScript by Creating Tetris (9)

Ravi da N.
8 min readMay 13, 2023

This will be the final installment of this series. Let’s complete the Tetris program together!

Here is a link to articles in this series: Previous Article

First, let’s make sure the blocks stack up. We add the ability for Mino to answer queries for the current block location as follows. I will attach the code for the entire completed program at the end of this article.

Please add the one highlighted line above to Mino.

Using the function getCurPos listed above, gFieldJdg records the existence of the blocks in the array _field as in the list below. Please add the method this.merge to gFieldJdg.

const gFieldJdg = new function() {
const _field = [];

// (omitted)

this.merge = (mino) => {
const [fposLeftTop, fpos4] = mino.getCurFPos();
for (let i = 0; i < 4; i++) {
_field[fposLeftTop + fpos4[i]] = true;
}
}
}

With just the additional code above, it is now possible to record the stacking of blocks. We will add just one line to gGame.run so that Mino.merge is called when a Mino lands.

Please add the one highlighted line above to gGame.

Now we can see the blocks building up as follows!

In addition, we will make sure to exit the game loop when the pile of Minos reaches the top. When the method Mino.drawAtStartPos is called to draw the next Mino, we will make its return value indicate whether the game can continue. Please add one line highlighted in the figure below to Mino.

We modify again the method gGame.run that has just been modified as follows. When the return value of Mino.drawAtStartPos is false, we will make the program exit the game loop.

The game now ends when the blocks are stacked to the top!

Next, when we find horizontal lines completed by filling all the cells within a line with tetriminos, we should clear them. We put the rows with a completed horizontal line into the array ret_rows as in the list below, and make gFieldJdg.merge return that array.

const gFieldJdg = new function() {

(omitted)

this.merge = (mino) => {
const [fposLeftTop, fpos4] = mino.getCurFPos();
for (let i = 0; i < 4; i++) {
_field[fposLeftTop + fpos4[i]] = true;
}

const ret_rows = [];
let row = Math.floor(fposLeftTop / g.PCS_FIELD_COL);
for (let i = Math.min(4, g.PCS_ROW - row); i > 0; i--, row++) {

const fpos_end = row * g.PCS_FIELD_COL + g.PCS_COL;
for (let fpos = fpos_end - g.PCS_COL + 1;; fpos++) {
if (_field[fpos] == false) { break; }
if (fpos == fpos_end) {
ret_rows.push(row);
break;
}
}
}
return ret_rows;
}
}

We should create a erasing lines method in gFieldJdg and gFieldGfx, which lines are specified in the array received as the return value from gFieldJdg.merge.

const gFieldJdg = new function() {

// (omitted)

this.eraseRows = (rowsToErase) => {
for (let row of rowsToErase) {
_field.copyWithin(g.PCS_FIELD_COL, 0, row * g.PCS_FIELD_COL);
_field.fill(false, 1, g.PCS_FIELD_COL - 1);
}
}
}

The signature of copyWithin is copyWithin(target, start, end). target is the index at which to copy the sequence to. start is the index at which to start copying elements from. end is the index at which to end copying elements from. We must be aware of that copyWithin copies up to but not including end.

The signature of fill is fill(value, start, end). As for the meaning of value and start, you guessed it. The meaning of end is also roughly as you might imagine, but note that, like end in copyWithin as before, fill fills up to but not including end. For example, the result of [0,1,2,3,4].fill(-1,1,3) is [0,-1,-1,3,4].

const gFieldGfx = new function() {

// (omitted)

const _pxWidthFieldInner = g.Px_BLOCK * g.PCS_COL;
this.eraseRows = (rowsToErase) => {
_ctx.fillStyle = 'black';
for (let row of rowsToErase) {
const imgToMove = _ctx.getImageData(0, 0, pxWidthField, row * g.Px_BLOCK);
_ctx.putImageData(imgToMove, 0, g.Px_BLOCK);
_ctx.fillRect(g.Px_BLOCK, 0, _pxWidthFieldInner, g.Px_BLOCK);
}
}
}

The signature of getImageData is getImageData(x, y, width, hight), and the signature of putImageData is putImageData(imageData, x, y), which imageData is the object retrieved by using getImageData.

Finally, after calling merge in the game loop, we should call the two functions we just created. Please add the highlighted lines as in the list below.

We have finally completed the Tetris program! I think you could find the program, which was about 300 lines, to be interesting. I would be happy if the experience working on this program could be of any help to you. Thank you for your interest in this article.

<!-- tetris.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tetris</title>
</head>
<body>
</body>
<script src="tetris.js"></script>
</html>
// tetris.js
'use strict';
{
const divTitle = document.createElement('div');
divTitle.textContent = "TETRIS";
document.body.appendChild(divTitle);
}

const g = {
Px_BLOCK: 30,
Px_BLOCK_INNER: 28,

PCS_COL: 10,
PCS_ROW: 20,
PCS_FIELD_COL: 12,

MSEC_GAME_INTERVAL: 1000,
}

const gFieldGfx = new function() {
const pxWidthField = g.Px_BLOCK * g.PCS_FIELD_COL;
const pxHeightField = g.Px_BLOCK * (g.PCS_ROW + 1);

const canvas = document.createElement('canvas');
canvas.width = pxWidthField;
canvas.height = pxHeightField;
document.body.appendChild(canvas);

const _ctx = canvas.getContext('2d');
_ctx.fillStyle = "black";
_ctx.fillRect(0, 0, pxWidthField, pxHeightField);

const yBtmBlk = g.Px_BLOCK * g.PCS_ROW;
const xRightBlk = pxWidthField - g.Px_BLOCK + 1;

_ctx.fillStyle = 'gray';
for (let y = 1; y < yBtmBlk; y += g.Px_BLOCK) {
_ctx.fillRect(1, y, g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
_ctx.fillRect(xRightBlk, y, g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
}

for (let x = 1; x < pxWidthField; x += g.Px_BLOCK) {
_ctx.fillRect(x, yBtmBlk + 1, g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
}

this.context2d = _ctx;
this.canvas = canvas;

const _pxWidthFieldInner = g.Px_BLOCK * g.PCS_COL;
this.eraseRows = (rowsToErase) => {
_ctx.fillStyle = 'black';
for (let row of rowsToErase) {
const imgToMove = _ctx.getImageData(0, 0, pxWidthField, row * g.Px_BLOCK);
_ctx.putImageData(imgToMove, 0, g.Px_BLOCK);
_ctx.fillRect(g.Px_BLOCK, 0, _pxWidthFieldInner, g.Px_BLOCK);
}
}
}

const gFieldJdg = new function() {
const _field = [];

for (let y = 0; y < 20; y++) {
_field.push(true);
for (let x = 0; x < 10; x++) {
_field.push(false);
}
_field.push(true);
}
for (let x = 0; x < 12; x++) {
_field.push(true);
}

this.chkToPut = (fposLeftTop, fpos4) => {
for (let i = 0; i < 4; i++) {
if (_field[fposLeftTop + fpos4[i]]) { return false; }
}
return true;
}

this.merge = (mino) => {
const [fposLeftTop, fpos4] = mino.getCurFPos();
for (let i = 0; i < 4; i++) {
_field[fposLeftTop + fpos4[i]] = true;
}

const ret_rows = [];
let row = Math.floor(fposLeftTop / g.PCS_FIELD_COL);
for (let i = Math.min(4, g.PCS_ROW - row); i > 0; i--, row++) {

const fpos_end = row * g.PCS_FIELD_COL + g.PCS_COL;
for (let fpos = fpos_end - g.PCS_COL + 1;; fpos++) {
if (_field[fpos] == false) { break; }
if (fpos == fpos_end) {
ret_rows.push(row);
break;
}
}
}
return ret_rows;
}

this.eraseRows = (rowsToErase) => {
for (let row of rowsToErase) {
_field.copyWithin(g.PCS_FIELD_COL, 0, row * g.PCS_FIELD_COL);
_field.fill(false, 1, g.PCS_FIELD_COL - 1);
}
}
}

const gMinos = new function() {
const rotater3 = createRotater(3);
const rotater4 = createRotater(4);

const _minos = [
new Mino('magenta', [1,0, 0,1, 1,1, 2,1], rotater3),
new Mino('blue' , [0,0, 0,1, 1,1, 2,1], rotater3),
new Mino('orange' , [2,0, 0,1, 1,1, 2,1], rotater3),
new Mino('green' , [1,0, 2,0, 0,1, 1,1], rotater3),
new Mino('red' , [0,0, 1,0, 1,1, 2,1], rotater3),
new Mino('cyan' , [0,1, 1,1, 2,1, 3,1], rotater4),
new Mino('yellow' , [1,0, 2,0, 1,1, 2,1], rotater0),
];
this.getNextMino = () => _minos[Math.floor(Math.random() * 7)];

function createRotater(minoSize)
{
return (blkpos8) => {
for (let idx = 0; idx < 8; idx += 2) {
const old_x = blkpos8[idx];
blkpos8[idx] = minoSize - 1 - blkpos8[idx + 1];
blkpos8[idx + 1] = old_x;
}
}
}

function rotater0(blkpos8) {}
}

function Mino(color, blkpos8, rotaterBlkpos8) {
const [_fposRotates, pxposRotates] = createRotates(blkpos8, rotaterBlkpos8);
const _minoGfx = new MinoGfx(color, pxposRotates);

let _fposLeftTop = 0;
let _fposDir = 0;
this.getCurFPos = () => [_fposLeftTop, _fposRotates[_fposDir]];

this.drawAtStartPos = () => {
_fposLeftTop = 4;
_fposDir = 0;

_minoGfx.setToStartPos();
_minoGfx.draw();

return gFieldJdg.chkToPut(_fposLeftTop, _fposRotates[_fposDir]);
};

this.move = (dx, dy) => {
const posUpdating = _fposLeftTop + dx + dy * g.PCS_FIELD_COL;
if (gFieldJdg.chkToPut(posUpdating, _fposRotates[_fposDir]) == false) {
return false;
}
_fposLeftTop = posUpdating;

_minoGfx.erase();
_minoGfx.move(dx, dy);
_minoGfx.draw();
return true;
}

this.rotate = (dir) => {
const dirUpdating = (_fposDir + dir + 4) % 4;
if (gFieldJdg.chkToPut(_fposLeftTop, _fposRotates[dirUpdating]) == false) {
return;
}
_fposDir = dirUpdating;

_minoGfx.erase();
_minoGfx.rotate(dirUpdating);
_minoGfx.draw();
}

function createRotates(blkpos8, rotaterBlkpos8) {
const ret_fpos = [];
const ret_pxpos = [];

for (let r = 0; r < 4; r++) {
const fpos4 = [];
for (let idx = 0; idx < 8; idx += 2) {
fpos4.push(blkpos8[idx] + blkpos8[idx + 1] * g.PCS_FIELD_COL);
}
ret_fpos.push(fpos4);
ret_pxpos.push([...blkpos8].map(x => x * g.Px_BLOCK));

rotaterBlkpos8(blkpos8);
}
return [ret_fpos, ret_pxpos];
}

function MinoGfx(color, pxposRotates) {
const _ctx = gFieldGfx.context2d;
const _color = color;
const _pxposRotates = pxposRotates;
let _x, _y, _pxposCur;

this.setToStartPos = () => {
_x = 4 * g.Px_BLOCK;
_y = 0;
_pxposCur = _pxposRotates[0];
}

this.move = (dx, dy) => {
_x += dx * g.Px_BLOCK;
_y += dy * g.Px_BLOCK;
}

this.rotate = (dir) => {
_pxposCur = _pxposRotates[dir];
}

this.draw = () => drawIn(_color);
this.erase = () => drawIn('black');

function drawIn(color) {
_ctx.fillStyle = color;
for (let idx = 0; idx < 8; idx += 2) {
_ctx.fillRect(_x + _pxposCur[idx] + 1, _y + _pxposCur[idx + 1] + 1
, g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
}
}
}
}

const gGame = new function() {
let _curMino = gMinos.getNextMino();
_curMino.drawAtStartPos();

document.onkeydown = (e) => {
switch (e.key)
{
case 'z':
_curMino.rotate(-1);
break;

case 'x':
_curMino.rotate(1);
break;

case 'ArrowLeft':
_curMino.move(-1, 0);
break;

case 'ArrowRight':
_curMino.move(1, 0);
break;

case 'ArrowDown':
if (_curMino.move(0, 1)) {
_timeNextDown = Date.now() + g.MSEC_GAME_INTERVAL;
}
break;
}
}

let _timeNextDown;
let _isQuit = false;

this.run = async () => {
_timeNextDown = Date.now() + g.MSEC_GAME_INTERVAL;

for (;;) {
await new Promise(r => setTimeout(r, _timeNextDown - Date.now()));

if (_isQuit) { break; }
if (Date.now() < _timeNextDown) { continue; }

if (_curMino.move(0, 1) == false) {
const rowsToErase = gFieldJdg.merge(_curMino);
if (rowsToErase.length > 0) {
gFieldJdg.eraseRows(rowsToErase);
gFieldGfx.eraseRows(rowsToErase);
}

_curMino = gMinos.getNextMino();
if (_curMino.drawAtStartPos() == false) {
break;
}
}
_timeNextDown += g.MSEC_GAME_INTERVAL;
}
}

this.quit = () => {
_isQuit = true;
}
}

gFieldGfx.canvas.onclick = gGame.quit;
gGame.run();

--

--

Ravi da N.

C++, C# developer. I'm happy to help so many people enjoy programming. I'd also like to share a lot of knowledge about the program!