Thursday, August 6, 2015

jQuery Tetris

Following the previous exercise with jQuery, I wanted to do something more complicated, such as the classic Tetris game. Unlike some other examples where jQuery is primarily used for visualization and the game itself is implemented "traditionally" using a tile matrix, I was interested in using the powers of jQuery to program the game mechanics directly. (Discalimer: I was also interested in writing a working game as quickly as possible, and I implemented ideas on the go, so I apologize if some of the code will look unpolished.)

So, let us, again, construct some skeleton interface:
<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>jQuery tetris</title>
  <style>
  span {
    
    float: left;
    position:absolute;
    width:20px;
    height:20px;
  }
   span.element {     background-color: #3f3;   }
   span.backdrop {     background-color: #ff3;   }
   .box{position:absolute;background-color: #ffe;
        top:50px;left:0;
        width:500px;height:500px;}
  </style>
    
</head>
<body>

    <div id="container" class="box"></div>

    <button id="bLeft">Left (A)</button>
    <button id="bRight">Right (D)</button>
    ----
    <button id="bRotLeft">Rotate Left (Q)</button>
    <button id="bRotRight">Rotate Right (W)</button>
    ----
    <button id="bDown">Down (S)</button>    
    <button id="bGround">Ground</button>    

 
<script src="jquery.js"></script>
Here we reserve the class element to denote the tiles (squares) in the current shape that we can control, and backdrop will be all tiles that have fallen in place.

Let's begin by writing some function that will generate a random Tetris shape. (I did all the testing using just one shape and then added the easiest kind of generator I could think of, so it is rather makeshift and does not ensure equal probability of all shapes. Still, it has the advantage of doing the job without switch-case constructs and copy-pastes):
<script>
  
function spawn(oX,oY,kind) {
 var cauldron='<span class="shape"> </span>';
 var potion='<span class="element"> </span>';
 var d1=kind%3; var d2=parseInt((kind%9)/3); var d3=parseInt(kind/9);
 return $(cauldron).appendTo($("#container")).css({left:oX+"px",top:oY+"px"})
  .append($(potion).css({left:(0)+"px",top:0+"px"}))
  .append($(potion).css({left:(0-20)+"px",top:0+"px"}))
  .append($(potion).css({left:0+(((d1==0)||((d2==d1)&&(d3!==0)))?20:(((d3==0))?0:-20))+"px",top:0+(((d1==0)||((d2==d1)&&(d3!==0)))?0:((d1==1)?20:-20))+"px"}))
  .append($(potion).css({left:-20+((d2==0)?-20:0)+"px",top:0+((d2==0)?0:((d2==1)?20:-20))+"px"}));
}  
As you can see, it places four tiles with class element into a container with class shape whose only purpose is to allow relative positioning of the tiles within the shape.

Now let us program the functionality of the Left and Right buttons. This is conveniently done using the .offset() method:
$("#bLeft").click(function(){var pos=current.offset().left;
                             if(posLeftmost()>0){current.offset({left:pos-20})}})  
                             
$("#bRight").click(function(){var pos=current.offset().left;
                             if(posRightmost()<480){current.offset({left:pos+20})}})  

function posLeftmost(){var min=600;$(".element").each(function(){var here=$(this).offset().left;if (here<min) {min=here} }); return min}
function posRightmost(){var max=0;$(".element").each(function(){var here=$(this).offset().left;if (here>max) {max=here} }); return max}

where posLeftmost() and posRightmost() are two auxiliary functions to determine the position of the left-/rightmost tile in the shape, so that we cannot move the shape out of bounds. These functions do a simple max/min search through the positions of all tiles in the current shape.
Moving down is implemented similarly, but here we need to check whether the already-fallen pieces block the movement of the current shape:
$("#bDown").click(function(){var pos=current.offset().top;if (shapeCanMoveDown()) {current.offset({top:pos+20})}})  

function auxIsFree(pos){var isFree=true;
                        $(".backdrop").each(function(){if (($(this).offset().top==pos.top + 20)&&($(this).offset().left==pos.left)){isFree=false}});return isFree}
function shapeCanMoveDown(){var canMove=(posLowest()<530);
                            $(".element").each(function(){if (!auxIsFree($(this).offset())) {canMove=false}})
                            return canMove}
function posLowest(){var max=0;$(".element").each(function(){var here=$(this).offset().top;if (here>max) {max=here} }); return max}

As we see, we just search through the backdrop using .each() to see whether any backdrop tile occupies the space beneath each tile of the shape.
Strictly speaking, left/right motion also needs this type of checking, which I did not bother to implement because it is totally analogous. We'll see below how this omission opens a way for "pass-through-wall" type cheats.

Now, if the shape cannot move down any more, the rules dictate that it should freeze in place. To do this, we implement another function that simply moves all the element's tiles to the backdrop (and we tie it to the Ground button for testing purposes):
function cement(){$(".element").removeClass("element").addClass("backdrop")}                               
$("#bGround").click(function(){cement();current=spawn(240,40,Math.floor(Math.random()*18))})                       


Rotating shapes is a bit more tricky. We make use of the relative placement of element's tiles in their container, manipulating their CSS position attributes:
$("#bRotRight").click(function(){current.children().each(function(index){
        posX=parseInt($(this).css("left"));
        posY=parseInt($(this).css("top"));
                                $(this).css({left:-posY,top:posX}) }) })

$("#bRotLeft").click(function(){current.children().each(function(index){
        posX=parseInt($(this).css("left"));
        posY=parseInt($(this).css("top"));
                                $(this).css({left:posY,top:-posX}) }) })
Again we did not bother to do any bounds checking. One way of doing this would be to call auxIsFree() on all the shape tiles after rotation and unconditionally rotate in the opposite direction should any of the calls return false.


What remains to be done logic-wise is the removal of filled lines from the backdrop. Perhaps too straightforward, my idea was to loop through the backdrop line by line (iterating y-coordinate) bottom to top and:
- if there are "too many" tiles on the current line, remove such tiles from DOM, and
- for all tiles above the current line, move them down in a similar fashion as we did with the element.
The easiest way is via the .filter() method, like this:
function posTallest(){var min=1000;$(".backdrop").each(function(){var here=$(this).offset().top;if (here<min) {min=here} }); return min}

function rowCount(pos){return $(".backdrop").filter(function(){
                               return $(this).offset().top==pos}).length}

function backdropPROCESS(){var tallest=posTallest();
       for (pos=550 ; pos>=tallest;pos-=20)
                           { if (rowCount(pos)>=25) {
                             $(".backdrop").filter(function(){return $(this).offset().top==pos}).remove();
                             $(".backdrop").filter(function(){return $(this).offset().top<pos})
                              .each(function(){$(this).offset({top:($(this).offset().top)+20})});
                              pos+=20; //a mortal sin here but this is the easiest way to make an iteration repeat itself
                             }
                            }}

This is the only place we ever need a for-loop in the entire game. I am pretty sure I could do without hard-coding y-coordinates but could not wait to get a functional game.

Finally, let us add the main controller for the game, launching it when the document's DOM is ready:
function TetrisLOOP(){
 var speed=500;
 var pos=current.offset().top;
 if (shapeCanMoveDown()) {current.offset({top:pos+20});setTimeout(TetrisLOOP,speed)}
 else {
  cement(); 
  backdropPROCESS();
  if (posTallest() > 150) {current=spawn(240,40,Math.floor(Math.random()*27));setTimeout(TetrisLOOP,speed)} 
  else {$("#container").css("background","#ffcccc").append($("<h1> Game over </h1>"));
    $(".backdrop").css("background","red");
    $("#bGround").removeAttr('disabled');}
  }

$( document ).ready(function() {
 $("#bGround").attr('disabled','disabled');
 current=spawn(240,40,Math.floor(Math.random()*10));
 var mainLOOP=setTimeout(TetrisLOOP,1000);
});

and a (very primitive) code block to enable WSAD-style keyboard control:
$(document).keypress(function(event){switch(event.which)
  {case 97:$("#bLeft").click();break;
   case 100:$("#bRight").click();break;
   case 115:$("#bDown").click();break;
   case 113:$("#bRotLeft").click();break;
   case 119:$("#bRotRight").click();break;}
  })

And we're all set - enjoy! Here is the link to the complete code for your experimentation.
Since I was yearning to get a functional jQuery Tetris as quickly as I could, I blatantly ignored all the "design" elements (grid, bordered tiles, varying colors etc.), as well as purely gameplay-ish issues such as displaying the next shape, scoring, and varying speed/levels. All of this can be implemented rather trivially. There are also a number of bugs stemming from the absent movability checks for left/right/rotate operations. I leave it to the interested reader to see what "cheat issues" this can cause and how to correct them. :D



No comments:

Post a Comment