3D Tetris With Three.js Tutorial
This simple tutorial walks you through how to create a Tetris game with Three.js.
Join the DZone community and get the full member experience.
Join For FreeThink about the way we play Tetris. When the block is moving, we transform and rotate it freely. Cubes that make blocks are clearly connected and it's intuitive that their representation in code should be as well. On the other hand, when we try to complete a slice (in 2D, a row) and we succeed, the cubes are removed and the block that was their origin doesn't matter at this point. In fact, it shouldn't matter — some boxes from a block may be removed and others not.
Tracing an origin of a box would require constant splitting and merging geometries and trust me, that would be a crazy mess. In original 2D Tetris, sometimes the color of a square was the indicator of the origin block. In 3D, however, we need a quick way to show the z-axis and color is perfect for this.
In our game, cubes will be connected when are dynamic and static when they are not.
Adding a Static Block
Let's start with a moment when a moving block touches the floor (or another block). The moving block (with merged geometry of a few cubes) is transformed into static, separated cubes that don't move anymore. It's convenient to keep these cubes in a 3D array.
Tetris.staticBlocks = [];
Tetris.zColors = [
0x6666ff, 0x66ffff, 0xcc68EE, 0x666633, 0x66ff66, 0x9966ff, 0x00ff66, 0x66EE33, 0x003399, 0x330099, 0xFFA500, 0x99ff00, 0xee1289, 0x71C671, 0x00BFFF, 0x666633, 0x669966, 0x9966ff
];
Tetris.addStaticBlock = function(x,y,z) {
if(Tetris.staticBlocks[x] === undefined) Tetris.staticBlocks[x] = [];
if(Tetris.staticBlocks[x][y] === undefined) Tetris.staticBlocks[x][y] = [];
var mesh = THREE.SceneUtils.createMultiMaterialObject(new THREE.CubeGeometry( Tetris.blockSize, Tetris.blockSize, Tetris.blockSize), [
new THREE.MeshBasicMaterial({color: 0x000000, shading: THREE.FlatShading, wireframe: true, transparent: true}),
new THREE.MeshBasicMaterial({color: Tetris.zColors[z]})
] );
mesh.position.x = (x - Tetris.boundingBoxConfig.splitX/2)*Tetris.blockSize + Tetris.blockSize/2;
mesh.position.y = (y - Tetris.boundingBoxConfig.splitY/2)*Tetris.blockSize + Tetris.blockSize/2;
mesh.position.z = (z - Tetris.boundingBoxConfig.splitZ/2)*Tetris.blockSize + Tetris.blockSize/2;
mesh.overdraw = true;
Tetris.scene.add(mesh);
Tetris.staticBlocks[x][y][z] = mesh;
};
There is a lot to explain here.
Colors and Materials
Tetris.zColors keeps a list of colors that indicate position of a cube on z-axis. I'd like to have a good looking cube, so it should have a color AND outlined border. I'm going to use something that is not very popular in Three.js tutorials - multiMaterials. There is a function in Three.js SceneUtils that takes a geometry and an array (notice brackets []) of materials. If you look in Three.js source:
createMultiMaterialObject : function ( geometry, materials ) {
var i, il = materials.length, group = new THREE.Object3D();
for ( i = 0; i < il; i ++ ) {
var object = new THREE.Mesh( geometry, materials[ i ] );
group.add( object );
}
return group;
},
It's a very simple hack that creates a mesh for every material. With pure WebGL there are better ways to achieve the same result (f.e. calling draw two times, once with gl.LINES and second with gl.something) but the usual use of this function is to for example to merge textures and materials at the same time - not different types of drawing.
Position in 3D Space
Now, why does the position look like that?
mesh.position.x = (x - Tetris.boundingBoxConfig.splitX/2)*Tetris.blockSize + Tetris.blockSize/2;
Our board center on init was placed in the (0,0,0) point. It is not a very good spot, as it means that some cubes will have a negative position and others positive. It would be better to specify a corner of an object in our case. Moreover, we would like to think of our boxes' positions as discrete values from 1 to 6, or at least 0 to 5. Three.js (and WebGL, OpenGL, and everything else) uses its own units that rather relate to meters or pixels. If you remember, in config, we put a value
Tetris.blockSize = boundingBoxConfig.width/boundingBoxConfig.splitX;
That is responsible for conversion. So as a summary:
// transform 0-5 to -3 - +2
(x - Tetris.boundingBoxConfig.splitX/2)
// scale to Three.js units
*Tetris.blockSize
// we specify cube center, not a corner - we have to shift position
+ Tetris.blockSize/2
Nice Test
Our game is still very static, but you can open your console and run:
var i = 0, j = 0, k = 0, interval = setInterval(function() {if(i==6) {i=0;j++;} if(j==6) {j=0;k++;} if(k==6) {clearInterval(interval); return;} Tetris.addStaticBlock(i,j,k); i++;},30)
It should animate filling the board with cubes.
Keeping Score
A small utility function to keep score:
Tetris.currentPoints = 0;
Tetris.addPoints = function(n) {
Tetris.currentPoints += n;
Tetris.pointsDOM.innerHTML = Tetris.currentPoints;
Cufon.replace('#points');
}
Preparation
First, create a new file to hold our block object and include it in index.html. The file should start with:
window.Tetris = window.Tetris || {}; // equivalent to if(!window.Tetris) window.Tetris = {};
This way, even if file parse order is somehow disturbed (which is very unlikely, BTW) you will never overwrite existing objects or use undefined variables. At this point, you may want to replace "var Tetris = {};
" declaration in our main file as well.
We need one utility function before we continue.
Tetris.Utils = {};
Tetris.Utils.cloneVector = function (v) {
return {x: v.x, y: v.y, z: v.z};
};
To understand why on Earth would we need it, we have to talk about variables in JS. If we use a number, it's always passed by value. It means that writing:
var a = 5;
var b = a;
will put number 5 in b, but it won't be related anyhow to a. However, when using objects:
var a = (x: 5};
var b = a;
b is a reference to the object. Using b.x = 6; will write to the very same object that is referenced by a.
That's why we need a method to create a copy of a vector. Simple v1 = v2 will mean that there is only one vector in our memory. However, if we access directly numeric parts of vector and make a clone, we would have two vectors and manipulating them will be independent.
The last preparation is the definition of shapes.
Tetris.Block = {};
Tetris.Block.shapes = [
[
{x: 0, y: 0, z: 0},
{x: 1, y: 0, z: 0},
{x: 1, y: 1, z: 0},
{x: 1, y: 2, z: 0}
],
[
{x: 0, y: 0, z: 0},
{x: 0, y: 1, z: 0},
{x: 0, y: 2, z: 0},
],
[
{x: 0, y: 0, z: 0},
{x: 0, y: 1, z: 0},
{x: 1, y: 0, z: 0},
{x: 1, y: 1, z: 0}
],
[
{x: 0, y: 0, z: 0},
{x: 0, y: 1, z: 0},
{x: 0, y: 2, z: 0},
{x: 1, y: 1, z: 0}
],
[
{x: 0, y: 0, z: 0},
{x: 0, y: 1, z: 0},
{x: 1, y: 1, z: 0},
{x: 1, y: 2, z: 0}
]
];
Note that every shape's first cube is (0,0,0). It's very important and will be explained in the next section.
Shape Generation
There are three values that describe a block: base shape, position and rotation. At this point we should think ahead about how we want to detect collision.
From my experience I can tell that collision detection in games is always more or less fake. It's all about performance — geometries are simplified, collisions for specific situations are ruled out first, some collisions are not considered at all, and collision response is almost always not accurate. It doesn't matter — if it looks natural, nobody will ever notice and we save a lot of precious CPU cycles.
So, what is the simplest collision detection for Tetris? All shapes are axis-aligned cubes with centers in one of the specified group of points. I'm 99 percent sure that keeping an array of values [FREE, MOVING, STATIC] for every position on the board is the best way to deal with it. This way, if we want to move a shape and space it would need is already occupied - we have a collision. Complexity: O(number of cubes in a shape) <=> O(1). Boo-yah!
Now, I know that rotation is quite complex and we should avoid it if possible. That's why we will keep the basic shape of the block in a rotated form. This way we can apply only position (which is simple) and quickly check if we have a collision. It actually doesn't matter that much in our case, but it would in a game that would be more complex. There is no game small enough to be programmed in a lazy way.
About position and rotation - both of these are used in Three.js. The problem is, however, that we use different units in Three.js and on our board. To keep our code simple, we will store position separately. Rotation is the same everywhere so we will use the built-in one.
First, we take a shape at random and create a copy. This is why we needed cloneVector function.
Tetris.Block.position = {};
Tetris.Block.generate = function() {
var geometry, tmpGeometry;
var type = Math.floor(Math.random()*(Tetris.Block.shapes.length));
this.blockType = type;
Tetris.Block.shape = [];
for(var i = 0; i < Tetris.Block.shapes[type].length; i++) {
Tetris.Block.shape[i] = Tetris.Utils.cloneVector(Tetris.Block.shapes[type][i]);
}
Now we need to connect all the cubes to act as one shape.
There is a Three.js function for that - it takes a geometry and a mesh and merges them. What actually happens here is a merge of the internal vertices array. It takes into account the position of merged geometry. It is a reason why we needed the first cube to be (0,0,0). Mesh has a position, but geometry hasn't - it's always considered to be (0,0,0). It would be possible to write a merge function for two meshes, but it's more complicated than keeping shapes as we did, isn't it?
geometry = new THREE.CubeGeometry(Tetris.blockSize, Tetris.blockSize, Tetris.blockSize);
for(var i = 1 ; i < Tetris.Block.shape.length; i++) {
tmpGeometry = new THREE.Mesh(new THREE.CubeGeometry(Tetris.blockSize, Tetris.blockSize, Tetris.blockSize));
tmpGeometry.position.x = Tetris.blockSize * Tetris.Block.shape[i].x;
tmpGeometry.position.y = Tetris.blockSize * Tetris.Block.shape[i].y;
THREE.GeometryUtils.merge(geometry, tmpGeometry);
}
With merged geometry, we can use a trick with double materials from earlier in the tutorial.
Tetris.Block.mesh = THREE.SceneUtils.createMultiMaterialObject(geometry, [
new THREE.MeshBasicMaterial({color: 0x000000, shading: THREE.FlatShading, wireframe: true, transparent: true}),
new THREE.MeshBasicMaterial({color: 0xff0000})
]);
We have to set the initial position and rotation for our block (a center of board for x,y, and some arbitrary number for z).
// initial position
Tetris.Block.position = {x: Math.floor(Tetris.boundingBoxConfig.splitX/2)-1, y: Math.floor(Tetris.boundingBoxConfig.splitY/2)-1, z: 15};
Tetris.Block.mesh.position.x = (Tetris.Block.position.x - Tetris.boundingBoxConfig.splitX/2)*Tetris.blockSize/2;
Tetris.Block.mesh.position.y = (Tetris.Block.position.y - Tetris.boundingBoxConfig.splitY/2)*Tetris.blockSize/2;
Tetris.Block.mesh.position.z = (Tetris.Block.position.z - Tetris.boundingBoxConfig.splitZ/2)*Tetris.blockSize + Tetris.blockSize/2;
Tetris.Block.mesh.rotation = {x: 0, y: 0, z: 0};
Tetris.Block.mesh.overdraw = true;
Tetris.scene.add(Tetris.Block.mesh);
}; // end of Tetris.Block.generate()
If you want, you can call Tetris.Block.generate() from you console.
Moving
Moving a block is actually very simple. For rotation, we use Three.js internals and we have to convert angles to radians.
Tetris.Block.rotate = function(x,y,z) {
Tetris.Block.mesh.rotation.x += x * Math.PI / 180;
Tetris.Block.mesh.rotation.y += y * Math.PI / 180;
Tetris.Block.mesh.rotation.z += z * Math.PI / 180;
};
Position is also simple: Three.js needs a position considering block size and our copy doesn't. There is a simple floor hit check for our entertainment; it will be removed later.
Tetris.Block.move = function(x,y,z) {
Tetris.Block.mesh.position.x += x*Tetris.blockSize;
Tetris.Block.position.x += x;
Tetris.Block.mesh.position.y += y*Tetris.blockSize;
Tetris.Block.position.y += y;
Tetris.Block.mesh.position.z += z*Tetris.blockSize;
Tetris.Block.position.z += z;
if(Tetris.Block.position.z == 0) Tetris.Block.hitBottom();
};
Hit and Create Again
What is hitBottom for? Remember? If a block life cycle has ended, we should convert it to static cubes, remove it from the scene and generate a new one.
Tetris.Block.hitBottom = function() {
Tetris.Block.petrify();
Tetris.scene.removeObject(Tetris.Block.mesh);
Tetris.Block.generate();
};
We already have generate() and removeObject() is a Three.js function for removing unused meshes. Luckily, earlier we wrote a function for static cubes and we now will use it in petrify().
Tetris.Block.petrify = function() {
var shape = Tetris.Block.shape;
for(var i = 0 ; i < shape.length; i++) {
Tetris.addStaticBlock(Tetris.Block.position.x + shape[i].x, Tetris.Block.position.y + shape[i].y, Tetris.Block.position.z + shape[i].z);
}
};
There is a shorthand for Tetris.Block.shape used - it improves both code clarity and performance, so use this technique every time it's suitable. In this function you can see why keeping a rotated shape and separated position was a good idea. Thanks to that our code will be pleasant to read and with collision detection it will be even more important.
Connect the Dots
OK, now we have all functions we need for blocks, let's hook them where needed. We need to generate one block on start, so change Tetris.start() to:
Tetris.start = function() {
document.getElementById("menu").style.display = "none";
Tetris.pointsDOM = document.getElementById("points");
Tetris.pointsDOM.style.display = "block";
Tetris.Block.generate(); // add this line
Tetris.animate();
};
With every game step we should move the block one step forward, so locate a place in
Tetris.animate() where we make a move and change it to:
while(Tetris.cumulatedFrameTime > Tetris.gameStepTime) {
Tetris.cumulatedFrameTime -= Tetris.gameStepTime;
Tetris.Block.move(0,0,-1);
Keyboard
I have to be honest: I hate keyboard events. The keycodes are meaningless and they are different for keydown and keypress. There is no good way to poll keyboard state, after second keypress event is repeated 10 times faster than for the first two, etc. If you think about serious game with a lot of keyboard interaction, you will almost certainly build some kind of wrapper for all this bullshit. You may try KeyboardJS, it looks good. I'll use vanilla JS to show the general idea. To debug it I've used console.log(keycode). It helps a lot to find the correct codes.
window.addEventListener('keydown', function (event) {
var key = event.which ? event.which : event.keyCode;
switch(key) {
case 38: // up (arrow)
Tetris.Block.move(0, 1, 0);
break;
case 40: // down (arrow)
Tetris.Block.move(0, -1, 0);
break;
case 37: // left(arrow)
Tetris.Block.move(-1, 0, 0);
break;
case 39: // right (arrow)
Tetris.Block.move(1, 0, 0);
break;
case 32: // space
Tetris.Block.move(0, 0, -1);
break;
case 87: // up (w)
Tetris.Block.rotate(90, 0, 0);
break;
case 83: // down (s)
Tetris.Block.rotate(-90, 0, 0);
break;
case 65: // left(a)
Tetris.Block.rotate(0, 0, 90);
break;
case 68: // right (d)
Tetris.Block.rotate(0, 0, -90);
break;
case 81: // (q)
Tetris.Block.rotate(0, 90, 0);
break;
case 69: // (e)
Tetris.Block.rotate(0, -90, 0);
break;
}
}, false);
If you try to play the game now, you should be able to move and rotate a block. There will be no collision detection, but when it hits the ground, it will be removed and new block will appear on board. Because we don't apply rotation to the stored shape, static version may be rotated differently.
Board Object
We will start with a new class to store our 3D space information. We need few “const”, “enum” values. They are really neither const nor enum, as there are no such things in JS, but there is a new function in JS 1.8.5 – freeze. You can create an object and protect it from any further modification. It is widely supported in all browsers that may run WebGL and will give us enum-like objects.
window.Tetris = window.Tetris || {};
Tetris.Board = {};
Tetris.Board.COLLISION = {NONE:0, WALL:1, GROUND:2};
Object.freeze(Tetris.Board.COLLISION);
Tetris.Board.FIELD = {EMPTY:0, ACTIVE:1, PETRIFIED:2};
Object.freeze(Tetris.Board.FIELD);
We will use field enum to store state of our board in fields array. On game start we need to initialize it as empty.
Tetris.Board.fields = [];
Tetris.Board.init = function(_x,_y,_z) {
for(var x = 0; x < _x; x++) {
Tetris.Board.fields[x] = [];
for(var y = 0; y < _y; y++) {
Tetris.Board.fields[x][y] = [];
for(var z = 0; z < _z; z++) {
Tetris.Board.fields[x][y][z] = Tetris.Board.FIELD.EMPTY;
}
}
}
};
Tetris.Board.init() should be called before any block appears in game. I call it from Tetris.init, because we can easily provide board dimensions as parameters:
// add anywhere in Tetris.init
Tetris.Board.init(boundingBoxConfig.splitX, boundingBoxConfig.splitY, boundingBoxConfig.splitZ);
We should also modify Tetris.Block.petrify function, so that it stores information in our new array.
Tetris.Block.petrify = function () {
var shape = Tetris.Block.shape;
for (var i = 0; i < shape.length; i++) {
Tetris.addStaticBlock(Tetris.Block.position.x + shape[i].x, Tetris.Block.position.y + shape[i].y, Tetris.Block.position.z + shape[i].z);
Tetris.Board.fields[Tetris.Block.position.x + shape[i].x][Tetris.Block.position.y + shape[i].y][Tetris.Block.position.z + shape[i].z] = Tetris.Board.FIELD.PETRIFIED;
}
};
Collision Detection
There are two main types of collisions in Tetris. The first one is wall collision, when an active block hits a wall or another block while being moved or rotated on x/y axes (e.g. on one level). The second one is ground collision which happens when a block is moved on the z-axis and hits the floor or another block and its life cycle is finished.
We will start with board walls collision, which is quite easy. To make code nicer (and faster) I used shorthands again.
Tetris.Board.testCollision = function (ground_check) {
var x, y, z, i;
// shorthands
var fields = Tetris.Board.fields;
var posx = Tetris.Block.position.x, posy = Tetris.Block.position.y,
posz = Tetris.Block.position.z, shape = Tetris.Block.shape;
for (i = 0; i < shape.length; i++) {
// 4 walls detection for every part of the shape
if ((shape[i].x + posx) < 0 ||
(shape[i].y + posy) < 0 ||
(shape[i].x + posx) >= fields.length ||
(shape[i].y + posy) >= fields[0].length) {
return Tetris.Board.COLLISION.WALL;
}
Now, how to deal with block-block collision? We already store petrified blocks in our array, so we can check if block is intersecting with any of existing cubes. You may wonder why testCollision has ground_check as an argument. It’s a result of a simple observation, that block-block collision is detected in almost the same way for ground and wall collision. The only distinction is movement on the z-axis which should cause a ground hit.
if (fields[shape[i].x + posx][shape[i].y + posy][shape[i].z + posz - 1] === Tetris.Board.FIELD.PETRIFIED) {
return ground_check ? Tetris.Board.COLLISION.GROUND : Tetris.Board.COLLISION.WALL;
}
We will also test if the position on the z-axis is not equal to zero. That means that there are no cubes below our moving block, but it reached the ground level and should be petrified anyway.
if((shape[i].z + posz) <= 0) {
return Tetris.Board.COLLISION.GROUND;
}
}
};
Collision Reaction
It wasn’t so bad, was it? Now let's do something with the information we have. We will start in the easiest place, detection of the lost game. We can do it by testing if there is a collision immediately after creating a new block. If it hits the ground, there is no point in playing further.
Add to Tetris.Block.generate after block position is calculated:
if (Tetris.Board.testCollision(true) === Tetris.Board.COLLISION.GROUND) {
Tetris.gameOver = true;
Tetris.pointsDOM.innerHTML = "GAME OVER";
Cufon.replace('#points');
}
Movement is also simple. After we change a position we call collision detection, passing the information about z-axis movement as an argument.
If there is a wall collision the move was impossible and we should undo it. We could add few lines to subtract position but I’m lazy and I prefer to call the move function again, but with inverted arguments. It will be never used with z-axis movement so we can pass a zero as z.
If the shape hits the ground, we already have a function hitBottom() that should be called. It will remove the active shapes from the game, modify the board state and create a new shape.
// add instead of ground level detection from part 3
var collision = Tetris.Board.testCollision((z != 0));
if (collision === Tetris.Board.COLLISION.WALL) {
Tetris.Block.move(-x, -y, 0); // laziness FTW
}
if (collision === Tetris.Board.COLLISION.GROUND) {
Tetris.Block.hitBottom();
}
If you run game at this point, you will notice that the rotating shape is not permanent. When it hits the ground, it returns to the initial rotation. That’s because we apply rotation to the Three.js mesh (as Tetris.Block.mesh.rotation) but we don’t use it to get coordinates of our cube-based shape representation. To deal with it we need to go through a quick math lesson.
3D Math
DISCLAIMER: If you are afraid of math or have little time, you can actually skip this part. It’s important to know what happens inside your engine, but later we will use a Three.js functions for that.
Consider a three-element vector (which represents a position in 3D space). To transform such vector in euclidean space we have to add another vector. It can be represented as:
\[\begin{matrix}x\\y\\z\\\end{matrix}\ + \begin{matrix}\delta x\\\delta y\\\delta z\\\end{matrix} = \begin{matrix}x'\\ y'\\ z'\\\end{matrix} \]
It’s fairly simple. The problem appears when we would like to rotate a vector. Rotation around a single axis affects two of the three coordinates (check if you don’t believe) and equations aren’t that simple. Luckily, there is one method used in almost all computer-generated graphics, including Three.js, WebGL, OpenGL, and GPU itself.
If you remember from high school, multiplying a vector by matrix will result in another vector. There is a number of transformations based on that. The easiest one is neutral transformation (using identity matrix) that does nothing but shows general idea and is used as a base for other transformations.
\[\begin{matrix}x\\y\\z\\w\\\end{matrix}\ * \begin{matrix} 1 & 0 & 0 & 0\\0 & 1 & 0 & 0\\0 & 0 & 1 & 0\\0 & 0 & 0 & 1\end{matrix}\ = \begin{matrix}x\\y\\z\\w\\\end{matrix}\]
Why do we use 4×4 matrices and 4-element vectors instead of 3×3 and 3-elements? It’s used to enable translation by a vector:
\[\begin{matrix}x\\y\\z\\w\\\end{matrix}\ * \begin{matrix} 1 & 0 & 0 & \delta x\\0 & 1 & 0 & \delta y\\0 & 0 & 1 & \delta z\\0 & 0 & 0 & 1\end{matrix}\ = \begin{matrix}x'\\y'\\z'\\w'\\\end{matrix}\]
It’s a nice math trick that makes all equations easier. It also helps with numerical errors and enables us to use even more advanced concepts like quaternions.
Scaling is also simple:
\[\begin{matrix}x\\y\\z\\w\\\end{matrix}\ * \begin{matrix} sx & 0 & 0 & 0\\ 0 & sy & 0 & 0\\ 0 & 0 & sz & 0\\ 0 & 0 & 0 & 1 \end{matrix}= \begin{matrix}x * sx\\y * sy\\z * sz\\w'\\\end{matrix}\]
There are three matrices for rotations, one for every axis.
For x-axis
\[ \begin{matrix} 1 & 0 & 0 & 0\\ 0 & cos \alpha & -sin \alpha & 0\\ 0 & sin \alpha & cos \alpha & 0\\ 0 & 0 & 0 & 1 \end{matrix}\]
For y-axis
\[ \begin{matrix} cos \alpha & 0 & sin \alpha & 0\\ 0 & 1 & 0 & 0\\ -sin \alpha & 0 & cos \alpha & 0\\ 0 & 0 & 0 & 1 \end{matrix}\]
For z-axis
\[ \begin{matrix} cos \alpha & -sin \alpha & 0 & 0\\ sin \alpha & cos \alpha & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1 \end{matrix}\]
Another great thing about matrix transformations is that we can easily combine two transformations by multiplying its matrices. If you want to rotate around all three axes, you can multiply three matrices and get something called a transformation matrix. It will easily transform a vector that represents a position.
Luckily most of the time you don’t have to work on your math library. Three.js already has a built-in math library and we are going to use it.
Back to Rotation
To rotate a shape in Three.js we need to create a rotation matrix and multiply it with shape’s every vector. We will use cloneVector again to make sure that created shape is independent from the one stored as a pattern.
// append to Tetris.Block.rotate()
var rotationMatrix = new THREE.Matrix4();
rotationMatrix.setRotationFromEuler(Tetris.Block.mesh.rotation);
for (var i = 0; i < Tetris.Block.shape.length; i++) {
Tetris.Block.shape[i] = rotationMatrix.multiplyVector3(
Tetris.Utils.cloneVector(Tetris.Block.shapes[this.blockType][i])
);
Tetris.Utils.roundVector(Tetris.Block.shape[i]);
}
There is one problem with rotation matrix and our representation of the board. Fields are represented as an array which is indexed by integers, while a result of matrix-vector multiplication may be a float. JavaScript is not very good with floating point numbers and it’s almost sure that it will produce positions like 1.000001 or 2.999998. This is why we need a rounding function.
Tetris.Utils.roundVector = function(v) {
v.x = Math.round(v.x);
v.y = Math.round(v.y);
v.z = Math.round(v.z);
};
When we have our shape rotated, it’s very simple to check if a collision occurs. I used the same trick with undoing rotation by calling the function again, but with inverted parameters. Please note, that collision will never occur when undoing a move. If you want, you can add an additional parameter so it won’t be checked again when not needed.
// append to Tetris.Block.rotate()
if (Tetris.Board.testCollision(false) === Tetris.Board.COLLISION.WALL) {
Tetris.Block.rotate(-x, -y, -z); // laziness FTW
}
Completed Slices and Score Counting
This function will be quite long but simple. To check if a slice is completed I calculate the maximum number of occupied fields and check every slice (moving on z-axis) if it’s full. This way I can change the size of the board and this function should be still working. Try to think about all your functions in such a way: If something may ever change, make your code flexible.
Tetris.Board.checkCompleted = function() {
var x,y,z,x2,y2,z2, fields = Tetris.Board.fields;
var rebuild = false;
var sum, expected = fields[0].length*fields.length, bonus = 0;
for(z = 0; z < fields[0][0].length; z++) {
sum = 0;
for(y = 0; y < fields[0].length; y++) {
for(x = 0; x < fields.length; x++) {
if(fields[x][y][z] === Tetris.Board.FIELD.PETRIFIED) sum++;
}
}
When the slice is full, we should remove it and shift all the following slices. To make sure that we don’t skip a shifted slice we decrease z once. To make the game more interesting, bonus points are granted if there are multiple slices completed at once.
if(sum == expected) {
bonus += 1 + bonus; // 1, 3, 7, 15...
for(y2 = 0; y2 < fields[0].length; y2++) {
for(x2 = 0; x2 < fields.length; x2++) {
for(z2 = z; z2 < fields[0][0].length-1; z2++) {
Tetris.Board.fields[x2][y2][z2] = fields[x2][y2][z2+1]; // shift
}
Tetris.Board.fields[x2][y2][fields[0][0].length-1] = Tetris.Board.FIELD.EMPTY;
}
}
rebuild = true;
z--;
}
}
if(bonus) {
Tetris.addPoints(1000 * bonus);
}
Now, even though we dealt with board information, we still have to make changes to Three.js geometries. We couldn’t do it in the previous loop, as it could rebuild the geometries twice or even more if multiple slices were completed at once. This loop checks every Tetris.Board.fields with corresponding Tetris.staticBlocks adding and removing geometries where needed.
if(rebuild) {
for(var z = 0; z < fields[0][0].length-1; z++) {
for(var y = 0; y < fields[0].length; y++) {
for(var x = 0; x < fields.length; x++) {
if(fields[x][y][z] === Tetris.Board.FIELD.PETRIFIED && !Tetris.staticBlocks[x][y][z]) {
Tetris.addStaticBlock(x,y,z);
}
if(fields[x][y][z] == Tetris.Board.FIELD.EMPTY && Tetris.staticBlocks[x][y][z]) {
Tetris.scene.removeObject(Tetris.staticBlocks[x][y][z]);
Tetris.staticBlocks[x][y][z] = undefined;
}
}
}
}
}
};
Audio API
Adding audio is very simple with HTML5. Let’s start with adding <audio> elements to index.html.
<audio id="audio_theme" src="music/tetris.mp3" preload="auto"></audio>
<audio id="audio_move" src="music/move.mp3" preload="auto"></audio>
<audio id="audio_collision" src="music/collision.mp3" preload="auto"></audio>
<audio id="audio_gameover" src="music/gameover.mp3" preload="auto"></audio>
<audio id="audio_score" src="music/cash.mp3" preload="auto"></audio>
Using these files in JS is also easy. First create an object to store your sounds:
// before Tetris.init()
Tetris.sounds = {};
To call Audio API we have to retrieve these DOM elements.
// in Tetris.init()
Tetris.sounds["theme"] = document.getElementById("audio_theme");
Tetris.sounds["collision"] = document.getElementById("audio_collision");
Tetris.sounds["move"] = document.getElementById("audio_move");
Tetris.sounds["gameover"] = document.getElementById("audio_gameover");
Tetris.sounds["score"] = document.getElementById("audio_score");
There are numerous methods and you can create your own audio player but for our purposes, play() and pause() is enough. You can probably guess where you should add the music:
Tetris.sounds["theme"].play() – in Tetris.init(), right after initialization of sound object.
Tetris.sounds["theme"].pause() – in Tetris.start().
else {Tetris.sounds["move"].play();} – in Tetris.Block.move(), if there is no ground collision.
Tetris.sounds["collision"].play(); – in Tetris.Block.move(), if there is a ground collision.
Tetris.sounds["score"].play(); – in Tetris.addPoints().
Tetris.sounds["gameover"].play(); – in Tetris.Block.generate(), where we test for the lost game.
Conclusion
That’s all folks! Our Tetris is fully functional now. I hope it was a fun way to learn Three.js. There are many topics like advanced geometries, shaders, lights, skeletal animation, etc., that were not covered here. I just wanted to show that to create a game they’re not always needed.
If you want to learn more, you should probably use pure WebGL from now on. You can start with this tutorial. Check also "Building the Game" by Brandon Jones.
Published at DZone with permission of Sebastian Poręba, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments