Over a million developers have joined DZone.

3D Tetris with Three.js tutorial - Part 3

· Web Dev Zone

Start coding today to experience the powerful engine that drives data application’s development, brought to you in partnership with Qlik.

Third part of the tutorial is about moving blocks. We will create a block, introduce some movement and steering. For now we won't care about collision detection.

Lets start with a summary. What is a lifecycle of a Tetris block? It's created at some fixed point, moved and rotated by the player, as time passes it also falls down by itself and at the very end it hits the ground and is turned into a static element. Then a next block is created and the cycle repeats. This description is more or less a plan of methods we should have in our block object.

Preparation

At 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 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. And I'm not talking only about silly JS games. 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 specified group of points. I'm 99% 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'm a little pedantic and I know, that rotation is quite complex and we should avoid it if possible. That's why we will keep the basic shape of 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]);
  }
  // to be continued...

 

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 internal vertices array. It takes into account 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 like 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);
}
  // to be continued...

 With merged geometry we can use a trick with double materials from part 1 of 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})
]);
  // to be continued...

 We have to set 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 & 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, in part 2 of this tutorial 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); // add this line

 

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 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.

After this tutorial you should:

  • Know that numerics are passed by value and objects by reference.
  • Understand life cycle of a Tetris block.
  • Know how to merge geometries and the difference between mesh and geometry.
  • Know how to bind keyoboard events.

Grab source from github

If you have trouble with any of these, check tutorial again or ask a question in the comments below.

 

Create data driven applications in Qlik’s free and easy to use coding environment, brought to you in partnership with Qlik.

Topics:

Published at DZone with permission of Sebastian Poręba, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

The best of DZone straight to your inbox.

SEE AN EXAMPLE
Please provide a valid email address.

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.
Subscribe

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}