Monday 10 July 2023

Web Tutorial: Tetris in vanilla JavaScript (Part 3/4)

Now that we have a block, we are going to move it - left, right and down. And of course, a control to make it drop all the way down. Moving the block is simple enough; it's implementing restrictions that gets a little tricky.

In the HTML for the directional pad, add click event handlers for the buttons to move the block left, right and down when clicking. For the Down button, this is just temporary and we're using it for testing.
<div class="buttons">
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad"  onclick="game.setPause(false)"><button>&#9650;</button></div>
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad" onclick="game.moveLeft()"><button>&#9664;</button></div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad dirPad" onclick="game.moveRight()"><button>&#9654;</button></div>
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad" onclick="game.moveDown()"><button>&#9660;</button></div>
    <div class="pad">&nbsp;</div>
</div>


Also, this button runs the rotateBlock() method.
<div class="buttons">
    <br />
    <button onclick="game.rotateBlock()">&#8635;</button>
</div>

<div class="buttons">
    <br />
    <button onclick="game.setStop()">&#9737;</button>
</div>


We will define all these methods - rotateBlock(), moveLeft(), moveRight(), moveDown() and drop().
positionBlock: function()
{
    container = document.getElementById("movingBlock");
    container.style.marginLeft = (this.currentPosition.x * 20) + "px";
    container.style.marginTop = (this.currentPosition.y * 20) + "px";
},
rotateBlock: function()
{

},
moveLeft: function()
{

},
moveRight: function()
{

},    
moveDown: function()
{

},
drop: function()
{

},

setPause: function(isAuto)    
{
    if (this.stopped) return;

    this.paused = (this.paused ? false : true);

    if (!isAuto)
    {
        var pauseScreen = document.getElementById("pauseScreen");
        pauseScreen.style.visibility = (this.paused ? "visible" : "hidden");
    }
},


We will handle rotateBLock() first. Firstly, if the game is either paused or stopped, we proceed no further and exit early.
rotateBlock: function()
{
    if (this.paused || this.stopped) return;
},


The next step is to remember that there are only 4 rotations - 0 to 3. So if the r property of currentPosition is 3, change it to 0. If not, just increment it, and assign the value to the variable r.
rotateBlock: function()
{
    if (this.paused || this.stopped) return;

    var r = (this.currentPosition.r == 3 ? 0 : this.currentPosition.r + 1);
},


We then assign the value of the r property of currentPosition, to the value of r, and call the method positionBlock(). If this seems a little superfluous, your instincts are correct. We're only doing it this way because there is more code to add later.
rotateBlock: function()
{
    if (this.paused || this.stopped) return;

    var r = (this.currentPosition.r == 3 ? 0 : this.currentPosition.r + 1);

    this.currentPosition.r = r;
    this.positionBlock();

},


In positionBlock, which we have already written, add more code. Define containerAspects. It will be the HTML element movingBlockAspects. Remember the div that is 4 times the width of its parent? Yes, that one. Then we use the r property of currentPosition to determine the left margin. Basically, it can be 0, -100, -200 or -300. This controls which rotation is visible at any one time within movingBlock.
positionBlock: function()
{
    var containerAspects = document.getElementById("movingBlockAspects");
    containerAspects.style.marginLeft = "-" + (this.currentPosition.r * 100) + "%";


    container = document.getElementById("movingBlock");
    container.style.marginLeft = (this.currentPosition.x * 20) + "px";
    container.style.marginTop = (this.currentPosition.y * 20) + "px";
},


Now try it! Click on the Rotate button...




...and you see that movingBlockAspects has moved one quarter to the left. Click again...




...and again...




... and at this point, if you click the Rotate button again, movingBlockAspects should move to its original position.




This effect will be easier to see if we set the overflow property of the movingBlock CSS class to hidden.
#movingBlock
{
    position: absolute;
    width: 80px;
    height: 80px;
    overflow: hidden;
    margin-top: 0px;
    margin-left: 0px;
}


Now you see that all the other rotations are hidden! And if you click the Rotate button, even though it just causes movingBlockAspects to shift left, what it actually looks like is that the block is rotating!




Back to this later. Let's work on moving the whole block left, right and down. In the moveLeft() method, return early if paused or stopped is true, just as we did for rotateBlock().
moveLeft: function()
{
    if (this.paused || this.stopped) return;
},


Then we declare x and set it to one less than the x property of currentPosition.
moveLeft: function()
{
    if (this.paused || this.stopped) return;

    var x = (this.currentPosition.x - 1);
},


We set the x property of currentPosition to x, then run the positionBlock() method.
moveLeft: function()
{
    if (this.paused || this.stopped) return;

    var x = (this.currentPosition.x - 1);
    
    this.currentPosition.x = x;
    this.positionBlock();

},


We do almost exactly the same for moveRight(), except that x is incremented by 1 instead of decremented.
moveLeft: function()
{
    if (this.paused || this.stopped) return;

    var x = (this.currentPosition.x - 1);
    
    this.currentPosition.x = x;
    this.positionBlock();
},
moveRight: function()
{
    if (this.paused || this.stopped) return;

    var x = (this.currentPosition.x + 1);
    
    this.currentPosition.x = x;
    this.positionBlock();

},    


Click the left and right arrows. Does the block move? You'll notice that as in the screenshot below, it's possible to move left or right out of the confines of the grid. We will be handling that case soon.




But for now, let us work on the moveDown() method. Again, we exit early if paused or stopped is true. Then we set y to the incremented value of the y property of currentPosition. We set the y property of currentPosition to y, and call positionBlock(). Note that we work on the y property rather than x, because we are moving vertically.
moveDown: function()
{
    if (this.paused || this.stopped) return;
    
    var y = (this.currentPosition.y + 1);
    
    this.currentPosition.y = y;
    this.positionBlock();

},


Try clicking on the Down button. Again, note that we're able to move it outside of the bounds of the grid. This simply will not do, so we are taking care of that next!




For that, we create the testPosition() method. It has three parameters - x, y and r. We return true if the position represented by x, y and r is viable, and false if not. By default, we return true.
positionBlock: function()
{
    var containerAspects = document.getElementById("movingBlockAspects");
    containerAspects.style.marginLeft = "-" + (this.currentPosition.r * 100) + "%";

    var container = document.getElementById("movingBlock");
    container.style.marginLeft = (this.currentPosition.x * 20) + "px";
    container.style.marginTop = (this.currentPosition.y * 20) + "px";
},
testPosition: function(x, y, r)
{
    return true;
},

rotateBlock: function()
{
    if (this.paused || this.stopped) return;

    var r = (this.currentPosition.r == 3 ? 0 : this.currentPosition.r + 1);

    this.currentPosition.r = r;
    this.positionBlock();
},


Now we declare shapeArr, and set to it the element in shapes that is pointed to by the shape property of the currentBlock property. We use r to point to the correct rotation.
testPosition: function(x, y, r)
{
    var shapeArr = this.shapes[this.currentBlock.shape][r];

    return true;
},


Now we traverse the two-dimensional array shapeArr using a nested For loop.
testPosition: function(x, y, r)
{
    var shapeArr = this.shapes[this.currentBlock.shape][r];

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {

        }
    }


    return true;
},


Now within the array, we only bother if the sub-element's value is 1. If it's 0, it is outside of the block and we don't have to bother with it.
testPosition: function(x, y, r)
{
    var shapeArr = this.shapes[this.currentBlock.shape][r];

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {

            }

        }
    }

    return true;
},


In a bit of a surprising inversion, i is the row, so we add y to it to ensure that we can compare the correct value vertically. Now, if this is less than 0, that means that part of the block is still past the top of the grid, we don't bother with it.
testPosition: function(x, y, r)
{
    var shapeArr = this.shapes[this.currentBlock.shape][r];

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (y + i < 0) continue;
            }
        }
    }

    return true;
},


This next line tests if horizontally, the block is still within the bounds of the block. If not, false is returned.
testPosition: function(x, y, r)
{
    var shapeArr = this.shapes[this.currentBlock.shape][r];

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (y + i < 0) continue;
                if (j + x < 0  || j + x > this.grid[0].length - 1) return false;
            }
        }
    }

    return true;
},


This next line tests if the occupied part of the block is past the bottom of the grid.
testPosition: function(x, y, r)
{
    var shapeArr = this.shapes[this.currentBlock.shape][r];

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (y + i < 0) continue;
                if (j + x < 0  || j + x > this.grid[0].length - 1) return false;
                if (i + y > this.grid.length - 1) return false;
            }
        }
    }

    return true;
},


Lastly, we test if that part of the grid that coincides with the occupied part of the block, is not null, which means that there is already a colored block there. If so, we return false.
testPosition: function(x, y, r)
{
    var shapeArr = this.shapes[this.currentBlock.shape][r];

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (y + i < 0) continue;
                if (j + x < 0  || j + x > this.grid[0].length - 1) return false;
                if (i + y > this.grid.length - 1) return false;

                if (this.grid[i + y][j + x] != null) return false;
            }
        }
    }

    return true;
},


Now let's call the testPosition() method where applicable! In rotateBlock(), we call it before setting currentPosition's r property. We're supposed to pass in x, y and r. In this case, we pass in the x property of currentPosition and the y property of currentPosition, then the proposed value of r. If this returns false, the proposed rotation is not viable and we exit before setting the value.
rotateBlock: function()
{
    if (this.paused || this.stopped) return;

    var r = (this.currentPosition.r == 3 ? 0 : this.currentPosition.r + 1);
    if (!this.testPosition(this.currentPosition.x, this.currentPosition.y, r)) return;

    this.currentPosition.r = r;
    this.positionBlock();
},


Similarly for all the others!
moveLeft: function()
{
    if (this.paused || this.stopped) return;

    var x = (this.currentPosition.x - 1);
    if (!this.testPosition(x, this.currentPosition.y, this.currentPosition.r)) return;
    
    this.currentPosition.x = x;
    this.positionBlock();
},
moveRight: function()
{
    if (this.paused || this.stopped) return;

    var x = (this.currentPosition.x + 1);
    if (!this.testPosition(x, this.currentPosition.y, this.currentPosition.r)) return;
    
    this.currentPosition.x = x;
    this.positionBlock();
},    
moveDown: function()
{
    if (this.paused || this.stopped) return;

    var y = (this.currentPosition.y + 1);
    if (!this.testPosition(this.currentPosition.x, y, this.currentPosition.r)) return;
    
    this.currentPosition.y = y;
    this.positionBlock();
},


Now you see that we can't rotate or move the block outside of the grid!




Let's create the drop() method. We will change this call (remember I said this was just temporary?). Thus, the Down button will now call drop().
<div class="pad dirPad" onclick="game.drop()"><button>&#9660;</button></div>


We create drop() here, starting the same as the other methods such as moveRight(), moveLeft(), rotateBlock() and moveDown().
moveDown: function()
{
    if (this.paused || this.stopped) return;

    var y = (this.currentPosition.y + 1);
    if (!this.testPosition(this.currentPosition.x, y, this.currentPosition.r))
    {
        return;
    }
    
    this.currentPosition.y = y;
    this.positionBlock();
},
drop: function()
{
    if (this.paused || this.stopped) return;
},

setPause: function(isAuto)    
{
    if (this.stopped) return;

    this.paused = (this.paused ? false : true);

    if (!isAuto)
    {
        var pauseScreen = document.getElementById("pauseScreen");
        pauseScreen.style.visibility = (this.paused ? "visible" : "hidden");
    }
},


Then we use a While loop. The condition is the testPosition() method with an incremented y property of currentPosition.
drop: function()
{
    if (this.paused || this.stopped) return;

    while(this.testPosition(this.currentPosition.x, this.currentPosition.y + 1, this.currentPosition.r))
    {    

    }

},


And while that returns true, we run positionBlock() after incrementing the y property of currentPosition. In essence, we pretty much run moveDown() until we reach the bottom.
drop: function()
{
    if (this.paused || this.stopped) return;

    while(this.testPosition(this.currentPosition.x, this.currentPosition.y + 1, this.currentPosition.r))
    {    
        var y = (this.currentPosition.y + 1);
        this.currentPosition.y = y;
        this.positionBlock();

    }
},


Reload. Now if you click the Down button, the block goes right to the bottom of the grid.




We are going to create one more method, stopBlock(), which will be run at moveDown() and drop(). This method basically is meant to be run once the block cannot move "down" any more, at which point the colors of the block will be transferred to the grid, and we repeat the cycle of the moving block again.
drop: function()
{
    if (this.paused || this.stopped) return;

    while(this.testPosition(this.currentPosition.x, this.currentPosition.y + 1, this.currentPosition.r))
    {    
        var y = (this.currentPosition.y + 1);
        this.currentPosition.y = y;
        this.positionBlock();
    }
},
stopBlock: function()
{

},

setPause: function(isAuto)    
{
    if (this.stopped) return;

    this.paused = (this.paused ? false : true);

    if (!isAuto)
    {
        var pauseScreen = document.getElementById("pauseScreen");
        pauseScreen.style.visibility = (this.paused ? "visible" : "hidden");
    }
},


First, we declare shapeArr and get the current shape's two-dimensional array. For that, we'll need the shape property of the currentBlock property and the r property of the currentPosition property. These two values will point to the correct data within the shapes property. We will also declare stopGame, and set it to false.
stopBlock: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];
    var stopGame = false;

},


We are going to determine if stopGame should be true instead. For this, we use a nested For loop to traverse shapeArr.
stopBlock: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];
    var stopGame = false;

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {

        }
    }

},


Now, we will only take action if the current sub-element is 1.
stopBlock: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];
    var stopGame = false;

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {

            }

        }
    }
},


We then check if the vertical position of that current sub-element is out of the top of the grid. If so, set stopGame to true. That means that when stopBlock() is being called and the block is out of the top of the grid, it's Game Over.
stopBlock: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];
    var stopGame = false;

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (i + this.currentPosition.y >= 0)
                {

                }
                else
                {
                    stopGame = true;
                }

            }
        }
    }
},


If not, we set that current corresponding sub-element in grid to the color property of currentBlock.
stopBlock: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];
    var stopGame = false;

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (i + this.currentPosition.y >= 0)
                {
                    this.grid[i + this.currentPosition.y][j + this.currentPosition.x] = this.currentBlock.color;
                }
                else
                {
                    stopGame = true;
                }
            }
        }
    }
},


Out of that loop we then check if stopGame is true. If so, call setStop().
stopBlock: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];
    var stopGame = false;

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (i + this.currentPosition.y >= 0)
                {
                    this.grid[i + this.currentPosition.y][j + this.currentPosition.x] = this.currentBlock.color;
                }
                else
                {
                    stopGame = true;
                }
            }
        }
    }

    if (stopGame)
    {
        this.setStop();
    }
    else
    {

    }

},


If not, that means the game is going to continue. currentBlock will be set to the value of nextBlock, while nextBlock will need to be generated using getRandomBlock(). And then we call renderGrid() and display the nextBlock by running populateAspect() with the arguments we used in reset().
stopBlock: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];
    var stopGame = false;

    for(var i = 0; i < shapeArr.length; i++)
    {
        for(var j = 0; j < shapeArr[i].length; j++)
        {
            if (shapeArr[i][j] == 1)
            {
                if (i + this.currentPosition.y >= 0)
                {
                    this.grid[i + this.currentPosition.y][j + this.currentPosition.x] = this.currentBlock.color;
                }
                else
                {
                    stopGame = true;
                }
            }
        }
    }

    if (stopGame)
    {
        this.setStop();
    }
    else
    {
        this.currentBlock = this.nextBlock;
        this.nextBlock = this.getRandomBlock();
        this.renderGrid();
        this.populateAspect("gridNext", this.shapes[this.nextBlock.shape][0], this.nextBlock.color, "square_small");    

    }
},


And this is where we will call stopBlock(). In moveDown(), we call stopBlock() if the block can no longer move down. In drop(), we call it at the end of the method because that, by definition, is when the block can no longer move down.
moveDown: function()
{
    if (this.paused || this.stopped) return;

    var y = (this.currentPosition.y + 1);
    if (!this.testPosition(this.currentPosition.x, y, this.currentPosition.r))
    {
        this.stopBlock();

        return;
    }
    
    this.currentPosition.y = y;
    this.positionBlock();
},
drop: function()
{
    if (this.paused || this.stopped) return;

    while(this.testPosition(this.currentPosition.x, this.currentPosition.y + 1, this.currentPosition.r))
    {    
        var y = (this.currentPosition.y + 1);
        this.currentPosition.y = y;
        this.positionBlock();
    }

    this.stopBlock();
},


Let's test this now. Note that the current block is the cyan Z-shaped one. The next block is the purple L-shaped one. Click on the Down button...




...the cyan block drops to the bottom. The purple L-shaped block should now be at the top and it's now your current block. And a new block (a red Z-shaped one) is the next block.




And if you do this enough times...




...it's Game Over!




Now we have most of the moving parts. It's just a matter of applying timer functions.


Next

Scoring, animations and beautification!

No comments:

Post a Comment