In this article, you will find the step by step documentation of the design and implementation of the popular Falling Blocks game as an SPA, using AngularJS.
Table of Contents
- Introduction
- AngularJS - An Introduction to the framework
- The Code - How does everything hang together in the solution?
- Falling Blocks - What is it and what are the basic data structures?
- The M in MVC - The Falling Blocks data structures and logic implemented in JavaScript classes
- AngularJS View & Controller - The V and C in MVC
- Server - A simple WebAPI service that reads and writes high scores
- Conclusion - Looking forward to your feedback
This article documents the design and implementation of the popular Falling Blocks 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:
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.
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 necessary, 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 necessary 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 scalable 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.
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
.
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:
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
.
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';
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"],
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;
}
},
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;
},
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 its 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 },
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;
if ((boardY > Game.BoardSize.h - 1) || (boardY < 0) ||
(boardX < 0) || (boardX > Game.BoardSize.w - 1)) {
return false;
}
if (board[boardY][boardX] < 0) {
return false;
}
}
}
}
return true;
},
checkIfTetrominoCanMoveDown: function (tetromino, board) {
let newTetromino = JSON.parse(JSON.stringify(tetromino));
newTetromino.y++;
return Game.checkIfTetrominoCanGoThere(newTetromino, board);
},
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;
}
}
}
},
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;
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];
}
}
if (gameState.lines % 5 == 0) {
gameState.level++;
}
return true;
}
}
return false;
},
getGameColor: function (gameState) {
if (gameState)
return Game.Colors[(gameState.level % Game.Colors.length)];
},
getSquareColor: function (gameState, y, x) {
let square = gameState.board[y][x];
if (square < 0) {
return Tetromino.Colors[Math.abs(square)];
} else {
return Tetromino.Colors[square];
}
},
getSquareCssClass: function (gameState, y, x) {
let square = gameState.board[y][x];
if (square == 0) {
return "Square ";
} else if (square < 0) {
return "Square SolidSquare";
} else {
return "Square TetrominoSquare";
}
},
getNextTetrominoColor: function (gameState, y, x) {
let square = gameState.nextTetrominoSquares[y][x];
if (square == 0) {
return $scope.getGameColor();
} else {
return Tetromino.Colors[square];
}
},
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;
},
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 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 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.
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">
<!--
<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() }}">
<!--
<div class="preloading">
<img src="assets/images/TetrisAnimated.gif" style="width:300px" />
<h1>... loading ...</h1>
</div>
<div class="container">
<div class="row">
<!--
<a href="https://github.com/TheoKand" target="_blank">
<img style="position: absolute; top: 0; right: 0; border: 0;"
src="https://camo.githubusercontent.com/652c5b9acfaddf3a9c326fa6bde407b87f7be0f4/
68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732
f666f726b6d655f72696768745f6f72616e67655f6666373630302e706e67"
alt="Fork me on GitHub"
data-canonical-src="https://s3.amazonaws.com/github/ribbons/
forkme_right_orange_ff7600.png"></a>
<!--
<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>
<!--
<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>
<!--
<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">
<!--
<div class="splash" ng-style="!GameState.running ?
{ 'display':'block'} : { 'display': 'none' }">
<img src="assets/images/logo.png" />
</div>
<!--
<div id="Game">
<!--
<div id="GameInfo">
Score: <b style="font-size:14px"
class="GameScoreValue">{{GameState.score}}</b><br />
Level: <b>{{GameState.level}}</b><br />
Next:
<!--
<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>
<!--
<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>
<!--
<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>
<!--
<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">×</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 accelerate. 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 »</a></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!--
<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">×</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>
<!--
<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">×</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>
<!--
<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">×</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>
<!--
<link rel="stylesheet" href="assets/css/Site.min.css" />
<link rel="stylesheet"
href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" />
<!--
<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>
<!--
<!--
<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>
<!--
<!--
</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;
let backgroundAnimationInfo = {};
(function () {
if (!(app.getCookie("AngularTetris_Music") === false))
soundEffectsService.playTheme();
GetHighscores();
AnimateBodyBackgroundColor();
$scope.GameState = new Game.gameState();
let infoHasBeenDisplayed = app.getCookie("AngularTetris_InfoWasDisplayed");
if (infoHasBeenDisplayed == "") {
app.setCookie("AngularTetris_InfoWasDisplayed", true, 30);
$("#InfoModal").modal('show');
}
})();
$scope.setMusic = function (on) {
if (on) {
app.setCookie("AngularTetris_Music", true, 30);
soundEffectsService.playTheme();
} else {
app.setCookie("AngularTetris_Music", false, 30);
soundEffectsService.stopTheme();
}
};
$scope.getMusic = function () {
return !(app.getCookie("AngularTetris_Music") === false);
};
$scope.setSoundFX = function (on) {
if (on) {
app.setCookie("AngularTetris_SoundFX", true, 30);
} else {
app.setCookie("AngularTetris_SoundFX", false, 30);
}
};
$scope.getSoundFX = function () {
return !(app.getCookie("AngularTetris_SoundFX") === false);
};
$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.");
};
$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!");
}
};
$scope.startGame = function () {
if (!$scope.GameState.running) {
if (!$scope.GameState.paused) {
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);
}
};
$scope.getGameColor = Game.getGameColor;
$scope.getSquareColor = Game.getSquareColor;
$scope.getSquareCssClass = Game.getSquareCssClass;
$scope.getNextTetrominoColor = Game.getNextTetrominoColor;
$scope.saveHighscore = function () {
let highscore = { Name: $('#txtName').val(), Score: $scope.GameState.score };
if (highscore.Name.length == 0) {
ShowMessage("", "Please enter your name!");
return;
}
$scope.PleaseWait_SaveHighscores = true;
highscoreService.put(highscore, function () {
$scope.PleaseWait_SaveHighscores = false;
$scope.GameState.IsHighscore = false;
GetHighscores();
}, function (errMsg) {
$scope.PleaseWait_SaveHighscores = false;
alert(errMsg);
});
};
$scope.onKeyDown = (function (key) {
if (!$scope.GameState.running) return;
let tetrominoAfterMovement =
JSON.parse(JSON.stringify($scope.GameState.fallingTetromino));
switch (key) {
case 37:
tetrominoAfterMovement.x--;
if (Game.checkIfTetrominoCanGoThere(tetrominoAfterMovement,
$scope.GameState.board)) {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.Rotate);
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
$scope.GameState.fallingTetromino.x--;
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
case 38:
Tetromino.rotate(tetrominoAfterMovement);
if (Game.checkIfTetrominoCanGoThere
(tetrominoAfterMovement, $scope.GameState.board)) {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.Rotate);
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
Tetromino.rotate($scope.GameState.fallingTetromino);
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
case 39:
tetrominoAfterMovement.x++;
if (Game.checkIfTetrominoCanGoThere
(tetrominoAfterMovement, $scope.GameState.board)) {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.Rotate);
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
$scope.GameState.fallingTetromino.x++;
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
case 40:
tetrominoAfterMovement.y++;
if (Game.checkIfTetrominoCanGoThere
(tetrominoAfterMovement, $scope.GameState.board)) {
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 {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
default: return;
}
});
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 };
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);
$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;
}
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
}
function GetNextRandomTetromino() {
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 {
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);
}
}
$scope.GameState.tetrominoHistory.push(randomTetrominoType);
$scope.GameState.tetrominoBag[randomTetrominoType]--;
return new Tetromino.tetromino(randomTetrominoType);
}
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");
}
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 {
if ($scope.GameState.fallingTetromino.y == 0) {
GameOver();
} else {
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.SOLIDIFY);
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");
$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 };
}
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);
}
}
}
gameInterval = $timeout(GameLoop, Game.getDelay($scope.GameState));
}
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);
});
}
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);
}
function ShowMessage(title, text) {
$scope.GenericModal = { Title: title, Text: text };
$("#InfoGeneric").modal("show");
}
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 to 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. Of course, 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 principle (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.
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
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
{
public List<Models.Highscores> Get()
{
using (Models.AngularContext db = new Models.AngularContext("AngularTetrisDB"))
{
var result = db.Highscores.OrderByDescending(h => h.Score).ToList();
return result;
}
}
[HttpPost]
public void Put(Models.Highscores newItem)
{
using (Models.AngularContext db = new Models.AngularContext("AngularTetrisDB"))
{
newItem.DateCreated = DateTime.Now;
db.Highscores.Add(newItem);
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;
}
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
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.
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.
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 main branch myself if and when I get some good suggestions.
History
- 30th October, 2017: Initial version