Click here to Skip to main content
13,765,264 members
Click here to Skip to main content
Add your own
alternative version

Stats

4.1K views
10 bookmarked
Posted 30 Oct 2017
Licenced CPOL

AngularJS Tetris

, 30 Oct 2017
Rate this:
Please Sign up or sign in to vote.
An original AngularJS implementation of the most famous video game ever

Table of Contents

  1. Introduction
  2. AngularJS - An Introduction to the framework
  3. The code - How does everything hang together in the solution?
  4. Tetris - What is it and what are the basic data structures?
  5. The M in MVC - The Tetris data structures and logic implemented in JavaScript classes
  6. AngularJS View & Controller - The V and C in MVC
  7. Server - A simple WebAPI service that reads and writes high scores
  8. Conclusion - Looking forward to your feedback

1. Introduction

This article documents the design and implementation of the popular Tetris game as a Single Page Application, using AngularJS. While this task is relatively complicated, it is described step by step and the article should be easy to understand for all developers including beginners. This is for you if you are interested in seeing the basic AngularJS features in action. It also offers some insight into JavaScript, Bootstrap, WebAPI and Entity Framework.  I used Microsoft Visual Studio 2015. The code is extensively commented and extra attention has been applied towards following best practices.

Please feel free to comment and suggest improvements, especially if you see something not implemented according to best practices! You are also encouraged to play around with the full code yourself: 

https://github.com/TheoKand/AngularJS-Tetris

More specifically, the following practices are explored:

  • Implementing complicated requirements in JavaScript while following some basic best practices such as using modules to encapsulate functionality, avoiding the global scope etc.
  • Separating concerns by creating Model classes using the OOP features of JavaScript. Using other advanced JavaScript features such as array manipulation, working with JSON and objects, creating shallow copies of objects, using timers to execute code using the event loop, while also responding to keyboard events by writing code that runs in the call stack. The difference between the two is explained.
  • Using the most important features of AngularJS to build an SPA. This includes Binding expressions, directives that allow printing data and lists of objects in the view, encapsulating code in AngularJS objects such as services and factories, exposing singleton value objects to the view etc. The proper way to do various things with AngularJS is explained, because it’s tempting to mix it with JavaScript but you end up with code that is not easily testable.
  • Using a combination of standard Bootstrap features with the Media CSS command, to create a fully responsive web user interface. Using cookies to save settings of your web app and allow the user to customize the look and feel.
  • Implementing JQuery animations and effects using methods such as .animate, shake etc.
  • Using HTML5 and JavaScript to enrich your web app with sound features. Preloading sound files and playing them concurrently as sound effects or as a background theme.
  • Using Microsoft WebAPI, SQL Server and Entity Framework to create a RESTful web service. Using AngularJS to consume this service and read/write JSON object data.
     

2. AngularJS

An introduction to the framework

A few years ago I decided to learn and start using a JavaScript SPA framework because this practice was becoming very popular. After some research it became clear that I should start using AngularJS. It’s considered the most complete and well designed SPA framework. It is relatively new which means there are more projects using older frameworks such as ReactJS and KnockoutJS. But my understanding was and still is that AngularJS is the best choice for dev teams starting new projects. Angular2 has built-in support for TypeScript which makes it even more exciting.  In this project we use AngularJS which basically means any version before v2.

AngularJS is a complete web application framework and has a rich set of features. It was supposed to encapsulate the features of many existing frameworks that developers were using in combination with one another. It provides a way to do everything you would need to do in a web app. But like all well-designed frameworks you don’t have a big learning curve to start using it. In this project we use it mainly for the binding features.

AngularJS makes it very easy to separate our view logic (html) from the model (JavaScript classes) and the business logic (controller). This is what the MVC pattern is all about. The AngularJS binding expressions and related directives allow us to show model data on a page without writing any code to update back and forth. The $scope object will get data from our JavaScript and update the page when nessecary, as long as we’re running code “inside” of AngularJS. It will also take any changes in the view (like a user typing in a textbox) and update the model. Let’s look at a simple binding expression. Consider this HTML code:

<h1 {{ PageTitle }} </h1>

and this JavaScript code:

$scope.PageTitle="AngularJS Tetris";

The title will be printed on the page without having to write any code to update it, as is nessecary with plain JavaScript. Whenever some code updates this model property, the view will be updated. The entire $scope object is considered to be the controller’s Model, but it’s common practice to create JavaScript classes that represent our problem domain entities and attach them as objects to the $scope. We follow this practice with the classes inside AngularJS-App/Models.

AngularJS knows when to update the view with any changes in the model, unless we change the model “outside” of it. This can only happen if we use setTimeout to execute some code outside of the call stack, or if we write code to respond to some user input. For both of the above, there is a way to do it properly through AngularJS, so that our view will be updated after the code finishes. There is the $timeout object that wraps the JavaScript timer functions (it needs to be injected in your controller as a dependency). And there are AngularJS directives that allow us to handle keyboard/mouse input correctly, like this

<body ng-keydown="OnKeyDown($event.which);">

Any changes in the $scope data that happen inside the OnKeyDown event will be synced with the view.  There is also a $scope.apply() method to do this manually, although it’s not considered good  practice.

So the point to take home is that there are two types of JavaScript code. Code that runs in the call-stack and code that runs in the event-loop. After a user action or a DOM event (like a page load), the call-stack is used. If you use the correct directives (like ng-init or ng-keydown) then you will be fine and all changes to the $scope will be automatically synched with the View. The event-loop is used when you start an asynchronous operation, use callbacks, promises, or even JavaScript timers. Again, you have to use the AngularJS wrapper, like $timeout, if you want your model to be automatically synced with the view.

Tip: Whenever given the choice, you should run more code in the event-loop because your project becomes more scallable and robust. Running a lot of resource-demanding code in the call-stack, especially with recursion, will lead to bad performance and stack overflow errors. So if you want your code to convey that you are an experienced developer, you should know your JavaScript promises, callbacks and your C# events, callbacks, Tasks and Async/Awaits.

3. The code solution

How does everything hang together in the solution?

The project consists of a server-side and a client-side component. The client is of course the HTML based Single Page Application (SPA) built with AngularJS. The server is a Microsoft WebAPI project that interacts with the data layer to read and write high score data. The client consumes this RESTful API via the $http object in AngularJS. It’s the same as using the JQuery $.ajax object. We could have separated the source code in two projects, inside two subfolders named Client and Server. In that case the client and server would need to be deployed separately, and this is why I avoided this. The project is now deployed on the Azure cloud from Visual Studio in one step.
The client-side component of the project is inside the folder AngularJS-App and is marked with the shaded color. Therefore, our app is at http://angulartetris20170918013407.azurewebsites.net/AngularJS-App and the API is at http://angulartetris20170918013407.azurewebsites.net/api/highscores

A brief explanation of the folders and files:

  • AngularJS-App\Assets contains style sheets, images and sound effects used in the AngularJS app.
  • AngularJS-App\Controllers contains the only controller that is referenced in the view of our MVC app. AngularJS is an MVC based application framework. Much like in Microsoft ASP.NET MVC, developers are encouraged to follow a strict folder structure by separating the Model (JavaScript classes attached to the $scope object), View (Index.html) and Controller classes (gameController.js – contains business logic and handles user input). In case you are wondering where the controller is referenced, look at the directive ng-controller="gameController" on the body element of Index.html.  This is the simplest way of attaching a controller to a view. 
  • AngularJS-App\Models contains the two JavaScript classes that represent our problem domain entities, the tetromino and the game.
  • AngularJS-App\Services It is common practice in AngularJS and other frameworks, to put reusable classes like Singletons inside a folder by this name. AngularJS has a few different types of objects for this purpose, such as services, factories, values, constants etc. Here we use two factories and keep it simple by naming them soundEffectsService, highScoreService and putting them in a folder named services
  • AngularJS-App\App.js is the first script in our app, where the AngularJS module is declared. If we had a more complex SPA with multiple views, the controllers and routing would be defined here with the module’s Config function. Much like we do in the server-side with WebApiConfig.cs.  The script App.js is also used to define some application-wide JavaScript functions that we don’t want to pollute the global scope with. In other words, we want any functions we create to only be available in our own application. The preloading of sound files also happens here.
  • AngularJS-App\Index.html is the View. This is where our user interface is defined. Our controller class (gameController.js) will operate on the elements of this file to display the Model data ($scope) inside bindings and directives. More on all of this later.
  • App_Start
    As you probably know this is an ASP.NET special folder. It contains the only routing definition we need for our WebAPI service. 
  • Controllers
    WebAPI is also an MVC based framework, actually it’s a simpler version of MVC. In a classic ASP.NET MVC application, we have models views and controllers. In WebAPI we only have models and controllers, because an API by definition does not have a view element. It is consumed by other applications. We have just one controller class which connects the client with the database by reading and writing High scores. In this project we only use the database to read and write high scores. Without this feature, there would be no need for a server-side component to this project; it would be simply an AngularJS SPA.
  • Models
    According to MVC best practices, in this folder we must put the POCOs (Plain Old Code Object) that correspond to our problem domain entities. In this case our problem domain is a Tetris game but it runs on the client side and the only interaction we have with the database is to read and write high scores.  Therefore we have one model class named Highscores.cs.  We are also using Entity Framework to connect to the database, so we need the class AngularContext which inherits from DbContext.

 

4. Tetris

What is it and what are the basic data structures?

The Tetris game is played on a 10x20 board. The board consists of 200 squares. There are 7 tetrominos that fall from the top in random order, and we must stack them on the bottom of the board. Each tetromino consists of four squares arranged in different geometric shapes.

When a tetromino touches on a solid square, it solidifies and can’t be moved anymore. Then the next tetrominos falls. To represent the game board in code we will use a 2D JavaScript array. This is essentially an array of arrays. The inner array contains a value, which represents one of three things: An empty square, a falling square (squares of falling tetrominos) or a solid square. The array is initialized in code like this:

 //initialize game board
board = new Array(boardSize.h);
for (var y = 0; y <boardSize.h; y++) {
    board[y] = new Array(boardSize.w);
    for (var x = 0; x <w; x++)
        board[y][x] = 0;
}


Here we can see a graphical representation of the game board. The outer array is represented by the rows. Each row is one item in the outer array. The inner array is represented by the squares. Each square is one item in the inner array. The gray numbers on the top-left corner of each square are the coordinates of this square. The red number on the bottom-right corner represents what the square contains. Like we said above, it’s either an empty square (0), a falling square (Tetromino.TypeEnum)  or a solid square (minus Tetromino.TypeEnum). The minus is used to signify that this square has been solidified. 

In terms of code, for the game board pictured above the following expressions would all return true:

board[0][0] == 0
board[2][6] == TypeEnum.LINE
board[3][6] == TypeEnum.LINE
board[4][6] == TypeEnum.LINE
board[5][6] == TypeEnum.LINE
board[18][3] == -TypeEnum.BOX
board[18][4] == -TypeEnum.BOX

 

 

 

The tetrominos are also defined in a similar way, as a 2D array. This tetromino of type TypeEnum.L is defined with the following code inside the function getSquares from models/tetromino.js :

Case TypeEnum.L:
    if (tetromino.rotation == 0) {

        //   |
        //   |
        // - -

        arr[0][2] =TypeEnum.L;
        arr[1][2] =TypeEnum.L;
        arr[2][2] =TypeEnum.L;
        arr[2][1] =TypeEnum.L;

    } else if (tetromino.rotation == 1) {

        // - - -
        //     |

        arr[1][0] =TypeEnum.L;
        arr[1][1] =TypeEnum.L;
        arr[1][2] =TypeEnum.L;
        arr[2][2] =TypeEnum.L;

As you can see the function returns different data if the tetromino is rotated. Each tetromino has from 2 to 4 different rotations, except the BOX that has none. These are defined in function rotateTetromino.
 

5. The M in MVC

The Tetris data structures and logic implemented in JavaScript classes

All of the above is implemented with two JavaScript classes in the subfolder models. It’s game.js and tetromino.js. They both follow the same design pattern which is a combination of singleton and factory. I want the object that contains all the game data to be serializable (save,restore game etc) so I can’t attach methods to the prototype. I use the object literal syntax to create the singleton. On the top it has enumerations, then it has instance methods (also called member functions) and on the bottom there is a “factory” function that returns the actual object. The member functions expect the object as an argument, because they are not true instance methods.

AngularJS-App\models\tetromino.js
'use strict';

//this singleton contains a factory function for the tetromino object and related methods. I use this way of creating the object
//because if I attach methods to the prototype, they won't exist after the object is serialized/deserialized.
const Tetromino = {

    TypeEnum: { UNDEFINED: 0, LINE: 1, BOX: 2, INVERTED_T: 3, S: 4, Z: 5, L: 6, INVERTED_L: 7 },
    Colors: ["white", "#00F0F0", "#F0F000", "#A000F0", "#00F000", "#F00000", "#F0A000", "#6363FF"],

    // a tetromino has 2 or 4 different rotations
    rotate: function (tetromino) {

        switch (tetromino.type) {

            case Tetromino.TypeEnum.LINE:
            case Tetromino.TypeEnum.S:
            case Tetromino.TypeEnum.Z:

                if (tetromino.rotation == 0)
                    tetromino.rotation = 1;
                else
                    tetromino.rotation = 0;

                break;

            case Tetromino.TypeEnum.L:
            case Tetromino.TypeEnum.INVERTED_L:
            case Tetromino.TypeEnum.INVERTED_T:

                if (tetromino.rotation < 3)
                    tetromino.rotation++;
                else
                    tetromino.rotation = 0;

                break;

        }

    },

    //Each tetromino has 4 squares arranged in a different geometrical shape. This method returns the tetromino squares
    //as a two dimensional array. Some tetrominos can also be rotated which changes the square structure
    getSquares: function (tetromino) {

        let arr = [[], []];
        arr[0] = new Array(3);
        arr[1] = new Array(3);
        arr[2] = new Array(3);
        arr[3] = new Array(3);

        switch (tetromino.type) {

            case Tetromino.TypeEnum.LINE:

                if (tetromino.rotation == 1) {

                    // ----

                    arr[1][0] = Tetromino.TypeEnum.LINE;
                    arr[1][1] = Tetromino.TypeEnum.LINE;
                    arr[1][2] = Tetromino.TypeEnum.LINE;
                    arr[1][3] = Tetromino.TypeEnum.LINE;

                } else {

                    // |
                    // |
                    // |
                    // |

                    arr[0][1] = Tetromino.TypeEnum.LINE;
                    arr[1][1] = Tetromino.TypeEnum.LINE;
                    arr[2][1] = Tetromino.TypeEnum.LINE;
                    arr[3][1] = Tetromino.TypeEnum.LINE;
                }

                break;

            case Tetromino.TypeEnum.BOX:

                arr[0][0] = Tetromino.TypeEnum.BOX;
                arr[0][1] = Tetromino.TypeEnum.BOX;
                arr[1][0] = Tetromino.TypeEnum.BOX;
                arr[1][1] = Tetromino.TypeEnum.BOX;
                break;

            case Tetromino.TypeEnum.L:
                if (tetromino.rotation == 0) {

                    //   |
                    //   |
                    // - -

                    arr[0][2] = Tetromino.TypeEnum.L;
                    arr[1][2] = Tetromino.TypeEnum.L;
                    arr[2][2] = Tetromino.TypeEnum.L;
                    arr[2][1] = Tetromino.TypeEnum.L;

                } else if (tetromino.rotation == 1) {

                    // - - -
                    //     |

                    arr[1][0] = Tetromino.TypeEnum.L;
                    arr[1][1] = Tetromino.TypeEnum.L;
                    arr[1][2] = Tetromino.TypeEnum.L;
                    arr[2][2] = Tetromino.TypeEnum.L;

                } else if (tetromino.rotation == 2) {

                    // - -
                    // |
                    // |

                    arr[1][1] = Tetromino.TypeEnum.L;
                    arr[1][2] = Tetromino.TypeEnum.L;
                    arr[2][1] = Tetromino.TypeEnum.L;
                    arr[3][1] = Tetromino.TypeEnum.L;

                } else if (tetromino.rotation == 3) {

                    // |
                    // - - -

                    arr[1][1] = Tetromino.TypeEnum.L;
                    arr[2][1] = Tetromino.TypeEnum.L;
                    arr[2][2] = Tetromino.TypeEnum.L;
                    arr[2][3] = Tetromino.TypeEnum.L;

                }

                break;

            case Tetromino.TypeEnum.INVERTED_L:

                if (tetromino.rotation == 0) {

                    // |
                    // |
                    // - -

                    arr[0][1] = Tetromino.TypeEnum.INVERTED_L;
                    arr[1][1] = Tetromino.TypeEnum.INVERTED_L;
                    arr[2][1] = Tetromino.TypeEnum.INVERTED_L;
                    arr[2][2] = Tetromino.TypeEnum.INVERTED_L;

                } else if (tetromino.rotation == 1) {

                    //     |
                    // - - -

                    arr[1][2] = Tetromino.TypeEnum.INVERTED_L;
                    arr[2][0] = Tetromino.TypeEnum.INVERTED_L;
                    arr[2][1] = Tetromino.TypeEnum.INVERTED_L;
                    arr[2][2] = Tetromino.TypeEnum.INVERTED_L;

                } else if (tetromino.rotation == 2) {

                    // - -
                    //   |
                    //   |

                    arr[1][1] = Tetromino.TypeEnum.INVERTED_L;
                    arr[1][2] = Tetromino.TypeEnum.INVERTED_L;
                    arr[2][2] = Tetromino.TypeEnum.INVERTED_L;
                    arr[3][2] = Tetromino.TypeEnum.INVERTED_L;

                } else if (tetromino.rotation == 3) {

                    // - - -
                    // |

                    arr[1][1] = Tetromino.TypeEnum.INVERTED_L;
                    arr[1][2] = Tetromino.TypeEnum.INVERTED_L;
                    arr[1][3] = Tetromino.TypeEnum.INVERTED_L;
                    arr[2][1] = Tetromino.TypeEnum.INVERTED_L;

                }

                break;

            case Tetromino.TypeEnum.INVERTED_T:

                if (tetromino.rotation == 0) {

                    //   |
                    // - - -

                    arr[0][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][0] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][2] = Tetromino.TypeEnum.INVERTED_T;

                } else if (tetromino.rotation == 1) {

                    //   |
                    // - |
                    //   |

                    arr[0][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[2][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][0] = Tetromino.TypeEnum.INVERTED_T;

                } else if (tetromino.rotation == 2) {

                    // - - -
                    //   |

                    arr[1][0] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][2] = Tetromino.TypeEnum.INVERTED_T;
                    arr[2][1] = Tetromino.TypeEnum.INVERTED_T;

                } else if (tetromino.rotation == 3) {

                    // |
                    // | -
                    // |

                    arr[0][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
                    arr[1][2] = Tetromino.TypeEnum.INVERTED_T;
                    arr[2][1] = Tetromino.TypeEnum.INVERTED_T;

                }

                break;

            case Tetromino.TypeEnum.S:

                if (tetromino.rotation == 0) {

                    //   |
                    //   - -
                    //     |

                    arr[0][0] = Tetromino.TypeEnum.S;
                    arr[1][0] = Tetromino.TypeEnum.S;
                    arr[1][1] = Tetromino.TypeEnum.S;
                    arr[2][1] = Tetromino.TypeEnum.S;

                } else if (tetromino.rotation == 1) {

                    //  --
                    // --
                    //

                    arr[0][1] = Tetromino.TypeEnum.S;
                    arr[0][2] = Tetromino.TypeEnum.S;
                    arr[1][0] = Tetromino.TypeEnum.S;
                    arr[1][1] = Tetromino.TypeEnum.S;

                }

                break;

            case Tetromino.TypeEnum.Z:

                if (tetromino.rotation == 0) {

                    //     |
                    //   - -
                    //   |

                    arr[0][1] = Tetromino.TypeEnum.Z;
                    arr[1][0] = Tetromino.TypeEnum.Z;
                    arr[1][1] = Tetromino.TypeEnum.Z;
                    arr[2][0] = Tetromino.TypeEnum.Z;

                } else if (tetromino.rotation == 1) {

                    //  --
                    //   --
                    //

                    arr[0][0] = Tetromino.TypeEnum.Z;
                    arr[0][1] = Tetromino.TypeEnum.Z;
                    arr[1][1] = Tetromino.TypeEnum.Z;
                    arr[1][2] = Tetromino.TypeEnum.Z;

                }

                break;

        }

        return arr;
    },

    //the tetromino object
    tetromino: function (type, x, y, rotation) {
        this.type = (type === undefined ? Tetromino.TypeEnum.UNDEFINED : type);
        this.x = (x === undefined ? 4 : x);
        this.y = (y === undefined ? 0 : y);
        this.rotation = (rotation === undefined ? 0 : y);
    }

};
  • TypeEnum is an enumeration for the 7 different tetromino types. The handy JavaScript object-literal syntax is used yet again.
  • Colors is an array that contains the colors of the tetrominos. As you can see there are 7 items in here as well, the first one is white because this is the background color of the game board.
  • rotate is a member function of the tetromino, it updates the rotation property of the object. As you can see it expects the object to be passed as an argument, as do all the following member functions.
  • getSquares is probably the most important function in the entire project. This is where the tetromino shapes are defined, in the form of a two dimensional array (array within an array). There is a rather large switch statement that populates the arrays depending on the type of tetromino and it’s current rotation. Please feel free to fork the code and change the shapes, it should be good fun to see how the game experience is altered with different shapes! It would be more challenging to add new shapes because the number of shapes (7) is hard coded in several places. It would also make sense to extract the shape definitions in a resource file of some sort, perhaps a neat JSON file in the assets subfolder.
  • tetromino is the “factory” method that returns an instance of the tetromino object. Our tetromino is defined by the following properties:
    • Type is a value from the TypeEnum enumeration.
    • X and Y is the position on the game board. All tetrominos start their life at X=4,Y=0, on the top middle of the board.
    • Rotation goes from 0 to 3 as we rotate the falling tetromino with the UP key. Some tetrominos have 2 rotations and the square has zero. A square is a square no matter how you rotate it!
AngularJS-App\models\Game.js
'use strict';

const Game = {

    BoardSize: { w: 10, h: 20 },
    Colors: ["#0066FF", "#FFE100", "#00C3FF", "#00FFDA", "#00FF6E", "#C0FF00", "#F3FF00", "#2200FF", "#FFAA00", "#FF7400", "#FF2B00", "#FF0000", "#000000"],
    BoardActions: { ADD: 0, REMOVE: 1, SOLIDIFY: 2 },

    //Check if this tetromino can move in this coordinate. It might be blocked by existing solid squares, or by the game board edges
    checkIfTetrominoCanGoThere: function (tetromino, board) {

        let tetrominoSquares = Tetromino.getSquares(tetromino);

        for (let y = 0; y < tetrominoSquares.length; y++) {
            for (let x = 0; x < tetrominoSquares[y].length; x++) {

                if (tetrominoSquares[y][x] != null) {

                    let boardY = tetromino.y + y;
                    let boardX = tetromino.x + x;

                    //tetromino is blocked by the game board edge
                    if ((boardY > Game.BoardSize.h - 1) || (boardY < 0) || (boardX < 0) || (boardX > Game.BoardSize.w - 1)) {
                        return false;
                    }

                    //tetromino is blocked by another solid square
                    if (board[boardY][boardX] < 0) {
                        return false;
                    }
                }
            }
        }

        return true;
    },

    //Check if this tetromino can move down on the board. It might be blocked by existing solid squares.
    checkIfTetrominoCanMoveDown: function (tetromino, board) {

        //create a shallow copy of the tetromino so that we can change the Y coordinate 
        let newTetromino = JSON.parse(JSON.stringify(tetromino));
        newTetromino.y++;
        return Game.checkIfTetrominoCanGoThere(newTetromino, board);
    },

    //This method can be used for 3 different actions: add a tetromino on the board, remove and solidify 
    modifyBoard: function (tetromino, board, boardAction) {

        let tetrominoSquares = Tetromino.getSquares(tetromino);
        for (let y = 0; y < tetrominoSquares.length; y++) {
            for (let x = 0; x < tetrominoSquares[y].length; x++) {

                if (tetrominoSquares[y][x] != null && tetrominoSquares[y][x] != 0) {
                    let boardY = tetromino.y + y;
                    let boardX = tetromino.x + x;

                    if (boardAction == Game.BoardActions.SOLIDIFY)
                        board[boardY][boardX] = -tetromino.type;
                    else if (boardAction == Game.BoardActions.REMOVE)
                        board[boardY][boardX] = 0;
                    else if (boardAction == Game.BoardActions.ADD)
                        board[boardY][boardX] = tetromino.type;

                }

            }
        }

    },

    //check if any lines were completed
    checkForTetris: function (gameState) {

        for (let y = Game.BoardSize.h - 1; y > 0; y--) {

            let lineIsComplete = true;
            for (let x = 0; x < Game.BoardSize.w; x++) {
                if (gameState.board[y][x] >= 0) {
                    lineIsComplete = false;
                    break;
                }
            }

            if (lineIsComplete) {
                gameState.lines++;
                gameState.score = gameState.score + 100 + (gameState.level - 1) * 50;

                //move everything downwards
                for (let fallingY = y; fallingY > 0; fallingY--) {
                    for (let x = 0; x < Game.BoardSize.w; x++) {
                        gameState.board[fallingY][x] = gameState.board[fallingY - 1][x];
                    }
                }

                //check if current level is completed
                if (gameState.lines % 5 == 0) {
                    gameState.level++;
                }

                return true;

            }

        }

        return false;

    },

    //returns the color of the game board depending on the level
    getGameColor: function (gameState) {
        if (gameState)
            return Game.Colors[(gameState.level % Game.Colors.length)];
    },

    //returns the color of a gameboard square (cell) depending on if it's empty, solidified or occupied by a falling tetromino
    getSquareColor: function (gameState, y, x) {

        let square = gameState.board[y][x];

        //a negative value means the square is solidified
        if (square < 0) {
            return Tetromino.Colors[Math.abs(square)];
        } else {
            //zero means the square is empty, so white is returned from the array. A positive value means the square contains a falling tetromino.
            return Tetromino.Colors[square];
        }

    },

    //returns the css class of a gameboard square (cell) depending on if it's empty, solidified or occupied by a falling tetromino
    getSquareCssClass: function (gameState, y, x) {
        let square = gameState.board[y][x];

        //zero means the square is empty
        if (square == 0) {
            return "Square ";
        } else if (square < 0) {
            //a negative value means the square is solidified
            return "Square SolidSquare";
        } else {
            //A positive value means the square contains a falling tetromino.
            return "Square TetrominoSquare";
        }
    },

    //returns the color of the next tetromino. The next tetromino is displayed while the current tetromino is being played
    getNextTetrominoColor: function (gameState, y, x) {
        let square = gameState.nextTetrominoSquares[y][x];
        if (square == 0) {
            return $scope.getGameColor();
        } else {
            return Tetromino.Colors[square];
        }
    },

    //Returns the game delay depending on the level. The higher the level, the faster the tetrimino falls
    getDelay: function (gameState) {

        let delay = 1000;
        if (gameState.level < 5) {
            delay = delay - (120 * (gameState.level - 1));
        } else if (gameState.level < 15) {
            delay = delay - (58 * (gameState.level - 1));
        } else {
            delay = 220 - (gameState.level - 15) * 8;
        }
        return delay;

    },

    //this object holds all the information that makes up the game state
    gameState: function () {

        this.startButtonText = "Start";
        this.level = 1;
        this.score = 0;
        this.lines = 0;
        this.running = false;
        this.paused = false;
        this.fallingTetromino = null;
        this.nextTetromino = null;
        this.nextTetrominoSquares = null;
        this.board = null;
        this.tetrominoBag = [];
        this.fullTetrominoBag = [0, 5, 5, 5, 5, 5, 5, 5];
        this.tetrominoHistory = [];
        this.isHighscore = false;

    }

};
  • BoardSize is an object that defines the game board dimensions. Might be fun to play around with different game board sizes.
  • Colors is an array that defines the background color of the page, that changes for each level.
  • BoardActions is an enumeration that is used by the member function modifyBoard.
  • checkIfTetrominoCanGoThere is a member function that returns TRUE if the specified tetromino can be placed in the specified game board. Remember, the tetromino object contains also the coordinates.
  • checkIfTetrominoCanMoveDown is a wrapper for the previous function and returns TRUE if the specified tetromino can move down on the game board. Every time the game loop runs, the currently falling tetromino moves down.
  • modifyBoard is a very important function that either adds, removes or solidifies the specified tetromino on the specified game board. Remember, each element in the the game board array represents a square on the game board. It can have one of three values: 0 for empty, TypeEnum for a falling square and minus TypeEnum for a solid square.
  • checkForTetris is called inside the game loop. It checks if any lines have been completed and moves everything downwards if so. It’s called continuously while it returns TRUE because many lines might be completed at once (up to 4, and this is called a TETRIS)
  • getGameColor returns the color of the game board depending on the level
  • getSquareColor returns the color of a gameboard square (cell) depending on if it's empty, solidified or occupied by a falling tetromino.
  • getSquareCssClass returns the CSS class of a game board square. The classes are defined in styles.css and provide some nice visual effects to distinguish falling from solidified shapes.
  • getNextTetrominoColor returns the color of the next tetromino. The next tetromino is displayed while the current tetromino is being played. On the view (index.html) you can see a div area inside <div id="GameInfo"> where the next tetromino is displayed using the ng-repeat AngularJS directive.
  • getDelay controls how fast the game moves as you progress through the levels. This algorithm has been fine tuned to provide very interesting and lengthy game play, if you can handle it! If you score above 150.000 points, you are definitely better than me.
  • Finally, the factory method that creates an instance of the gameState object. This object holds all the information that makes up the game state. We can serialize this object in order to save a game as a cookie and allow the user to restore it at a later time. These are the properties:
    • startButtonText. Could probably use some refactoring. The same button is used for Start and Pause.
    • Level. Increases every other 5 completed lines as you can see in the instance method checkForTetris.
    • Score. Increases exponentially with each completed line. The higher the level, the higher the points you get for completed lines. You can get thousands and thousands of points if you make a Tetris on an advanced level!
    • Lines is a counter of total completed lines.
    • Running and paused are booleans. When the page loads both are false. 
    • fallingTetromino. A tetromino object (created by the factory method inside tetromino.js) that represents the currently falling tetromino.
    • nextTetromino. The one that will fall next, and is displayed on the corner of the game board.
    • nextTetrominoSquares. The squares of the next tetromino as they are returned by getSquares. This array is needed in order to show the next tetromino on the screen.
    • Board. This is the most important data structure of the entire project, as it represents the game board. Remember, each element in the the game board array represents a square on the game board. It can have one of three values: 0 for empty, TypeEnum for a falling square and minus TypeEnum for a solid square.
    • tetrominoBag is an array that contains the remaining tetrominos of each type. When one falls, the counter is decreased. When the bag is empty it’s filled again by assigning it to fullTetrominoBag
    • fullTetrominoBag is the initial value of the array above.
    • tetrominoHistory is an array that contains the previous shapes.

 

6. HTML View & AngularJS Controller

The V and C in MVC

The view of our application is defined in index.html. If we were creating a larger Single Page Application that had routing and multiple views, they would be implemented in a separate subfolder named Views. There are comments to show you what each element does.

AngularJS-App\Index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" ng-app="myApp">
<head>
    <title>AngularJS Tetris</title>

    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0">

    <!-- Global site tag (gtag.js) - Google Analytics -->
    <script async src="https://www.googletagmanager.com/gtag/js?id=UA-108858196-1"></script>
    <script>
        window.dataLayer = window.dataLayer || [];
        function gtag() { dataLayer.push(arguments); }
        gtag('js', new Date());
        gtag('config', 'UA-108858196-1');
    </script>

</head>
<body ng-keydown="onKeyDown($event.which);" ng-controller="gameController" style="background-color:{{ getGameColor() }}">

    <!-- splash screen displayed while sound effects are preloading-->
    <div class="preloading">
        <img src="assets/images/TetrisAnimated.gif" style="width:300px" />
        <h1>... loading ...</h1>
    </div>

    <div class="container">
        <div class="row">

            <!-- github ribbon -->
            <a href="https://github.com/TheoKand" target="_blank"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://camo.githubusercontent.com/652c5b9acfaddf3a9c326fa6bde407b87f7be0f4/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6f72616e67655f6666373630302e706e67" alt="Fork me on GitHub" data-canonical-src="https://s3.amazonaws.com/github/ribbons/forkme_right_orange_ff7600.png"></a>

            <!-- application menu-->
            <div class="dropdown">
                <button ng-click="startGame()" type="button" class="btn btn-sm btn-success" id="btnStart">{{ GameState.startButtonText }}</button>
                <button class="btn btn-sm btn-warning dropdown-toggle" type="button" data-toggle="dropdown">
                    More
                    <span class="caret"></span>
                </button>

                <ul class="dropdown-menu">
                    <li><a href="#" data-toggle="modal" data-target="#InfoModal" id="btnInfo">Info</a></li>
                    <li><a href="#" data-toggle="modal" data-target="#InfoHighscores">Highscores</a></li>
                    <li role="separator" class="divider"></li>
                    <li ng-class="{ 'disabled': !GameState.running && !GameState.paused }"><a href="#" ng-click="saveGame()">Save Game</a></li>
                    <li><a href="#" ng-click="restoreGame()">Restore Game</a></li>
                    <li class="hidden-xs divider" role="separator"></li>
                    <li class="hidden-xs "><a href="#" ng-click="setMusic(!(getMusic()))">Music : {{ getMusic() ? "OFF" : "ON"}}</a></li>
                    <li class="hidden-xs "><a href="#" ng-click="setSoundFX(!(getSoundFX()))">Sound Effects : {{ getSoundFX() ? "OFF" : "ON"}}</a></li>
                </ul>
            </div>

            <!-- app title for medium and large displays -->
            <div class="hidden-xs text-center" style="float:right">
                <h2 style="display: inline; color:white; text-shadow: 1px 1px 3px black;">AngularJS Tetris</h2>
                <br /><small>by <a href="https://github.com/TheoKand">Theo Kandiliotis</a></small>
            </div>

            <!-- app title for smaller displays -->
            <div class="visible-xs text-center" style="float:right">
                <h4 style="display: inline; color:white; text-shadow: 1px 1px 3px gray;">AngularJS Tetris</h4>
            </div>

            <br style="clear:both" />

            <div class="text-center">

                <!-- logo displayed when the game is paused -->
                <div class="splash" ng-style="!GameState.running ? { 'display':'block'} : { 'display': 'none' }">
                    <img src="assets/images/logo.png" />
                </div>

                <!-- game board-->
                <div id="Game">

                    <!-- This area contains the game info like score, level and the next tetromino -->
                    <div id="GameInfo">
                        Score: <b style="font-size:14px" class="GameScoreValue">{{GameState.score}}</b><br />
                        Level: <b>{{GameState.level}}</b><br />
                        Next:
                        <!-- The AngularJS ng-repeat directive is used to display the 2-d array that contains the next tetromino -->
                        <div ng-repeat="row in GameState.nextTetrominoSquares track by $id($index)">
                            <div ng-repeat="col in GameState.nextTetrominoSquares[$index] track by $id($index)" class="SmallSquare" style="background-color:{{ getNextTetrominoColor(GameState,$parent.$index,$index) }}">
                            </div>
                        </div>
                    </div>

                    <!-- The AngularJS ng-repeat directive is used to display the 2-d array that contains the game board -->
                    <div ng-repeat="row in GameState.board track by $id($index)">
                        <div ng-repeat="col in GameState.board[$index] track by $id($index)" class="{{ getSquareCssClass(GameState,$parent.$index,$index) }}" style="z-index:1;background-color:{{ getSquareColor(GameState,$parent.$index,$index) }}">
                        </div>
                    </div>
                </div>

                <!-- This area contains an on-screen keyboard and is only visible for mobile devices. This is done with the css tag @media (max-width: 768px) -->
                <div id="TouchScreenController">

                    <div style="width:100%;height:50px;border:1px solid black" ng-click="onKeyDown(38)">

                        <span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span>

                    </div>

                    <div style="float:left;width:50%;height:50px;border-right:1px solid black;border-left:1px solid black;" ng-click="onKeyDown(37)">

                        <span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span>

                    </div>

                    <div style="float:left;width:50%;height:50px;border-right:1px solid black;">

                        <span class="glyphicon glyphicon-arrow-right" aria-hidden="true" ng-click="onKeyDown(39)"></span>

                    </div>

                    <div style="width:100%;height:50px;border:1px solid black;clear:both;">

                        <span class="glyphicon glyphicon-arrow-down" aria-hidden="true" ng-click="onKeyDown(40)"></span>

                    </div>

                </div>

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

    <!-- information area. A bootstrap modal dialog that is the "about" screen. It's displayed automatically the first time a user opens the page -->
    <div class="modal fade" id="InfoModal" role="dialog">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal">&times;</button>
                    <h2 class="modal-title">AngularJS Tetris <small>by <a href="https://github.com/TheoKand">Theo Kandiliotis</a></small></h2>
                </div>
                <div class="modal-body">

                    An original AngularJS version of the most popular video game ever.

                    <h4>Control</h4>
                    <p>Use the arrow keys LEFT, RIGHT to move the tetromino, UP to rotate and DOWN to accelarate. If you are using a mobile device, a virtual on-screen keyboard will appear.</p>

                    <h4>Source code</h4>

                    <p>The full source code is available for download on my Github account. The project was created with Microsoft Visual Studio 2015 on September 2017, using AngularJS, Bootstrap 3.3.7, JQuery, C#, WebAPI, Entity Framework. </p>
                    <p><a class="btn btn-default" href="https://github.com/TheoKand/AngularTetris">Browse &raquo;</a></p>

                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
                </div>
            </div>
        </div>

    </div>

    <!-- highscores area. A bootstrap modal dialog that shows the highscores -->
    <div class="modal fade" id="InfoHighscores" role="dialog">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal">&times;</button>
                    <h2 class="modal-title">Highscores </h2>
                </div>
                <div class="modal-body">

                    <table class="table table-striped">
                        <thead>
                            <tr>
                                <th></th>
                                <th>
                                    Name
                                </th>
                                <th>
                                    Score
                                </th>
                                <th>
                                    Date
                                </th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr ng-repeat="highscore in highscores track by $index">
                                <td>{{$index +1}}</td>
                                <td>{{highscore.Name}}</td>
                                <td>{{highscore.Score}}</td>
                                <td>{{highscore.DateCreated | date : short}}</td>
                            </tr>
                        </tbody>
                    </table>

                    <img src="assets/images/PleaseWait.gif" ng-style="PleaseWait_GetHighscores ? { 'display':'block'} : { 'display': 'none' }" />

                    You must score {{highscores[highscores.length-1].Score}} or more to get in the highscores!

                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
                </div>
            </div>
        </div>

    </div>

    <!-- gameover area. A bootstrap modal dialog that's shown after a game ends. If the score is a highscore, an additional area to enter a name is displayed -->
    <div class="modal fade" id="InfoGameover" role="dialog">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal">&times;</button>
                    <h2 class="modal-title">Game Over!</h2>
                </div>
                <div class="modal-body">

                    <p>
                        <b>Your score is {{GameState.score}}</b>
                    </p>

                    <table class="table table-striped">
                        <thead>
                            <tr>
                                <th></th>
                                <th>
                                    Name
                                </th>
                                <th>
                                    Score
                                </th>
                                <th>
                                    Date
                                </th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr ng-repeat="highscore in highscores track by $index">
                                <td>{{$index +1}}</td>
                                <td>{{highscore.Name}}</td>
                                <td>{{highscore.Score}}</td>
                                <td>{{highscore.DateCreated | date : short}}</td>
                            </tr>
                        </tbody>
                    </table>

                    <div ng-style="GameState.IsHighscore ? { 'display':'block'} : { 'display': 'none' }">
                        Please enter your name: <input id="txtName" type="text" />
                        <button ng-click="saveHighscore()" type="button" id="btnSaveHighscore" class="btn btn-sm btn-success">SAVE</button> <img src="assets/images/PleaseWait.gif" style="height:50px" ng-style="PleaseWait_SaveHighscores ? { 'display':'block'} : { 'display': 'none' }" />

                    </div>

                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
                </div>
            </div>
        </div>
    </div>

    <!-- generic modal area. Used whenever we want to show a modal message in our SPA -->
    <div class="modal fade" id="InfoGeneric" role="dialog">
        <div class="modal-dialog modal-sm">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal">&times;</button>
                    <h2 class="modal-title">{{ GenericModal.Title }}</h2>
                </div>
                <div class="modal-body">
                    <p>
                        {{ GenericModal.Text }}
                    </p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
                </div>
            </div>
        </div>
    </div>

    <!-- styles -->
    <link rel="stylesheet" href="assets/css/Site.min.css" />
    <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" />

    <!-- 3rd party components -->
    <script src="//code.jquery.com/jquery-1.9.1.min.js"></script>
    <script src="//code.jquery.com/ui/1.9.1/jquery-ui.js"></script>
    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.6.4/angular.min.js"></script>
    <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>

    <!-- project components -->

    <!-- MINIFIED -->
    <script src="app.min.js"></script>
    <script src="models/tetromino.min.js"></script>
    <script src="models/game.min.js"></script>
    <script src="services/highscoreService.min.js"></script>
    <script src="services/soundEffectsService.min.js"></script>
    <script src="controllers/gameController.min.js"></script>
    
    <!-- NORMAL -->
    <!--<script src="app.js"></script>
    <script src="models/tetromino.js"></script>
    <script src="models/game.js"></script>
    <script src="services/highscoreService.js"></script>
    <script src="services/soundEffectsService.js"></script>
    <script src="controllers/gameController.js"></script>-->

</body>
</html>

Last but not least, we should take a look at the controller gameController.js. This is the code that brings everything together. This code interacts and updates the view, it responds to the user’s input and it interacts with the back-end by reading and writing data.

AngularJS-App\controllers\gameController.js
'use strict';

app.controller('gameController', ['$scope', '$timeout','highscoreService','soundEffectsService', function ($scope, $timeout, highscoreService, soundEffectsService) {

    let gameInterval = null; //The timerId of the game loop timer. 
    let backgroundAnimationInfo = {}; //Singleton object that contains info about the page's background color animation. As the game progresses, the animation becomes more lively

    //This IIFEE is the "entry-point" of the AngularJS app
    (function () {

        if (!(app.getCookie("AngularTetris_Music") === false)) soundEffectsService.playTheme();

        GetHighscores();
        AnimateBodyBackgroundColor();

        //instantiate the game state object. It's created in the singleton class Game and it's saved in the AngularJS scope because it must be accessible from the view
        $scope.GameState = new Game.gameState();

        //show the information modal, only the first time
        let infoHasBeenDisplayed = app.getCookie("AngularTetris_InfoWasDisplayed");
        if (infoHasBeenDisplayed == "") {
            app.setCookie("AngularTetris_InfoWasDisplayed", true, 30);
            $("#InfoModal").modal('show');
        }

    })();

    //start or stop the theme music
    $scope.setMusic = function (on) {
        if (on) {
            app.setCookie("AngularTetris_Music", true, 30);
            soundEffectsService.playTheme();
        } else {
            app.setCookie("AngularTetris_Music", false, 30);
            soundEffectsService.stopTheme();
        }
    };

    //is the music on?
    $scope.getMusic = function () {
        return !(app.getCookie("AngularTetris_Music") === false);
    };

    //start or stop the sound fx
    $scope.setSoundFX = function (on) {
        if (on) {
            app.setCookie("AngularTetris_SoundFX", true, 30);
        } else {
            app.setCookie("AngularTetris_SoundFX", false, 30);
        }
    };

    //are the soundfx on?
    $scope.getSoundFX = function () {
        return !(app.getCookie("AngularTetris_SoundFX") === false);
    };

    //Save the game state in a cookie
    $scope.saveGame = function () {

        app.setCookie("AngularTetris_GameState", $scope.GameState, 365);
        ShowMessage("Game Saved", "Your current game was saved. You can return to this game any time by clicking More > Restore Game.");

    };

    //Restore the game state from a cookie
    $scope.restoreGame = function () {
        let gameState = app.getCookie("AngularTetris_GameState");
        if (gameState != "") {

            $scope.startGame();
            $scope.GameState = gameState;

            ShowMessage("Game Restored", "The game was restored and your score is " + $scope.GameState.score + ". Close this window to resume your game.");

        } else {

            ShowMessage("", "You haven't saved a game previously!");

        }
    };

    //init a new game and start the game loop timer
    $scope.startGame = function () {

        if (!$scope.GameState.running) {

            if (!$scope.GameState.paused) {
                //start new game
                InitializeGame();
            }

            $scope.GameState.paused = false;
            $scope.GameState.running = true;
            gameInterval = $timeout(GameLoop, 0);
            $scope.GameState.startButtonText = "Pause";

        } else {

            $scope.GameState.running = false;
            $scope.GameState.paused = true;

            $scope.GameState.startButtonText = "Continue";
            if (gameInterval) clearTimeout(gameInterval);

        }

    };

    //these game-related functions (implemented in models/game.js) must be accessible from the view
    $scope.getGameColor = Game.getGameColor;
    $scope.getSquareColor = Game.getSquareColor;
    $scope.getSquareCssClass = Game.getSquareCssClass;
    $scope.getNextTetrominoColor = Game.getNextTetrominoColor;

    //save a new highscore
    $scope.saveHighscore = function () {

        let highscore = { Name: $('#txtName').val(), Score: $scope.GameState.score };

        if (highscore.Name.length == 0) {
            ShowMessage("", "Please enter your name!");
            return;
        }

        //used to show a spinner on the view
        $scope.PleaseWait_SaveHighscores = true;

        //call the highscores service to save the new score
        highscoreService.put(highscore, function () {
            $scope.PleaseWait_SaveHighscores = false;
            $scope.GameState.IsHighscore = false;
            GetHighscores();
        }, function (errMsg) {
            $scope.PleaseWait_SaveHighscores = false;
            alert(errMsg);
        });

    };

    //handle keyboard event. The tetromino is moved or rotated
    $scope.onKeyDown = (function (key) {

        if (!$scope.GameState.running) return;

        let tetrominoAfterMovement = JSON.parse(JSON.stringify($scope.GameState.fallingTetromino));

        switch (key) {
            case 37: // left

                tetrominoAfterMovement.x--;

                if (Game.checkIfTetrominoCanGoThere(tetrominoAfterMovement, $scope.GameState.board)) {

                    if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.Rotate);

                    //remove tetromino from current position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.REMOVE);
                    //move tetromino
                    $scope.GameState.fallingTetromino.x--;
                    //add to new position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.ADD);

                } else {
                    if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
                }

                break;

            case 38: // up

                Tetromino.rotate(tetrominoAfterMovement);

                if (Game.checkIfTetrominoCanGoThere(tetrominoAfterMovement, $scope.GameState.board)) {

                    if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.Rotate);

                    //remove tetromino from current position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.REMOVE);
                    //rotate tetromino
                    Tetromino.rotate($scope.GameState.fallingTetromino);
                    //add to new position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.ADD);

                } else {
                    if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
                }
                break;

            case 39: // right

                tetrominoAfterMovement.x++;

                if (Game.checkIfTetrominoCanGoThere(tetrominoAfterMovement, $scope.GameState.board)) {

                    if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.Rotate);

                    //remove tetromino from current position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.REMOVE);
                    //move tetromino
                    $scope.GameState.fallingTetromino.x++;
                    //add to new position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.ADD);

                } else {
                    if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
                }

                break;

            case 40: // down

                tetrominoAfterMovement.y++;

                if (Game.checkIfTetrominoCanGoThere(tetrominoAfterMovement, $scope.GameState.board)) {
                    //remove tetromino from current position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.REMOVE);
                    //move tetromino
                    $scope.GameState.fallingTetromino.y++;
                    //add to new position
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.ADD);

                } else {
                    if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
                }

                break;

            default: return; // exit this handler for other keys
        }

    });

    //Initialize everything to start a new game
    function InitializeGame() {

        $scope.GameState.running = false;
        $scope.GameState.lines = 0;
        $scope.GameState.score = 0;
        $scope.GameState.level = 1;
        $scope.GameState.tetrominoBag = JSON.parse(JSON.stringify($scope.GameState.fullTetrominoBag));
        $scope.GameState.tetrominoHistory = [];
        $scope.GameState.IsHighscore = false;

        backgroundAnimationInfo = { Color: $scope.getGameColor($scope.GameState), AlternateColor: makeColorLighter($scope.getGameColor($scope.GameState), 50), Duration: 1500 - ($scope.level - 1) * 30 };

        //get next tetromino
        if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.Drop);
        if ($scope.GameState.nextTetromino) {
            $scope.GameState.fallingTetromino = $scope.GameState.nextTetromino;
        } else {
            $scope.GameState.fallingTetromino = GetNextRandomTetromino();
        }
        $scope.GameState.nextTetromino = GetNextRandomTetromino();
        $scope.GameState.nextTetrominoSquares = Tetromino.getSquares($scope.GameState.nextTetromino);

        //initialize game board
        $scope.GameState.board = new Array(Game.BoardSize.h);
        for (let y = 0; y < Game.BoardSize.h; y++) {
            $scope.GameState.board[y] = new Array(Game.BoardSize.w);
            for (let x = 0; x < Game.BoardSize.w; x++)
                $scope.GameState.board[y][x] = 0;
        }

        //show the first falling tetromino 
        Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.ADD);

    }

    //Returns a random Tetromino. A bag of all 7 tetrominoes are randomly shuffled and put in the field of play. If possible the same tetromino does not appear two consequtive times.
    function GetNextRandomTetromino() {

        //refill bag if empty
        let isEmpty = !$scope.GameState.tetrominoBag.some(function (a) { return a > 0; });
        let availableTetrominos = [];
        let randomTetrominoType;

        for (let i = 1; i <= 7; i++) {
            if ($scope.GameState.tetrominoBag[i] > 0) {
                availableTetrominos.push(i);
            }
        }

        if (isEmpty) {
            $scope.GameState.tetrominoBag = JSON.parse(JSON.stringify($scope.GameState.fullTetrominoBag));
            availableTetrominos = [Tetromino.TypeEnum.LINE, Tetromino.TypeEnum.BOX, Tetromino.TypeEnum.INVERTED_T, Tetromino.TypeEnum.S, Tetromino.TypeEnum.Z, Tetromino.TypeEnum.L, Tetromino.TypeEnum.INVERTED_L];
        }

        if (availableTetrominos.length == 1) {

            randomTetrominoType = availableTetrominos[0];

        } else if (availableTetrominos.length <= 3) {

            let randomNum = Math.floor((Math.random() * (availableTetrominos.length - 1)));
            randomTetrominoType = availableTetrominos[randomNum];

        } else {

            //don't allow the same tetromino two consecutive times
            let cantHaveThisTetromino = 0;
            if ($scope.GameState.tetrominoHistory.length > 0) {
                cantHaveThisTetromino = $scope.GameState.tetrominoHistory[$scope.GameState.tetrominoHistory.length - 1];
            }

            randomTetrominoType = Math.floor((Math.random() * 7) + 1);
            while ($scope.GameState.tetrominoBag[randomTetrominoType] == 0 || (randomTetrominoType == cantHaveThisTetromino)) {
                randomTetrominoType = Math.floor((Math.random() * 7) + 1);
            }

        }

        //keep a list of fallen tetrominos
        $scope.GameState.tetrominoHistory.push(randomTetrominoType);

        //decrease available items for this tetromino (bag with 7 of each)
        $scope.GameState.tetrominoBag[randomTetrominoType]--;

        return new Tetromino.tetromino(randomTetrominoType);
    }

    //Game is over. Check if there is a new highscore
    function GameOver() {

        if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.GameOver);

        $scope.GameState.running = false;
        $scope.GameState.startButtonText = "Start";

        if ($scope.GameState.score > 0 && $scope.highscores) {
            if ($scope.highscores.length < 10) {
                $scope.GameState.IsHighscore = true;
            } else {
                let minScore = $scope.highscores[$scope.highscores.length - 1].Score;
                $scope.GameState.IsHighscore = ($scope.GameState.score > minScore);
            }
        }

        $("#InfoGameover").modal("show");

    }

    // the game loop: If the tetris game is running 1. move the tetromino down if it can fall, 2. solidify the tetromino if it can't go futher down, 3. clear completed lines, 4. check for game over and send the next tetromino
    function GameLoop() {

        if (!$scope.GameState.running) return;

        let tetrominoCanFall = Game.checkIfTetrominoCanMoveDown($scope.GameState.fallingTetromino, $scope.GameState.board);
        if (tetrominoCanFall) {

            Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.REMOVE);
            $scope.GameState.fallingTetromino.y++;
            Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.ADD);

        } else {

            //tetromino is solidified. Check for game over and Send the next one.
            if ($scope.GameState.fallingTetromino.y == 0) {
                GameOver();
            } else {

                //solidify tetromino
                Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.SOLIDIFY);

                //clear completed lines
                let currentLevel = $scope.GameState.level;
                let howManyLinesCompleted = 0;
                while (Game.checkForTetris($scope.GameState)) {
                    howManyLinesCompleted++;
                }

                if (howManyLinesCompleted > 0) {

                    if (howManyLinesCompleted == 1)
                        $("#Game").effect("shake", { direction: "left", distance: "5", times: 3 }, 500);
                    else if (howManyLinesCompleted == 2)
                        $("#Game").effect("shake", { direction: "left", distance: "10", times: 4 }, 600);
                    else if (howManyLinesCompleted == 3)
                        $("#Game").effect("shake", { direction: "left", distance: "15", times: 5 }, 700);
                    else if (howManyLinesCompleted == 4) {
                        $("#Game").effect("shake", { direction: "left", distance: "30", times: 4 }, 500);
                        $("#Game").effect("shake", { direction: "up", distance: "30", times: 4 }, 500);
                    }

                    let scoreFontSize = 25 + (howManyLinesCompleted - 1) * 15;
                    $(".GameScoreValue").animate({ fontSize: scoreFontSize + "px" }, "fast");
                    $(".GameScoreValue").animate({ fontSize: "14px" }, "fast");

                    //give extra points for multiple lines
                    $scope.GameState.score = $scope.GameState.score + 50 * (howManyLinesCompleted - 1);
                    if (howManyLinesCompleted == 4) {
                        $scope.GameState.score = $scope.GameState.score + 500;
                    }

                    if ($scope.getSoundFX()) {
                        if (howManyLinesCompleted == 1)
                            soundEffectsService.play(app.SoundEffectEnum.LineComplete1);
                        else if (howManyLinesCompleted == 2)
                            soundEffectsService.play(app.SoundEffectEnum.LineComplete2);
                        else if (howManyLinesCompleted == 3)
                            soundEffectsService.play(app.SoundEffectEnum.LineComplete3);
                        else if (howManyLinesCompleted == 4)
                            soundEffectsService.play(app.SoundEffectEnum.LineComplete4);

                        if ($scope.GameState.level > currentLevel) soundEffectsService.play(app.SoundEffectEnum.NextLevel);
                    }

                    backgroundAnimationInfo = { Color: $scope.getGameColor($scope.GameState), AlternateColor: makeColorLighter($scope.getGameColor($scope.GameState), 50), Duration: 1500 - ($scope.level - 1) * 30 };
                }

                //send next one
                if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.Drop);
                if ($scope.GameState.nextTetromino) {
                    $scope.GameState.fallingTetromino = $scope.GameState.nextTetromino;
                } else {
                    $scope.GameState.fallingTetromino = GetNextRandomTetromino();
                }
                $scope.GameState.nextTetromino = GetNextRandomTetromino();
                $scope.GameState.nextTetrominoSquares = Tetromino.getSquares($scope.GameState.nextTetromino);

                tetrominoCanFall = Game.checkIfTetrominoCanMoveDown($scope.GameState.fallingTetromino, $scope.GameState.board);
                if (!tetrominoCanFall) {
                    GameOver();
                } else {
                    Game.modifyBoard($scope.GameState.fallingTetromino, $scope.GameState.board, Game.BoardActions.ADD);
                }

            }

        }

        //set the game timer. The delay depends on the current level. The higher the level, the fastest the game moves (harder)
        gameInterval = $timeout(GameLoop, Game.getDelay($scope.GameState));

    }

    //call the highscoreService to get the highscores and save result in the scope
    function GetHighscores() {

        $scope.PleaseWait_GetHighscores = true;

        highscoreService.get(function (highscores) {
            $scope.PleaseWait_GetHighscores = false;
            $scope.highscores = highscores;
        }, function (errMsg) {
            $scope.PleaseWait_GetHighscores = false;
            alert(errMsg);
        });
    }

    //Changes the provided color to be this percent lighter
    function makeColorLighter(color, percent) {
        let num = parseInt(color.slice(1), 16), amt = Math.round(2.55 * percent), R = (num >> 16) + amt, G = (num >> 8 & 0x00FF) + amt, B = (num & 0x0000FF) + amt;
        return "#" + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1);
    }

    //show a modal message 
    function ShowMessage(title, text) {
        $scope.GenericModal = { Title: title, Text: text };
        $("#InfoGeneric").modal("show");
    }

    //animate the background color between two colors
    function AnimateBodyBackgroundColor() {

        if (!backgroundAnimationInfo.AlternateColor) {
            backgroundAnimationInfo = {
                Color: Game.Colors[1], AlternateColor: makeColorLighter(Game.Colors[1], 50), Duration: 1500
            };
        }

        $("body").animate({
            backgroundColor: backgroundAnimationInfo.AlternateColor
        }, {
            duration: 1000,
            complete: function () {

                $("body").animate({
                    backgroundColor: backgroundAnimationInfo.Color
                }, {
                    duration: 1000,
                    complete: function () {
                        AnimateBodyBackgroundColor();
                    }
                });

            }
        });

    }

}]);
  • On the top we have a couple of private members. Notice that almost all JavaScript variables across the project are either declared with let or const. If you still use var everywhere, perhaps it's time read up on the difference between let, const and var.
  • There is an immediately-invoked function expression (or IIFE) that is the entry point of the AngularJS app. Ofcourse the code in app.js is executed before, but that’s “outside” AngularJS.
  • Then you have quite a few functions that are attached to the $scope because they must be accessible to the view. These functions allow the UI elements to interact with the game elements. For example, saveGame is called when the related menu is clicked, to serialize the game state and save it as a cookie. Some of these functions are not implemented in here but are simply pointers to member functions from the Game model class (models/game.js). Remember the DRY principe (Don’t Repeat Yourself)!
  • The onKeyDown function is obviously very important because it’s where the game input is handled. We use the ng-keydown directive to call this function. If we did it differently, the code in here would be “outside” of AngularJS. We would have to refresh the $scope manually, and this is not good practice. If you stick to using the AngularJS features instead of mixing it with HTML, you don’t have to worry about this.
  • Then you have a few private functions that don’t have to be accessed from the view. One of them is InitializeGame that gives default values to all the game state properties and is executed when the user clicks the button Start Game. The most important private function is GameLoop. This is where we check if a new tetromino must be sent, if some lines were completed or if the game is over.

7. The Server

A simple WebAPI service that reads and writes high scores

The high scores are saved in an SQL Server database table. The WebAPI controller interacts with this data by using the model class Highscores.cs :

Models\Highscores.cs
public class Highscores
{
    public int id { get; set; }
    public string Name { get; set; }
    public int Score { get; set; }
    public System.DateTime DateCreated { get; set; }
}

Initially I used the code-first approach of Entity Framework, because we don’t have an existing database to connect to. Unfortunately deploying a code-first app to Azure was not readily supported on my Azure account, so I switched to using an existing SQL Server database.

Here are the only important bits of code in the server-side of the solution:

App_Start\WebApiConfig.cs

The routes that our web service has are defined here and the only one is http://<server>/api/highscores

// Web API routes
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
    name: "DefaultApi",
    routeTemplate: "api/{controller}/{id}",
    defaults: new { id = RouteParameter.Optional }
);

Since we are building a RESTful service, the standard GET and PUT verbs of the HTTP protocol will be used. So the client that needs to read the high scores will make a GET request to this URL. When the client needs to write a new score, they will do a PUT to the same URL.

Controllers\HighscoresController.cs
public class HighscoresController : ApiController
{
    // GET api/<controller>
    /// <summary>
    /// Returns the list of highscores
    /// </summary>
    public List<Models.Highscores> Get()
    {
        using (Models.AngularContext db = new Models.AngularContext("AngularTetrisDB"))
        {
            var result = db.Highscores.OrderByDescending(h => h.Score).ToList();
            return result;
        }
    }

    /// <summary>
    /// Adds a new highscore to the database
    /// </summary>
    /// <param name="newItem"></param>
    [HttpPost]
    public void Put(Models.Highscores newItem)
    {
        using (Models.AngularContext db = new Models.AngularContext("AngularTetrisDB"))
        {
            //add new highscore
            newItem.DateCreated = DateTime.Now;
            db.Highscores.Add(newItem);

            //delete lower highscore if there are more than 10
            if (db.Highscores.Count() > 9)
            {
                var lowest = db.Highscores.OrderBy(h => h.Score).First();
                db.Highscores.Remove(lowest);
                db.Entry(lowest).State = System.Data.Entity.EntityState.Deleted;
            }

            //persist changes with entity framework
            db.SaveChanges();
        }
    }
}

What’s great about WebAPI is that it integrates very easily with JavaScript, because a POCO on the server side is the same as a POCO in the client side. In our JavaScript code, a highscore will be saved in a simple anonymous object with properties Name, Score and DateCreated. An array of these will be returned by the Ajax library we use to connect to the API. In our case we use AngularJS’s $http object:

AngularJS-App\services\highscoreService.js
//Query the WebAPI action method to get the list of highscores
factory.get = function (successCallback, errorCallback) {
    $http.get("/api/highscores").then(
        (response) => successCallback(response.data),
        (response) => errorCallback("Error while reading the highscores: " + response.data.Message)
    );
};

In the above code that uses JavaScript promises, the function successCallback will receive an array of High score objects. We could also return the Promise object instead of using callbacks. If our server and client were separate projects, the GET call would have the full URL such as http://server/api/highscores.  

 

8. Conclusion

Looking forward to your feedback

The reason I created this article is to ask the community for suggestions for improvements in this project. I’m particularly interested in how the code can be improved to better meet best practices. This applies mostly to AngularJS but also to the rest of the technologies/frameworks used here. If you enjoyed this article or felt that it helped you to add something to your knowledge, please upvote it here in CodeProject.

You can also go to the github page and give it a star.

https://github.com/TheoKand/AngularJS-Tetris

I’d be even more thrilled if you try to fork it and play around with the code. I’ll definitely continue to update the the main branch my self if and when I get some good suggestions. 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Theo Kand
Software Developer (Senior) Freelancer
Greece Greece
No Biography provided

You may also be interested in...

Comments and Discussions

 
-- There are no messages in this forum --
Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web06-2016 | 2.8.181114.1 | Last Updated 30 Oct 2017
Article Copyright 2017 by Theo Kand
Everything else Copyright © CodeProject, 1999-2018
Layout: fixed | fluid