Wednesday, July 17, 2013

Recreating Tetris using Javascript and HTML5 - Part 3

 Add one more control feature
The Tetris game I'm modeling after, allows player to push any key (except up, down, left and right) to move a falling Tetrimino immediately down to the bottom and gets itself freezed. In my game, I'm gonna use the space key to handle this funciton.

To implement this functionality, I added the following functions to Tetrimino. Yep, that's it! These two functions takes care of the all the Tetriminoes in all directions. Of course, you also need to add the event handler in function monitorKeyboard

self.moveToBottom = function(){
 var numLevelMoveDownS_1 = self.s_1.findTheHighestFixedSquareUnder() - self.s_1.vPosition - 1;
 var numLevelMoveDownS_2 = self.s_2.findTheHighestFixedSquareUnder() - self.s_2.vPosition - 1;
 var numLevelMoveDownS_3 = self.s_3.findTheHighestFixedSquareUnder() - self.s_3.vPosition - 1;
 var numLevelMoveDownS_4 = self.s_4.findTheHighestFixedSquareUnder() - self.s_4.vPosition - 1;
 var numLevelMoveDown = Math.min(numLevelMoveDownS_1, numLevelMoveDownS_2, numLevelMoveDownS_3, numLevelMoveDownS_4)
 
 self.s_1.vPosition += numLevelMoveDown;
 self.s_2.vPosition += numLevelMoveDown;
 self.s_3.vPosition += numLevelMoveDown;
 self.s_4.vPosition += numLevelMoveDown;
}

self.moveToBottomAndFreeze = function(){
 self.moveToBottom();
 self.freeze();
 var n = checkLinesToClear();
}

var monitorKeyboard = function (){
            $(document).keydown(function(evt){
              ...
              else if(evt.which == 32){
               currTetrimino.moveToBottomAndFreeze();
              }
            });
         }

Pause/Restart Game
While playing, player might want to pause/restart game. To implement this, we need to stop the execution of our gameLoop and later restart it. Below is the code we need to add.

var gameOn = true;
var gameLoop;

var pause_restart = function(){
 if(gameOn){
  clearInterval(gameLoop);
  gameOn = false;
 }
 else{
  startGame();
  gameOn = true;
 }

}
var monitorKeyboard = function (){
            $(document).keydown(function(evt){
              if(evt.which == 39 && gameOn){
               currTetrimino.checkAndMoveToRight();
              }
              else if(evt.which == 37 && gameOn){
               currTetrimino.checkAndMoveToLeft();
              }
              else if(evt.which == 40 && gameOn){
               currTetrimino.checkAndMoveDown();
              }
              else if(evt.which == 38 && gameOn){
               currTetrimino.checkAndRotate();
              }
              else if(evt.which == 32 && gameOn){
               currTetrimino.moveToBottomAndFreeze();
              }
              else if(evt.which == 80){
               pause_restart();
              }
            });
         }
var startGame = function(){
 gameLoop = setInterval(function(){
  clearCanvas();
  generateNextTetrimino();
  updatePosition();
  drawOnCanvas();
 }, 1000/50);
}

The global variable gameOn indicates whether the game is in play or paused. We assign the returning value of the setInterval function to another global variable gameLoop, on which we can call clearInterval later to pause the game. I also wrap up the whole gameLoop within a new function startGame, which we can use to restart the game. We also need to add one more keyboard handler in function monitorKeyboard. I use 'p' as the key to control pausing and restarting. Don't forget to disable all the other keys when game is paused by adding "&&gameOn" after each condition.

Game Statistics
For now, we haven't written any code that deal with game statistics, i.e. at which level the game is, how many lines have been cleared, and total points. We've already reserve some space for game statistics at the right hand side of the game area. Now let's put something into it.

var statsGameLevel = 1, 
    statsLinesCleared = 0,
    statsPoints = 0;

var updateStats = function(lines){
 statsLinesCleared += lines;
 statsPoints += lines*lines*50 + lines*50;

 if(statsLinesCleared < 100){
  statsGameLevel = 1
  currSpeed = speedLevels[0];
 }
 else if(statsLinesCleared >= 100 && statsLinesCleared < 200){
  statsGameLevel = 2
  currSpeed = speedLevels[1];
 }
 else if(statsLinesCleared >= 200 && statsLinesCleared < 300){
  statsGameLevel = 3
  currSpeed = speedLevels[2];  
 }
 else if(statsLinesCleared >= 300 && statsLinesCleared < 400){
  statsGameLevel = 4
  currSpeed = speedLevels[3];  
 }
 else if(statsLinesCleared >= 400){
  statsGameLevel = 5
  currSpeed = speedLevels[4];  
 }
}

Three more global variables, statsGameLevel, statsLinesCleared, statsPoints are added. statsGameLevel  is the difficulty level of the game, divided into 5 level. From level 1 to level 5, the speed is gonna be 20, 16, 12, 10, 8. statsLinesCleared stores the number of lines have been cleared so far, for each 100 lines clear, the difficulty level will increased by 1, unless the difficulty level is 5, and it is the highest level. StatsPoints stores the points player earns for clearing lines; if a single line is cleared, player gets 100 points, and the more lines cleared at one time, player gets bonus points. The lines cleared at one time and the points earned are listed in the table below.

Lines Cleared Points Earned
1 100
2 300
300 600
4 1000

Function updateStats takes number of lines cleared as a single parameter, and updates all the three statistics variables. And this function needs to be called right after when function checkLinesToClear is called.

self.moveToBottomAndFreeze = function(){
 self.moveToBottom();
 self.freeze();
 var n = checkLinesToClear();
 updateStats(n);
}

 self.fall = function(){
  if(self.counter >= self.speed){
   var s_1_can_fall = self.s_1.canFall();
   var s_2_can_fall = self.s_2.canFall();
   var s_3_can_fall = self.s_3.canFall();
   var s_4_can_fall = self.s_4.canFall();

   if(s_1_can_fall && s_2_can_fall && s_3_can_fall && s_4_can_fall){
    self.moveDown(); 
   }
   else{
    self.freeze();
    var n = checkLinesToClear();
    updateStats(n);
   }

   self.counter = 0;
  }
  self.counter++;
 }

Like any other objects that's on canvas, you need a function to draw the game statistics on canvas, shown below.

var drawStats = function(){
 ctx.fillStyle = "Black";
 ctx.font = '15pt Calibri';
 ctx.fillText("LEVEL: ", 210, 120);
 ctx.fillText("LINES: ", 210, 160);
 ctx.fillText("POINTS: ", 210, 200);
 ctx.fillText(statsGameLevel, 210, 140);
 ctx.fillText(statsLinesCleared, 210, 180);
 ctx.fillText(statsPoints, 210, 220);
}

Make The Game Nicer
Currently all the squares are all drawn in pure black color, that's neat but may not be so user-friendly. So we're gonna use the image shown below to fill out the square.



And now our game looks like this.




















There are three place we need to add or change the code. First, declare a new global Image object and points it to square.png. In the function drawFixedSquares and the draw function of Square. Comment out the code that draw the squares and replace it with the code that draws the square.png.

var squareImage = new Image();
squareImage.src = 'square.png';

var drawFixedSquares = function(){
 for(var i=0; i<20; i++){
  for(var j=0; j<10; j++){
   if(gameGrid[i][j] == 1){
    // ctx.fillStyle = "Black";  
    // ctx.fillRect(j*squareLength, i*squareLength, squareLength, squareLength);
    try{
     ctx.drawImage(squareImage, 0, 0, squareLength, squareLength, j*squareLength, i*squareLength, squareLength, squareLength)
    } catch(e)
    {
     console.log('drawImage not work')
    }
   }
  }
 }
}

 self.draw = function(){
  if(self.active){
   // ctx.fillStyle = self.color;  
   // ctx.fillRect(self.hPosition*squareLength, self.vPosition*squareLength, squareLength, squareLength);

   try{
    ctx.drawImage(squareImage, 0, 0, squareLength, squareLength, self.hPosition*squareLength, self.vPosition*squareLength, squareLength, squareLength)
   } catch(e)
   {
    console.log('drawImage not work')
   }
  }
 }

End The Game
As we know, in a Tetris game, if the Tetriminoes reaches the top of the game area, the game ends. In our game, we also need that mechanism. One thing to notice is that the Tetriminoes reaching the top does not necessarily mean the end of a game. For instance, if we have a layout like below, and the next Tetrimino is J. Upon its appearance, the player immediately move the J to the left most side, which ends with layout shown below. Because this layout doesn't prevent the next Tetrimino from generating, it is not the end of the game. The way to check the end of a game is to check if there is any fixed squares there're in the initial position of next Tetrimino. And this should be checked right before each new Tetrimino is generated on canvas. Added code is shown below.

var generateNextTetrimino = function(){
 if(!currTetrimino.active){
  currTetrimino = RandomGenOneBag();

  if(checkEndOfGame(currTetrimino))
   endGame();
 }
  
}

var checkEndOfGame = function(tetri){
 if(gameGrid[tetri.s_1.vPosition][tetri.s_1.hPosition] == 1 || gameGrid[tetri.s_2.vPosition][tetri.s_2.hPosition] == 1 || gameGrid[tetri.s_3.vPosition][tetri.s_3.hPosition] == 1 || gameGrid[tetri.s_4.vPosition][tetri.s_4.hPosition] == 1){
  return true;
 }

 return false;
}

var endGame = function(){
 clearInterval(gameLoop);
 gameOn = false;
 setTimeout(function(){
  ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
  ctx.fillRect(0, 0, 200, canvasHeight);

  ctx.fillStyle = "Black";
  ctx.font = '25pt Calibri';
  ctx.fillText("GAME OVER", 15, 180);
 },100)
}

The new function endGame stops the gameLoop, marks gameOn as false and draw a half transparent white color on the game area and put the "Game Over" in the middle.



















Add The Clearing Line Animation
In a typical Tetris game, it usually has some kind of animation when clearing lines. Old version of Tetris game usually make the lines that're going to be cleared twinkle. The twinkling can be achieved by alternating between blank line and square-filled line. Three functions are added shown below.

var lineDisappear = function(linesToClear){
 var len = linesToClear.length;
 for(var i=0; i<len; i++){
  ctx.fillStyle = 'White';
  ctx.beginPath();
  ctx.rect(0, linesToClear[i]*squareLength, canvasWidth, squareLength);
  ctx.closePath();
  ctx.fill();
 }
}

var lineAppear = function(linesToClear){
 var len = linesToClear.length;
 for(var i=0; i<len; i++){
  for(var j=0;j<10;j++){
   ctx.drawImage(squareImage, 0, 0, squareLength, squareLength, j*squareLength, linesToClear[i]*squareLength, squareLength, squareLength)
  }
 }
}

var clearingLineAnimation = function(linesToClear){
 if(linesToClear.length==0)
  return;

 setTimeout(function(){
  clearInterval(gameLoop);
 }, 1000/10);

 var animateTimes = 0;
 clearLineAnimation = setInterval(function(){
  if(animateTimes<6){
   if(animateTimes%2 ==0)
    lineDisappear(linesToClear);
   else if(animateTimes%2 ==1)
    lineAppear(linesToClear);

   animateTimes++;
  }
  else{
   clearInterval(clearLineAnimation);
   startGame();
   updateGameGrid(linesToClear);
  }
   
 }, 1000/10);
}
var checkLinesToClear = function(){
 var fixSquareNum;
 var linesToClear = [];
 var counter = 0;

 for(var i=0; i<20; i++){
  fixSquareNum = 0;
  for(var j=0; j<10; j++){
   fixSquareNum += gameGrid[i][j];
  }
  if(fixSquareNum == 10){
   linesToClear[counter] = i;
   counter++;
  }
 }
 clearingLineAnimation(linesToClear);
        updateGameGrid(linesToClear);
 return counter;
}

clearingLineAnimation takes an array of line number as parameter, if the array is not empty, it will first cease the game loop, and then it will call function lineAppear, and lineDisappear alternatively, three time for each of them. Finally, it ceases the clearLineAnimation and restart the game loop. Note that we also need to move the code updateGameGrid(linesToClear) from function checkLinesToClear to clearingLineAnimation. This way, we can see that whenever a line is ready to be cleared, it first twinkles and then all the other lines  get updated. If we leave it as it was, the animation just doesn't look right.

Implement The Next Tetrimino Display Area
Our final step is to implement the functionality that while dealing with the current Tetrimino, the user can see which Tetrimino will come next. The next Tetrimino is usually shown above the statistics. It only takes 10 minutes to implement this functionality. See the code change below.

var nextTetrimino = RandomGenOneBag();

var drawOnCanvas = function(){
 drawLine();
 currTetrimino.draw();
 nextTetrimino.drawStandBy();
 drawFixedSquares();
 drawStats();
}

self.drawStandBy = function(){
 if(self.active){

  try{
   ctx.drawImage(squareImage, 0, 0, squareLength, squareLength, self.hPosition*squareLength+155, self.vPosition*squareLength+30, squareLength, squareLength)
  } catch(e)
  {
   console.log('drawImage not work')
  }
 }
}

self.drawStandBy = function(){
 if(self.active){
  self.s_1.drawStandBy();
  self.s_2.drawStandBy();
  self.s_3.drawStandBy();
  self.s_4.drawStandBy();
 }
}

var generateNextTetrimino = function(){
 if(!currTetrimino.active){
  currTetrimino = nextTetrimino;
  nextTetrimino = RandomGenOneBag();

  if(checkEndOfGame(currTetrimino))
   endGame();
 } 
}

First of all, you need to add a new global variable nextTetrimino and assign a random Tetrimino to it. This global variable stands for the next Tetrimino that will be shown above the statistics. To make the next Tetrimino show at the right place, I wrote two functions, both called drawStandBy, and added them to Square and Tetrimino respectively. The first drawStandBy function belongs to Square, and the only difference between the draw function and drawStandBy is that the latter increases the horizontal value and vertical value by 155 and 30 respectively, which draws the square right above the statistics. The drawStandBy function of Tetrimino just calls the drawStandBy function of Square, which draws a Tetrimino above the statistics. Don't forget to call drawStandBy function in drawOnCanvas. Finally, you need to change the function generateNextTetrimino a little bit: whenever the current Tetrimino becomes inacitve, assign the nextTetrimino to currTetrimino and assign a new random Tetrimino to nextTetrimino.

Congrats! Now you have a fully functional Tetris game. Get the code here.
Play the game here.





No comments:

Post a Comment