Thursday, 6 July 2023

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

Early into my foray into JavaScript, I saw an online job ad from a gaming company that asked me just how good my JavaScript was. The line that grabbed me was: Can you write Tetris without using jQuery?

Now, I wasn't sure I wanted to join that company, but it was a challenge I couldn't refuse. It took a week or two, but I got it working. Years later, I revisited the code and was dismayed. Sure, it worked. But it was a mess. The naming conventions were crap. The flow was embarrassingly cryptic. There were so many points that needed to be optimized.

So I redid it. And today I will share it with you.

We will start off with layout, and this is really just some standard HTML. We need a meta tag in the head tag to make sure that this little application works on mobile devices. In the styling, we make sure that all divs have a red outline, for visibility while we work. We will also set the background to black and define the font properties.
<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>JS Tetris</title>

        <style>
            div { outline: 1px solid red;}
      
            body
            {
                background-color: rgb(0, 0, 0);
                font-family: sans-serif;
                font-size: 10px;
            }
        </style>

        <script>

        </script>
    </head>

    <body>

    </body>
</html>


In the body, we start with a div with an id of container. The style for this includes a set height and width, the margin property setting it in the middle and a white background. We cap it off with rounded corners using the border-radius property.
<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>JS Tetris</title>

        <style>
            div { outline: 1px solid red;}
      
            body
            {
                background-color: rgb(0, 0, 0);
                font-family: sans-serif;
                font-size: 10px;
            }
      
            #container
            {
                width: 300px;
                height: 650px;
                margin: 0 auto 0 auto;
                background-color: rgb(255, 255, 255);
                border-radius: 10px;
            }  
     
            </style>

        <script>

        </script>
    </head>

    <body>
        <div id="container">

        </div>

    </body>
</html>


This white rectangle is where the user interface takes place.
00



Now for more layout. We need three divs, with ids header, grid and footer inside container.
<div id="container">
  <div id="header">

  </div>

  <div id="grid">

  </div>

  <div id="footer">

  </div>

</div>


header takes up full width and only 100 pixels height.
<style>
  div { outline: 1px solid red;}

  body
  {
    background-color: rgb(0, 0, 0);
    font-family: sans-serif;
    font-size: 10px;
  }

  #container
  {
    width: 300px;
    height: 650px;
    margin: 0 auto 0 auto;
    background-color: rgb(255, 255, 255);
    border-radius: 10px;
  }        
 
  #header
  {
    width: 100%;
    height: 100px;
  }

</style>


grid has only 200 pixels width and 400 pixels height, and is set in the middle via the margin property. The background color has been set darker, and border property is set to 5 pixels with an inset, because I think thematically, it looks good.
#header
{
  width: 100%;
  height: 100px;
}

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


footer has the same dimensions as header, with the addition of setting text to align center.
#header
{
  width: 100%;
  height: 100px;
}

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

#footer
{
  width: 100%;
  height: 100px;    
  text-align: center;
}


Nothing too complicated here at this point.




The topmost section is going to be the where we house the score and stage displays, as well as the next Tetris block. To that end, we begin by adding a paragraph with the word "TETRIS" in it, as a title, then a div with an id of topDisplay.
<div id="header">
    <p>TETRIS</p>
    <div id="topDisplay">

    </div>

</div>


In the styles, we add this specification for any paragraph tags within header. It's just cosmetic, so you can go with my choice here if you want. I basically aligned it center, added a bit of padding at the top, bolded it and made the kerning wide using the letter-spacing property.
#header
{
    width: 100%;
    height: 100px;
}

#header p
{
    padding-top: 10px;
    text-align: center;
    font-weight: bold;
    letter-spacing: 2em;
}


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


Now for topDisplay, we want it to have an inset just like grid. The width and height have been made big enough to house whatever it is we're adding in.
#header
{
    width: 100%;
    height: 100px;
}

#header p
{
    padding-top: 10px;
    text-align: center;
    font-weight: bold;
    letter-spacing: 2em;
}

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

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


Looking good.




We need to divide this div into two. To do that, we have two divs, one with id header_left and the other with id header_right. It's a bit clumsy but what the hell, eh?
<div id="header">
    <p>TETRIS</p>
    <div id="topDisplay">
        <div id="header_left">

        </div>

        <div id="header_right">

        </div>
    </div>
</div>


In header_left, we put in text and span tags with ids. The span tags are important; they will act as containers for the values we will provide later. In header_right, we have a div with id gridNext.
<div id="header">
    <p>TETRIS</p>
    <div id="topDisplay">
        <div id="header_left">
            <b>STAGE</b><br />
            <span id="txtStage">0</span><br />
            <b>SCORE</b><br />
            <span id="txtScore">0</span><br />

        </div>

        <div id="header_right">
            <div id="gridNext"></div>
        </div>
    </div>
</div>


header_left takes up half the width of its parent, and we float it left. header_right takes up 35%, and we float it right. In both cases, we give it a padding of 5 pixels.
#topDisplay
{
    width: 200px;
    height: 60px;
    background-color: rgba(0, 0, 0, 0.5);
    margin: 5px auto 5px auto;
    position: relative;
    border: 5px inset rgb(155, 155, 155);
}

#header_left
{
    width: 50%;
    height: 100%;
    padding: 5px;
    float: left;
}

#header_right
{
    width: 35%;
    height: 100%;
    padding: 5px;
    float: right;
}


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


And then we style gridNext to be a 40 by 40 pixel square, with 5 pixel padding.
#topDisplay
{
    width: 200px;
    height: 60px;
    background-color: rgba(0, 0, 0, 0.5);
    margin: 5px auto 5px auto;
    position: relative;
    border: 5px inset rgb(155, 155, 155);
}

#header_left
{
    width: 50%;
    height: 100%;
    padding: 5px;
    float: left;
}

#header_right
{
    width: 35%;
    height: 100%;
    padding: 5px;
    float: right;
}

#gridNext
{
    width: 40px;
    height: 40px;
    margin: 0px auto 0 auto;
    padding: 5px;
}


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


Let's see what we did here.




We don't need to do anything for grid at the moment, so let's mosey on over to footer. In it, we need three divs, each with a class of buttons. In the first two, we have button tags. The first one has a circular arrow icon (we will use this as the Rotate button) and the second one has what I hope looks like a Start/Stop icon. In reality, I know it looks like a gigantic cartoony boobie, but really I don't care at this point. If you care, change it.
<div id="footer">
    <div class="buttons">
        <br />
        <button>&#8635;</button>
    </div>

    <div class="buttons">
        <br />
        <button>&#9737;</button>
    </div>


    <div class="buttons">

    </div>

</div>


The buttons CSS class has a width of 33% so that each width takes up roughly a third of its parents. Height is at 90 pixels, and we have a slight margin at the top. More importantly, the float property is set to left, so it all lines up.
#footer
{
    width: 100%;
    height: 100px;        
    text-align: center;
}

.buttons
{
    width: 33%;
    height: 90px;
    float: left;
    margin-top: 10px;
}


By default, button tags within the CSS class buttons will be 50 by 50 pixels, circular, and have a large font size.
.buttons
{
    width: 33%;
    height: 90px;
    float: left;
    margin-top: 10px;
}

.buttons button
{
    width: 50px;
    height: 50px;
    border-radius: 50%;
    font-size: 3em;
}


Let's see what we have here!




What we need next is a directional pad. For this, we insert nine divs into the last div, with a space in them, and style them using the pad CSS class.
<div id="footer">
    <div class="buttons">
        <br />
        <button>&#8635;</button>
    </div>

    <div class="buttons">
        <br />
        <button>&#9737;</button>
    </div>

    <div class="buttons">
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>
        <div class="pad">&nbsp;</div>

    </div>
</div>


pad takes up roughly a third of its parent's width, and 30 pixels height. We float it left. Why one-third, you ask? Because we need it in a 3 by 3 grid, and floating left means that the 9 divs will show up that way, breaking to the new line every 3 divs.
.buttons
{
    width: 33%;
    height: 90px;
    float: left;
    margin-top: 10px;
}

.pad
{
    width: 33%;
    height: 30px;
    float: left;
}


.buttons button
{
    width: 50px;
    height: 50px;
    border-radius: 50%;
    font-size: 3em;
}


Yep, and we do indeed have that grid now.




Add the dirPad CSS class to these particular divs.
<div class="buttons">
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad">&nbsp;</div>
</div>


dirPad basically just gives this div a black background.
.pad
{
    width: 33%;
    height: 30px;
    float: left;
}

.dirPad
{
    background-color: rgb(0, 0, 0);
}


.buttons button
{
    width: 50px;
    height: 50px;
    border-radius: 50%;
    font-size: 3em;
}


And now we have a black cross!



 
Time to make buttons in those black squares. Replace the non-breaking space in these specific divs.
<div class="buttons">
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad"><button>&#9650;</button></div>
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad"><button>&#9664;</button></div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad dirPad"><button>&#9654;</button></div>
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad"><button>&#9660;</button></div>
    <div class="pad">&nbsp;</div>
</div>


We style these buttons within the pad CSS class by giving them 100% of the width and height of their parents. The color is defined as white, and we define the font size as 1em to override the previous class.
.dirPad
{
    background-color: rgb(0, 0, 0);
}

.buttons button
{
    width: 50px;
    height: 50px;
    border-radius: 50%;
    font-size: 3em;
}

.pad button
{
    width: 100%;
    height: 100%;
    background: none;
    color: rgb(255, 255, 255);
    font-size: 1em;
}


And here be thy directional buttons!




More stuff

I was planning on making this purely about the user interface layout, but the next bits are fairly small and doable, and I figure, why not? The big stuff is coming later. What we're going to do here are the Pause and Stop screens, which are a combination of user interface and JavaScript.

We begin with an object, game, in the script tag. In it, we have two properties, paused and stopped. Both of these are Boolean values, and set to false by default.
<script>
    var game = {
        paused: false,
        stopped: false    
    }
</script>


In the body tag, we run the reset() method when the page is loaded.
<body onload="game.reset();">


And now let's define this method. We're going to do a lot of stuff in here later, but at the moment, we are just going to call the renderGrid() method.
<script>
    var game = {
        paused: false,
        stopped: false,
        reset: function()
        {
            this.renderGrid();
        }

    }
</script>


Time to define renderGrid(). We begin by defining the variable grid. Set it to the div element grid. Clear it of all content.
<script>
    var game = {
        paused: false,
        stopped: false,
        reset: function()
        {
            this.renderGrid();
        },
        renderGrid: function()
        {
            var grid = document.getElementById("grid");

            grid.innerHTML = "";
        }

    }
</script>


We then define pauseScreen by creating a div, then set the id to pauseScreen. After that, we use the appendChild() method to insert pauseScreen into grid.
renderGrid: function()
{
    var grid = document.getElementById("grid");

    grid.innerHTML = "";

    var pauseScreen = document.createElement("div");
    pauseScreen.id = "pauseScreen";
    grid.appendChild(pauseScreen);

}


Let's define some styles for pauseScreen. The position property is set to absolute because we want it to overlap everything that will be going into grid. And since it will be overlapping everything, width and height should be 100% of grid. The background color is set to a translucent black, and it is hidden by default.
.pad button
{
    width: 100%;
    height: 100%;
    background: none;
    color: rgb(255, 255, 255);
    font-size: 1em;
}
            
#pauseScreen
{
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.8);
    visibility: hidden;
}


We then use a pseudoselector to implant a block element within pauseScreen. It will have the word "PAUSED" in there, and the rest of it is cosmetic.
#pauseScreen
{
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.8);
    visibility: hidden;
}

#pauseScreen::before
{
    display: block;
    content: "PAUSED";
    text-align: center;
    font-weight: bold;
    font-size: 2em;
    color: rgb(255, 255, 255);
    margin-top: 200px;
}


We will set the Up button and set it to call the setPause() method when clicked, with false as an argument.
<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"><button>&#9664;</button></div>
    <div class="pad dirPad">&nbsp;</div>
    <div class="pad dirPad"><button>&#9654;</button></div>
    <div class="pad">&nbsp;</div>
    <div class="pad dirPad"><button>&#9660;</button></div>
    <div class="pad">&nbsp;</div>
</div>


Now we define the setPause() method. What it's supposed to do, is toggle the paused property between true and false. The parameter, isAuto, is used to define if it is a user-activated call, meaning, if the method was called by clicking the Up button. That's because there will be cases where the game is paused for animations to take place, in which case isAuto will be true. For now, we will begin by exiting the method if stopped is true.
<script>
    var game = {
        paused: false,
        stopped: false,
        reset: function()
        {
            this.renderGrid();
        }
        renderGrid: function()
        {
            var grid = document.getElementById("grid");

            grid.innerHTML = "";

            var pauseScreen = document.createElement("div");
            pauseScreen.id = "pauseScreen";
            grid.appendChild(pauseScreen);        
        },
        setPause: function(isAuto)    
        {
            if (this.stopped) return;
        }

    }
</script>


Then we set paused to true if it is currently false, and vice versa.
setPause: function(isAuto)    
{
    if (this.stopped) return;

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


After that, we check if isAuto is false. That means this method was called by clicking the Up button. When that is true, we make sure that pauseScreen is visible if paused is true.
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");
    }

}


Now reload. Click the Up button. Does this come on? If you click it again, does it go off? Great!




Back to renderGrid(). We want to set up stopScreen as well.
renderGrid: function()
{
    var grid = document.getElementById("grid");

    grid.innerHTML = "";

    var pauseScreen = document.createElement("div");
    pauseScreen.id = "pauseScreen";
    grid.appendChild(pauseScreen);

    var stopScreen = document.createElement("div");
    stopScreen.id = "stopScreen";
    grid.appendChild(stopScreen);

},


For styling, it will share the specifications with pauseScreen.
#pauseScreen, #stopScreen
{
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.8);
    visibility: hidden;
}


The text for stopScreen is different, but that's the only difference.
#pauseScreen, #stopScreen
{
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.8);
    visibility: hidden;
}

#pauseScreen::before
{
    display: block;
    content: "PAUSED";
    text-align: center;
    font-weight: bold;
    font-size: 2em;
    color: rgb(255, 255, 255);
    margin-top: 200px;
}

#stopScreen::before
{
    display: block;
    content: "GAME OVER";
    text-align: center;
    font-weight: bold;
    font-size: 2em;
    color: rgb(255, 255, 255);
    margin-top: 200px;
}


In the HTML, set the button to call setStop() when it's clicked.
<div class="buttons">
    <br />
    <button onclick="game.setStop()">&#9737;</button>
</div>


And then define setStop(). As before, we toggle stopped between true and false.
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");
    }
},
setStop: function()    
{
    this.stopped = (this.stopped ? false : true);
}


Since there is no "auto" for this, we don't need to check for it. We just ensure that stopScreen is visible if stopped is true, and vice versa.
setStop: function()    
{
    this.stopped = (this.stopped ? false : true);

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

}


Now reload and click the button that looks like a giant boob. Does the "GAME OVER" screen come on? If you click it again, does it go off?




The layout and user interface is in place. We will populate the grid next!

Next

Displaying blocks in the grid.

No comments:

Post a Comment