Welcome to part 2 in this building Tetris tutorial. If you missed part 1, feel free to check it out here. In this second part, we'll be finishing up the project by adding collision detection to each Tetris shape, generating new blocks, rotations and detecting when rows have been filled and collapsed.

Part 2 we will get to the heart of the matter and implement the majority of the game elements. There's much code and much to discuss up ahead. So to start off, head over to part 1 and get that code set up.

Recap of Part 1

In part we were able to render a 10 x 15 game board in JavaScript which ended up looking like the following.

The following variables will be used to keep track of our game state.


var shapes = new Array();
var currentShape;
var height = 15;
var width = 10;
var state = 1;      // 1 running - 0 paused - 2 game over
var colors = ['black', 'orange', 'red', 'blue'];
var move = 0;
var occupiedblocks = new Array();
var direction = "";
var points = 0;

A quick recap of some of the more important variables to keep track of.

currentShape will represent the current game shape in play on the board.
state: whether the game is running, paused or a game over condition has occurred.
occupiedBlocks: A collection of indexes of currently occupied game board blocks.
direction: "Up", "Down", "Left", "Right"

Our game board is essentially a Matrix of 0's and 1's, with 1's signifying that a position is currently occupied. Our shape is a equally a smaller grid of 1's and 0's in its own right. When the shape moves in any direction, it's essentially toggling on 1's and 0's on the game board based on it's shape and current location on the board. When we move left, we offset the x axis by -1. When we move right. We offset the x axis by a +1. And any downward movement will of course offset the y axis by a +1.

To simplify the process for now, the timer function of the game has been disabled and the blocks are solely controlled through the keyboard. So first off, let's start with the basics from where we left off. In Part 1, we left off with generating a random shape and moving it around the board in any direction. So next up on our list of things to do, is to detect when the shape has made contact with a boundary or barrier.

Horizontal collision detection

Up next, let's make sure that our element can't reach outside of the bounds of the walls. That is to say, left or right.


function hitTheWall()
{
    // check if the current block at at the edge already
    // if any block in shape has an x index of 0 or width - 1
    // or if any element to the left of right is occupied
    var blocks = currentShape.shape;
    var offset = currentShape.location;
    var collision = false;

    for(var i = 0; i < blocks.length; i++)
    {
        var block = blocks[i];
        var x = block[0] + offset[0];
        var y = block[1] + offset[1];

        if (direction=="left")
            x--;
        else if (direction=="right")
            x++;

        var blk = document.querySelector('[data-x="' + x + '"][data-y="' + y + '"]');

        if (occupiedblocks.indexOf(blk.dataset.index) > -1)
        {
            collision=true;
            break;
        }

        if (x < 0 && direction=="left")
        {
            collision=true;
            break;
        }

        else if (x == width && direction == "right")
        {
            collision=true;
            break;
        }
    }

    return collision;
}

Based on whether we're moving left or right, we're going to offset our location by a +-1 and then grab that block element from the board. If that element is occupied (set to 1), we can say that we have hit a wall and cannot move any more in that direction. The same will apply for the boundaries of the board. If we're at location 0, or if we're at location "width", then again we can safely assume that we've hit the edges of the board.

If we have detected a collision, we simply do nothing. We don't shift anything or redraw anything.

Vertical collision detection

Vertical collision detection in Tetris comes in two forms. The first being the initial collision that we can expect, that is making contact with our virtual ground. And the second being when a block makes contact with another block on the board and has to immediately stop.

We can handle each case individually as the first is a unique case. Here is some pseudo code for collision detection that will help to give a better idea of the overall process.


For each block in a shape
    Calculate the offset of the block based on the direction it is moving
    
    if the game board block matching the shape block is occupied (set to '1')
        collision has occurred
        generate a new block
    else
        set the new offset for each shape block
        draw new position
        update board to show new occupied blocks

The following collision function will get called as a check, before any action is performed on our current shape. If we're in the clear and there's no collision, then we can safely update block locations and redraw our shape on to the board.


// check if block has a collision
// if lowest 'y' block in each 'x' has hit an oocupied area
// predictive
// will look at the following line, to see if collided
function collided()
{
    var blocks = currentShape.shape;
    var offset = currentShape.location;
    var collision = false;

    // determine if next block down will result in collision
    for(var i = 0; i < blocks.length; i++)
    {
        var block = blocks[i];
        var x = block[0] + offset[0];
        var y = block[1] + offset[1];

        if (direction =="down")
            y++;

        var block = document.querySelector('[data-x="' + x + '"][data-y="' + y + '"]');

        if (y == height || occupiedblocks.indexOf(block.dataset.index) > -1)
        {
            collision = true;
            break;
        }
    }

    // if it does, we will set the state of the shapes current location to occupied
    // we will then store those occupied index for future lookup
    // create a new shape
    // and determine whether we have completed a full row to clear
    if (collision)
    {
        for(var i = 0; i < blocks.length; i++)
        {
            var block = blocks[i];
            var x = block[0] + offset[0];
            var y = block[1] + offset[1];

            var block = document.querySelector('[data-x="' + x + '"][data-y="' + y + '"]');
            block.dataset.state = "1";
        }

        occupiedblocks = occupiedblocks.concat(currentShape.indexes);
        createShape();
        checkRows();
    }

    return collision;
}

We're following the same logic we did with horizontal collision detection above. If any of the blocks in the current shape intersects with an occupied block down below, then we have a collision. And if we do have a collision, we'll want to handle the following.

  • Set all current blocks that shape is in to 'occupied' (set it to '1')
  • Add indexes to array of 'occupiedBlocks'
  • Create new shape
  • Redraw new shape

Rendering new blocks

Assuming that a collision has indeed occurred, we will need to generate a new block at the top of our board to begin its journey. No new logic will have to be added here. Our original createShape function should be able to handle the job without any changes.


function createShape()
{
    var randomShape = Math.floor(Math.random() * shapes.length);
    var randomColor = Math.floor(Math.random() * colors.length);
    var center = Math.floor(width / 2);
    var shape = shapes[randomShape];
    var location = [center, 0];

    currentShape = {
        shape: shape,
        color: colors[randomColor],
        location: location,
        indexes: getBlockNumbers(shape, location)
    };
}

Essentially, we're going to select a random shape from our shapes array, set a random color, set its center location and calculate its indices on the board (for future lookup).

Checking rows

At this point, once our shape has hit a collision and is now stationary somewhere on the board, we can check if a row has been completed. And if it is, we'll have to clear the row out, and shift all current occupied rows down by the number of rows that were cleared. That is to say, if we had a marvelous 4 line clear, then we'll want to shift down the occupied blocks by 4.


function checkRows()
{
    var counter = 0;
    var start = 0;
    // check all rows for complete lines
    // after collision
    for (var y = 0; y < height; y++)
    {
        var filled = true;
        for(var x = 0; x < width; x++)
        {
            var blk = document.querySelector('[data-x="' + x + '"][data-y="' + y + '"]');
            if (blk.dataset.state == "0")
            {
                filled=false;
                break;
            }
        }

        if (filled)
        {
            // determines where to start shifting down
            if (start == 0)
                start = y;

            counter++;

            // clear out line
            for(var i = 0; i < width;i++)
            {
                var blk = document.querySelector('[data-x="' + i + '"][data-y="' + y + '"]');
                blk.dataset.state = "0";
                blk.style.backgroundColor = "white";
                removeIndex(blk.dataset.index);
            }
        }
    }

    if (counter > 0)
    {
        points += counter * 100;
        shiftDown(counter, start);
        document.getElementById("points").innerHTML = points;
    }
}

To begin, we're going to run through each and every row in our board. And because we know that the entire board is essentially a series of 0's and 1's now, we can determine if a row is complete by its absence of any 0's. If any 0's are found in that row, we can conclude that it is not a complete row. The function to shift down the elements above the cleared row, is as follows.


// shift down all occupied blocks from top to down
// update all 'y' coordinates + 1, ending with row we're removing
function shiftDown(counter, start)
{
    for (var i = start-1; i >= 0; i--)
    {
        for(var x = 0; x < width; x++)
        {
            var y = i + counter;
            var blk = document.querySelector('[data-x="' + x + '"][data-y="' + i + '"]');
            var nextblock = document.querySelector('[data-x="' + x + '"][data-y="' + y + '"]');

            if (blk.dataset.state == "1")
            {
                nextblock.style.backgroundColor = blk.style.backgroundColor;
                nextblock.dataset.state = "1";
                blk.style.backgroundColor ="white";
                blk.dataset.state = "0";
                removeIndex(blk.dataset.index);
                occupiedblocks.push(nextblock.dataset.index);
            }
        }
    }
}

The removeIndex function is a helper function that simply removes the given index from the collection of occupied blocks.


function removeIndex(index)
{
    var location = occupiedblocks.indexOf(index);
    occupiedblocks.splice(location, 1);
}

Rotations

And last, but not least is rotations. Because our shape is a set of coordinates essentially (x, y), we can treat it as a Matrix of 1's and 0's. You can find out more about Matrix transformations and rotations online, but here we'll just be showing the function that gets the job done.

But the general idea is that we're essentially updating the Matrix component of the currentShape object and then redrawing it onto our game grid.


// roates current shape
function rotate()
{
    var newShape = new Array();
    var shape = currentShape.shape;

    for(var i = 0; i < shape.length; i++)
    {
        var x = shape[i][0];
        var y = shape[i][1];
        var newx = (getWidth() - y);
        var newy = x;
        newShape.push([newx, newy]);
    }

    clearCurrent();
    currentShape.shape = newShape;
    currentShape.indexes = getBlockNumbers(newShape, currentShape.location);
}


function getHeight()
{
    var y = 0;
    // returns the height of current shape
    // max y found
    for(var i = 0; i < currentShape.shape.length; i++)
    {
        var block = currentShape.shape[i];
        if (block[1] > y)
            y = block[1];
    }

    return y;
}

function getWidth()
{
    var width = 0;

     for(var i = 0; i < currentShape.shape.length; i++)
    {
        var block = currentShape.shape[i];
        if (block[0] > width)
            width = block[0];
    }

    return width;
}

And that is a fully functioning Tetris level. In part 1 we go through the setup of building our game board, creating a collection of shapes and of rendering our shape onto the board.

And in this part 2, we get to the heart of the matter by detecting collisions, handling rotations and clearing out completed rows. And for your viewing pleasure, here is the fully functioning level down below. If you have any questions, leave them in the comment line below, and I'll be sure to answer as soon as I can.

0
Start

Comments