25.01.2009

Javascript Tetris Pt 5: The Life Of A Piece

Full source code can be downloaded from project home at kjeldahlnilsson.net.

In this installment we are going to set up the proper four-tile tetris piece and its interaction with the playing field. Be warned; this is the installment where we write most of our code. Grab some coffee before we start. :)

We will add a single functional, visual test for the piece and field interaction. I started out with separate tests for painting the piece, moving it, collision detection and so forth. However, in the end I found a single test sufficed to check all these things -┬áso we’ll simply use that, keeping our code walkthrough somewhat brief.

The test sets up the playing piece, the play field, and behavior for user input: movement, rotation and switching the shape of the tetris piece.

test.js:

 testPieceCollision: function() {
        Graphics.clearGameContainer();

        Field.init("blue", 400, 100);
        Piece.init();

        // Register key mapping
        setKeyReaction(function(keyCode) {
            switch (keyCode) {
            case DIR_KEY_DOWN:
                Piece.moveDown(15);
                break;
            case DIR_KEY_UP:
                Piece.moveUp(15);
                break;
            case DIR_KEY_LEFT:
                Piece.moveLeft(Piece.State.tileWidth);
                break;
            case DIR_KEY_RIGHT:
                Piece.moveRight(Piece.State.tileWidth);
                break;
            case NUM_KEY_ONE:
                Piece.toggleSquareShape();
                break;
            case NUM_KEY_TWO:
                Piece.toggleLineShape();
                break;
            case NUM_KEY_THREE:
                Piece.toggleTeeShape();
                break;
            case NUM_KEY_FOUR:
                Piece.toggleRHookShape();
                break;
            case NUM_KEY_FIVE:
                Piece.toggleLHookShape();
                break;
            case NUM_KEY_SIX:
                Piece.toggleRightLShape();
                break;
            case NUM_KEY_SEVEN:
                Piece.toggleLeftLShape();
                break;
            case SPACE_KEY:
                Piece.rotate(true);
                break;
            default:
                Graphics.drawString('Direction keys moves, space rotates, 1-5 changes piece type', 400, 400);
            }
        });
    },

In Tetris, the playing piece is made up of four tiles. We have seven shapes - seven basic ways of ways of grouping the four tiles.

tetrispiecetypes

All of these piece types can be rotated in ninety degree steps, giving us up to four different shapes per piece type. All piece shapes, no matter their rotation, fit inside a 4*4 grid… another two dimensional array. We’ll define the possible shapes of the tetris piece as array literals. I’ll limit the code listing to the possible shapes for the “T” piece type, for brevitys sake:

piece.js:

//   x
//  xxx : tee
var tee0dg =
[[0, 0, 1, 0],
 [0, 1, 1, 1],
 [0, 0, 0, 0],
 [0, 0, 0, 0]];

var tee90dg =
[[0, 0, 1, 0],
 [0, 0, 1, 1],
 [0, 0, 1, 0],
 [0, 0, 0, 0]];

var tee180dg =
[[0, 0, 0, 0],
 [0, 1, 1, 1],
 [0, 0, 1, 0],
 [0, 0, 0, 0]];

var tee270dg =
[[0, 0, 1, 0],
 [0, 1, 1, 0],
 [0, 0, 1, 0],
 [0, 0, 0, 0]];

var teeRotations = [tee0dg, tee90dg, tee180dg, tee270dg];

Finally we wrap the possible rotated shapes in an array, so we hold all possible rotated shapes of that piece type in one place. We do the same for all other piece shapes.

Now we have everything we need to define the playing piece and its behavior.

piece.js:

var Piece = {

    State: {
        piecePos: {
            x: 0,
            y: 0
        },
        color: "black",
        currRotation: squareRotations[0],
        currShape: squareRotations,
        rotationCounter: 0,
        // The four sprites that make up the piece
        tiles: [null, null, null, null],
        tileWidth: 23,
        tileHeight: 30
    },

    init: function(color) {

        rotationCounter = 0;

        var width = this.State.tileWidth;
        var height = this.State.tileHeight;

        if (!color) { // Assume colorful diagnostic pattern
            this.State.tiles[0] = Graphics.createRectangleDiv("green", 0, 0, width, height);
            this.State.tiles[1] = Graphics.createRectangleDiv("yellow", 0, 0, width, height);
            this.State.tiles[2] = Graphics.createRectangleDiv("orange", 0, 0, width, height);
            this.State.tiles[3] = Graphics.createRectangleDiv("red", 0, 0, width, height);
        }
        else {
            this.State.tiles[0] = Graphics.createRectangleDiv(color, 0, 0, width, height);
            this.State.tiles[1] = Graphics.createRectangleDiv(color, 0, 0, width, height);
            this.State.tiles[2] = Graphics.createRectangleDiv(color, 0, 0, width, height);
            this.State.tiles[3] = Graphics.createRectangleDiv(color, 0, 0, width, height);
        }

        this.reset();
    },

    // Reset piece pos to middle of middle, top
    reset: function() {
        this.State.piecePos.x = Field.State.posX + ((Field.WIDTH / 2) * this.State.tileWidth) - (2 * this.State.tileWidth);
        this.State.piecePos.y = Field.State.posY - (this.State.tileHeight * 2);
        this.setRandomShape();
        this.redraw();
    },

    drawSingleTile: function(xSlot, ySlot, tileNo) {
        var derivedX = this.State.piecePos.x + (xSlot * this.State.tileWidth);
        var derivedY = this.State.piecePos.y + (ySlot * this.State.tileHeight);
        this.State.tiles[tileNo].style.top = derivedY;
        this.State.tiles[tileNo].style.left = derivedX;
    },

    redraw: function() {
        var tileCounter = 0;
        this.State.currRotation.eachWithIndexes(function(element, x, y) {
            if (element === 1) { // Is the matrix slot ticked?
                Piece.drawSingleTile(x, y, tileCounter);
                tileCounter++;
            }
        });
        tileCounter = 0;
    },

    move: function(dx, dy) {
        var directionIsDown = (dy > 0);

        var collisionCheck = Field.checkCollisions(this.State.piecePos.x, this.State.piecePos.y, this.State.currRotation, directionIsDown, dx, dy);
        if (collisionCheck.collides) {
            if (collisionCheck.sticks) {
                Field.mergeShapeIntoField(this.State.piecePos.x, this.State.piecePos.y, this.State.currRotation);
                this.reset();
                this.isGameOver();
            }
        }
        else {
            this.State.piecePos.x += dx;
            this.State.piecePos.y += dy;
            this.redraw();
        }
    },

    moveUp: function(speed) {
        this.move(0, -(speed));
    },
    moveDown: function(speed) {
        this.move(0, speed);
    },
    moveLeft: function(speed) {
        this.move( - (speed), 0);
    },
    moveRight: function(speed) {
        this.move(speed, 0);
    },

    setRandomShape: function() {
        var random = Math.floor(Math.random() * 7);

        switch (random) {
        case 0:
            this.toggleSquareShape();
            break;
        case 1:
            this.toggleLineShape();
            break;
        case 2:
            this.toggleTeeShape();
            break;
        case 3:
            this.toggleRHookShape();
            break;
        case 4:
            this.toggleLHookShape();
            break;
        case 5:
            this.toggleRightLShape();
            break;
        case 6:
            this.toggleLeftLShape();
            break;
        }
    },

    rotate: function(doCollisionCheck) {
        this.State.rotationCounter++;
        if (this.State.rotationCounter == this.State.currShape.length) {
            this.State.rotationCounter = 0;
        }

        if (doCollisionCheck) {
            var collisionCheck = Field.checkCollisions(this.State.piecePos.x, this.State.piecePos.y, this.State.currShape[this.State.rotationCounter], false, 0, 0);
            if (collisionCheck.collides) {
                return;
            }
        }

        this.State.currRotation = this.State.currShape[this.State.rotationCounter];
        this.redraw();
    },

    resetRotation: function() {
        this.State.currRotation = this.State.currShape[0];
        this.State.rotationCounter = 0;
        this.redraw();
    },

    toggleSquareShape: function() {
        this.State.currShape = squareRotations;
        this.resetRotation();
    },

    toggleLineShape: function() {
        this.State.currShape = lineRotations;
        this.resetRotation();
    },

    toggleTeeShape: function() {
        this.State.currShape = teeRotations;
        this.resetRotation();
    },

    toggleRHookShape: function() {
        this.State.currShape = rhookRotations;
        this.resetRotation();
    },

    toggleLHookShape: function() {
        this.State.currShape = lhookRotations;
        this.resetRotation();
    },

    toggleLeftLShape: function() {
        this.State.currShape = leftLRotations;
        this.resetRotation();
    },

    toggleRightLShape: function() {
        this.State.currShape = rightLRotations;
        this.resetRotation();
    }

};

Whew. The final task is to define how the field behaves when the playing piece touches its borders, or other tiles on the field. In other words, collision detection. The Field object will take the playing piece position, shape, direction and speed and determine two things: will the piece collide with anything? If it collides, does it also stick to that surface? If it does, the Piece will, as defined above, ask Field to merge its shape into the current position in the Field grid.

We add the following members to the Field object literal (which we started defining in yesterdays blog post):

field.js:

    CollisionData: {
        collides: false,
        sticks: false
    },

    checkCollisions: function(xPos, yPos, shapeArray, directionIsDown, dx, dy) {
        if (this.State.gridState) {
            var fieldCollision = this.pieceCollidesWithField(xPos, yPos, shapeArray, directionIsDown, dx, dy);
            var boundaryCollision = this.pieceCollidesWithFloorOrWall(xPos, yPos, shapeArray, directionIsDown, dx, dy);
            var collisionResult = Object.create(this.CollisionData);
            collisionResult.collides = fieldCollision.collides || boundaryCollision.collides;
            collisionResult.sticks = fieldCollision.sticks || boundaryCollision.sticks;
            return collisionResult;
        }
    },

    pieceCollidesWithField: function(xPos, yPos, shapeArray, directionIsDown, dx, dy) {
        var collision = Object.create(this.CollisionData);

        shapeArray.eachWithIndexes(function(element, x, y) {
            if (!element || (collision && collision.collides)) {
                return;
            }

            var tileXPos = xPos + (Piece.State.tileWidth * x) + dx;
            var tileYPos = yPos + (Piece.State.tileHeight * y) + 1;
            var movingRect = makeRect(tileXPos, tileYPos, Piece.State.tileWidth, Piece.State.tileHeight);

            Field.State.gridTiles.eachWithIndexes(function(tile, arrX, arrY) {
                if (collision.collides) {
                    return;
                }

                if (Field.isTileOn(arrX, arrY)) { // Only  collision if tile is actually switched on
                    var tileX = Field.State.posX + (Piece.State.tileWidth * arrX);



                    var tileY = Field.State.posY + (Piece.State.tileHeight * arrY);
                    var fieldRect = makeRect(tileX, tileY, Piece.State.tileWidth, Piece.State.tileHeight);
                    collision.collides = intersectRect(movingRect, fieldRect);

                    if (collision.collides && directionIsDown) {
                        collision.sticks = true;

                    }
                }
            });
        });

        return collision;
    },

    pieceCollidesWithFloorOrWall: function(xPos, yPos, shapeArray, directionIsDown, dx, dy) {
        var collision;

        shapeArray.eachWithIndexes(function(element, x, y) {
            if (!element || (collision && collision.collides)) {
                return;
            }

            collision = Object.create(Field.CollisionData);

            // Check for floor collision
            var tileYPos = yPos + (Piece.State.tileHeight * y) + 1;
            var tileBottom = tileYPos + Piece.State.tileHeight;
            var fieldBottom = Field.State.posY + (Piece.State.tileHeight * Field.HEIGHT);
            collision.collides = (tileBottom > fieldBottom);
            collision.sticks = directionIsDown;

            // Check for wall collision (if no floor collision)
            if (!collision.collides) {
                var tileXPos = xPos + (Piece.State.tileWidth * x) + dx;
                var tileLeft = tileXPos;
                var tileRight = tileXPos + Piece.State.tileWidth;
                var fieldLeft = Field.State.posX;
                var fieldRight = Field.State.posX + (Piece.State.tileWidth * Field.WIDTH);
                collision.collides = (tileLeft < fieldLeft || tileRight > fieldRight);
            }
        });

        return collision;
    },

    mergeShapeIntoField: function(xPos, yPos, shapeArray) {
        shapeArray.eachWithIndexes(function(element, x, y) {
            if (element) {
                var tileAbsoluteXPos = xPos + (Piece.State.tileWidth * x);
                var tileAbsoluteYPos = yPos + (Piece.State.tileHeight * y);
                var tileXPosInField = tileAbsoluteXPos - Field.State.posX;
                var tileYPosInField = tileAbsoluteYPos - Field.State.posY;
                var tileXLocationInField = tileXPosInField / Piece.State.tileWidth;
                var tileYLocationInField = Math.round(tileYPosInField / Piece.State.tileHeight);
                Field.tileOn(tileXLocationInField, tileYLocationInField);
            }
        });

        Field.doRowClears();
    }

I’m not completely happy with the pieceCollides*() methods. Some of the variable names could be clearer. Checking the entire field for collisions is a little brute force (collision can only occur in the field tiles overlapping and directly surrounding the piece). The calculation of coordinates could be simplified by having Piece use coordinates relative to Field. And methods with more than two levels worth of nested blocks are just asking for an additional method extract. This implementation does the job well enough for now, however.

Once the playing piece has “stuck” and been merged into the field, we check if any rows have been completely filled in the playing field - these should be cleared, and the pieces above should be shuffled down. We’ll hold off implementing doRowClears(), however - todays installment is already running a little long. :)

In the next part we’ll finish the piece-field interaction. We’ll also add some visual and aural interest to the game.


PreviousNext