Wednesday 12 July 2023

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

We cap off this web tutorial with the timer function! But before we can do that, we want to make sure that the Rotate, Left, Right and Down buttons don't work unless the block is fully in frame. So let's create the isFullyInFrame() method. By default, it returns true.
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;
},
isFullyInFrame: function()
{
    return true;
},

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();
},


We first declare shapeArr as we've done so many times, and traverse it using a nested For loop.
isFullyInFrame: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];

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

        }
    }


    return true;
},


Of course, we take no action if the value of the sub-array is not 1.
isFullyInFrame: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.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;
},


And if the vertical position of that sub-element with regard to the grid is less than 0, which means it is outside of the top of the grid, we return false.
isFullyInFrame: function()
{
    var shapeArr = this.shapes[this.currentBlock.shape][this.currentPosition.r];

    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) return false;
            }
        }
    }

    return true;
},


In moveLeft(), moveRight() and drop(), we make a call to isFullyInFrame() and if it returns false, we exit early. We don't want to do it for moveDown() because we want the block to move down regardless.
rotateBlock: function()
{
    if (this.paused || this.stopped) return;
    if (!this.isFullyInFrame()) 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();
},
moveLeft: function()
{
    if (this.paused || this.stopped) return;
    if (!this.isFullyInFrame()) 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;
    if (!this.isFullyInFrame()) 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))
    {
        this.stopBlock();
        return;
    }
    
    this.currentPosition.y = y;
    this.positionBlock();
},
drop: function()
{
    if (this.paused || this.stopped) return;
    if (!this.isFullyInFrame()) 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();
},


If you reload and try the buttons, nothing will work. That is because the block is still off the top of the grid and this won't change until we work on the timer functions. Add timer, stage and score to the properties. timer is null by default, stage is 1 and score is 0.
currentPosition: { x: 0, y: 0, r: 0},
timer: null,
stage: 1,
score: 0,

paused: false,
stopped: false,


In setPause(), we check if paused is true after setting it. If it is, we run clearInterval() on timer.
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");
    }

    if (this.paused)
    {
        clearInterval(this.timer);
    }
    else
    {

    }

},


If not, that means paused was either set to false by the user or the game, and we need to start the timer function. We set timer to the setInterval() function. It will call the moveDown() method at an interval determined by the getInterval() method.
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");
    }

    if (this.paused)
    {
        clearInterval(this.timer);
    }
    else
    {
        this.timer = setInterval(
            ()=>
            {
                this.moveDown()
            },
            this.getInterval()
        )

    }
},


Time to work on getInterval(). We declare interval as 1500, and then return it at the end of the method..
setStop: function()    
{
    this.stopped = (this.stopped ? false : true);

    var stopScreen = document.getElementById("stopScreen");
    stopScreen.style.visibility = (this.stopped ? "visible" : "hidden");

},
getInterval: function()
{
    var interval = 1500;

    return interval;
}


We declare diff as the stage property multiplied by 100, then subtract diff from interval. Basically, the higher stage is, the smaller the interval.
getInterval: function()
{
    var interval = 1500;
    var diff = this.stage * 100;
    interval = interval - diff;


    return interval;
}


We lastly ensure that interval is not less than 100. That will be the interval, in milliseconds.
getInterval: function()
{
    var interval = 1500;
    var diff = this.stage * 100;
    interval = interval - diff;
    if (interval < 100) interval = 100;

    return interval;
},


In setStop(), we check if stopped is true after setting it. If so, we clear the timer and if not, we call reset().
setStop: function()    
{
    this.stopped = (this.stopped ? false : true);

    var stopScreen = document.getElementById("stopScreen");
    stopScreen.style.visibility = (this.stopped ? "visible" : "hidden");

    if (!this.stopped)
    {
        this.reset();
    }
    else
    {
        clearInterval(this.timer);
    }

},


We're not quite done. In reset(), we make sure right at the start that stopped is "manually" set to false and paused is "manually" set to true, then we run setPause() passing in true as an argument, to unpause the game. This will result in the timer getting started.
reset: function()
{
    this.stopped = false;
    this.paused = true;
    this.setPause(true);


    this.grid = [];
    for(var i = 0; i < 20; i++)
    {
        var temp = [];
        for(var j = 0; j < 10; j++)
        {
            temp.push(null);
        }

        this.grid.push(temp);
    }

    this.currentBlock = this.getRandomBlock();
    this.nextBlock = this.getRandomBlock();

    this.renderGrid();
    this.populateAspect("gridNext", this.shapes[this.nextBlock.shape][0], this.nextBlock.color, "square_small");
},


OK. You see that the block moves on its own. And the Rotate, Left, Right and Down buttons only work once it's moved past the top of the grid!




Scoring

What we need to do now is detect when Tetris has completed rows, and react accordingly. This is handled in the stopBlock() method. In the middle of the method, after getting all the colors from the moving block to the grid, declare completedRows and set it to the value returned by calling setCompletedRows() with the argument "white".
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;
                }
            }
        }
    }

    var completedRows = this.setCompletedRows("white");

    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 here, we create setCompletedRows(). It has a parameter, color. We declare completedRows as 0 and return that value at the end of the method. Basically, what we want to do is count the number of completed rows in the grid, and set the values of the elements of those rows to color.
getInterval: function()
{
    var interval = 1500;
    var diff = this.stage * 100;
    interval = interval - diff;
    if (interval < 100) interval = 100;

    return interval;
},
setCompletedRows: function(color)
{
    var completedRows = 0;

    return completedRows;
}


We then go through the number of elements in grid using a For loop. In it, filled is declared, and the starting value is the number of sub-elements in that current element.
setCompletedRows: function(color)
{
    var completedRows = 0;

    for(var i = 0; i < this.grid.length; i++)
    {
        var filled = this.grid[i].length;
    }


    return completedRows;
}


We run through the sub-elements using a For loop, and decrement filled each time we find a sub-element that is not null.
setCompletedRows: function(color)
{
    var completedRows = 0;

    for(var i = 0; i < this.grid.length; i++)
    {
        var filled = this.grid[i].length;

        for(var j = 0; j < this.grid[i].length; j++)
        {
            if (this.grid[i][j]) filled--;
        }

    }

    return completedRows;
}


If, at the end of that, filled is 0, that means there are no null values in that row. We increment completedRows...
setCompletedRows: function(color)
{
    var completedRows = 0;

    for(var i = 0; i < this.grid.length; i++)
    {
        var filled = this.grid[i].length;

        for(var j = 0; j < this.grid[i].length; j++)
        {
            if (this.grid[i][j]) filled--;
        }

        if (filled == 0)
        {
            completedRows++;
        }

    }

    return completedRows;
},


And then we run through the sub-elements again with a For loop and set the value to color.
setCompletedRows: function(color)
{
    var completedRows = 0;

    for(var i = 0; i < this.grid.length; i++)
    {
        var filled = this.grid[i].length;

        for(var j = 0; j < this.grid[i].length; j++)
        {
            if (this.grid[i][j]) filled--;
        }

        if (filled == 0)
        {
            completedRows++;

            for(var j = 0; j < this.grid[i].length; j++)
            {
                this.grid[i][j] = color;
            }    

        }
    }

    return completedRows;
},


Back to stopBlock(), after running setCompletedRows(), we check if completedRows() is more than 0. In the Else block, we encapsulate the rest of the existing code.
var completedRows = this.setCompletedRows("white");

if (completedRows > 0)
{
                
}
else
{

    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");    
    }
}


So if there are completed rows, we want the game to pause, so we call setPause() and pass in true as an argument because this is a system call, and then call renderGrid().
var completedRows = this.setCompletedRows("white");

if (completedRows > 0)
{
    this.setPause(true);
    this.renderGrid();  
                     
}
else
{
    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");    
    }
}


Then we use setTimeout() with an interval of 500 milliseconds. In it, we call setCompletedRows() with a value of "x". Any value other than the values in colors or "white", will just appear as no color. After that, we call renderGrid() to re-render the grid once more.
var completedRows = this.setCompletedRows("white");

if (completedRows > 0)
{
    this.setPause(true);
    this.renderGrid();

    setTimeout(
        () =>
        {
            this.setCompletedRows("x");
            this.renderGrid();                                                            
        },
        500
    );   
                     
}
else
{
    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");    
    }
}


In there, we have a nested setTimeout() with another 500 milliseconds interval! In it, we run compactRows(). We will handle this later.
var completedRows = this.setCompletedRows("white");

if (completedRows > 0)
{
    this.setPause(true);
    this.renderGrid();

    setTimeout(
        () =>
        {
            this.setCompletedRows("x");
            this.renderGrid();

            setTimeout(
                () =>
                {
                    this.compactRows();                                
                },
                500
            );   
                                                             
        },
        500
    );                         
}
else
{
    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 in here, we do what we did earlier - set currentBlock and nextBlock, re-render the grid and the Next Block display. And then we run setPause() again to un-pause the game.
var completedRows = this.setCompletedRows("white");

if (completedRows > 0)
{
    this.setPause(true);
    this.renderGrid();

    setTimeout(
        () =>
        {
            this.setCompletedRows("x");
            this.renderGrid();

            setTimeout(
                () =>
                {
                    this.compactRows();

                    this.currentBlock = this.nextBlock;
                    this.nextBlock = this.getRandomBlock();
                    this.renderGrid();
                    this.populateAspect("gridNext", this.shapes[this.nextBlock.shape][0], this.nextBlock.color, "square_small");

                    this.setPause(true); 
                                   
                },
                500
            );                                                                 
        },
        500
    );                         
}
else
{
    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");    
    }
}


OK, we have to create compactRows() now. This is to remove all completed rows in grid. We create tempArr, an empty array. At the end of the method, we set grid to the value of tempArr.
setCompletedRows: function(color)
{
    var completedRows = 0;

    for(var i = 0; i < this.grid.length; i++)
    {
        var filled = this.grid[i].length;

        for(var j = 0; j < this.grid[i].length; j++)
        {
            if (this.grid[i][j]) filled--;
        }

        if (filled == 0)
        {
            completedRows++;

            for(var j = 0; j < this.grid[i].length; j++)
            {
                this.grid[i][j] = color;
            }    
        }
    }

    return completedRows;
},
compactRows: function()
{
    var tempArr = [];

    this.grid = tempArr;
}  
 


We next iterate through the elements of grid. We only need to check if the first sub-element is not "x".
compactRows: function()
{
    var tempArr = [];

    for(var i = 0; i < this.grid.length; i++)
    {
        if (this.grid[i][0] != "x")
        {

        }
    }


    this.grid = tempArr;
}    


That means that it's not a completed row, and we need to push the row to tempArr.
compactRows: function()
{
    var tempArr = [];

    for(var i = 0; i < this.grid.length; i++)
    {
        if (this.grid[i][0] != "x")
        {
            tempArr.push(this.grid[i]);
        }
    }

    this.grid = tempArr;
}    


This means that tempArr has all the non-completed rows. However, we still need to fill in empty rows to make the full grid. So first, we get the number of rows required, rowsToAdd, which is the size of grid less the size of tempArr.
compactRows: function()
{
    var tempArr = [];

    for(var i = 0; i < this.grid.length; i++)
    {
        if (this.grid[i][0] != "x")
        {
            tempArr.push(this.grid[i]);
        }
    }

    var rowsToAdd = this.grid.length - tempArr.length;

    this.grid = tempArr;
}    


Then we use a For loop to insert an array of 10 null values into tempArr (via the unshift() method) rowsToAdd times.
compactRows: function()
{
    var tempArr = [];

    for(var i = 0; i < this.grid.length; i++)
    {
        if (this.grid[i][0] != "x")
        {
            tempArr.push(this.grid[i]);
        }
    }

    var rowsToAdd = this.grid.length - tempArr.length;
    for (i = 0; i < rowsToAdd; i++) tempArr.unshift([null, null, null, null, null, null, null, null, null, null]);

    this.grid = tempArr;
}    


So now we play! You see I'm about to get two completed rows.




The rows turn white...




...then vanish!




And now we get to scoring. At the stopBlock() method, just inside the If block that checks if completedRows is greater than 0, add this call to addToScore(). Pass in completedRows as an argument.
if (completedRows > 0)
{
    this.addToScore(completedRows);
    this.setPause(true);
    this.renderGrid();


Now we create addToScore(). Begin by declaring pointsPerRow and pointsPerStage. These really would be better off as properties, but I can't be arsed. pointsPerRow is how many points you want each completed row to be worth, while pointsPerStage is a factor of how many points it takes to get to the next stage. You can see I've entered in completely arbitrary values, though of course it would make sense for pointsPerStage to be always greater than pointsPerRow.
setStop: function()    
{
    this.stopped = (this.stopped ? false : true);

    var stopScreen = document.getElementById("stopScreen");
    stopScreen.style.visibility = (this.stopped ? "visible" : "hidden");

    if (!this.stopped)
    {
        this.reset();
    }
    else
    {
        clearInterval(this.timer);
    }
},
addToScore: function(rows)
{
    var pointsPerRow = 100;
    var pointsPerStage = 1000;
},

getInterval: function()
{
    var interval = 1500;
    var diff = this.stage * 100;
    interval = interval - diff;
    if (interval < 100) interval = 100;

    return interval;
},


Here, we increment score with this formula involving pointsPerRow and the number of completed rows, exponentially. And then we use the value of score and pointsPerStage to calculate the value of stage. We add 1 at the end because stage can never be smaller than 1.
addToScore: function(rows)
{
    var pointsPerRow = 100;
    var pointsPerStage = 1000;
    this.score += (pointsPerRow * rows * rows);
    this.stage = Math.floor(this.score / pointsPerStage) + 1
;
},


Finally, we call setScore() and setStage().
addToScore: function(rows)
{
    var pointsPerRow = 100;
    var pointsPerStage = 1000;
    this.score += (pointsPerRow * rows * rows);
    this.stage = Math.floor(this.score / pointsPerStage) + 1;

    this.setScore();
    this.setStage();

},


These two methods are fairly straightforward. They display the values of stage and score in the appropriate placeholders.
setStop: function()    
{
    this.stopped = (this.stopped ? false : true);

    var stopScreen = document.getElementById("stopScreen");
    stopScreen.style.visibility = (this.stopped ? "visible" : "hidden");

    if (!this.stopped)
    {
        this.reset();
    }
    else
    {
        clearInterval(this.timer);
    }
},
setScore: function()
{
    var container = document.getElementById("txtScore");
    container.innerHTML = this.score;
},
setStage: function()
{
    var container = document.getElementById("txtStage");
    container.innerHTML = this.stage;
},

addToScore: function(rows)
{
    var pointsPerRow = 100;
    var pointsPerStage = 1000;
    this.score += (pointsPerRow * rows * rows);
    this.stage = Math.floor(this.score / pointsPerStage) + 1;

    this.setScore();
    this.setStage();
},


At the beginning of the reset() method, we make sure that these methods are called as well.
reset: function()
{
    this.score = 0;
    this.stage = 1;

    this.setScore();
    this.setStage();


    this.stopped = false;
    this.paused = true;
    this.setPause(true);

    this.grid = [];
    for(var i = 0; i < 20; i++)
    {
        var temp = [];
        for(var j = 0; j < 10; j++)
        {
            temp.push(null);
        }

        this.grid.push(temp);
    }

    this.currentBlock = this.getRandomBlock();
    this.nextBlock = this.getRandomBlock();

    this.renderGrid();
    this.populateAspect("gridNext", this.shapes[this.nextBlock.shape][0], this.nextBlock.color, "square_small");
},


Also remove the placeholder values in txtStage and txtScore.
<div id="header_left">
    <b>STAGE</b><br />
    <span id="txtStage"></span><br />
    <b>SCORE</b><br />
    <span id="txtScore"></span><br />
</div>


Now play! Notice how when we get a completed row, the score jumps to 100?




And when I get over 1000 points, the stage is now 2! And the block moves faster.




Final touches

Let's do some cleanup here. First and foremost, we want to remove the red outlines.
div { outline: 0px solid red;}


Then we want to ensure that the block is not visible outside of the grid.
#grid
{
    width: 200px;
    height: 400px;
    background-color: rgba(0, 0, 0, 0.5);
    margin: 5px auto 5px auto;
    position: relative;
    border: 5px inset rgb(155, 155, 155);
    overflow: hidden;
}


Finally, we add this code in the CSS so that the word "NEXT" appears in the display panel for the next block.
#gridNext
{
    width: 40px;
    height: 40px;
    margin: 0px auto 0 auto;
    padding: 5px;
}

#gridNext::before
{
    display: block;
    position: absolute;
    content: "Next";
}


#grid
{
    width: 200px;
    height: 400px;
    background-color: rgba(0, 0, 0, 0.5);
    margin: 5px auto 5px auto;
    position: relative;
    border: 5px inset rgb(155, 155, 155);
    overflow: hidden;
}


See what a dramatic difference all this makes?!




Thanks for reading!

This code is a significant improvement from my code all those years back. Now there are some improvements that could still be made, such as a few optimizations, naming conventions and such. But for now, it's good enough.

Time to moveOn(),
T___T

No comments:

Post a Comment