Discount week - May Swift week
Save up to 80 % on our Swift e-learning courses. Only this week!
Get up to 60 % extra points for free! More info

Lesson 11 - Tetris in MonoGame: Level Features

In the previous lesson, Tetris in MonoGame: Score and Level Completing, we finished the Tetris level and implemented a score and game pause.

Today's tutorial is the last one where we're going to take look at the level. We're going to improve the block rotation and add a ghost block to the game.

Improving the Block Rotation

The way our block is rotated isn't certainly very natural. In the original Tetris, the blocks are defined in all rotated positions manually, some blocks have only two positions (like the I-block), some only one (the O-block). However, such manual declaration isn't very technical and would hardly improve our programming skills. So far, our solution rotates each block as a square matrix of the size 4x4. The problem is that most blocks are three tiles large, some even just two.

First, let's edit our blocks in the text file so that they're always aligned in the upper left corner of the matrix. Of course, we have to work with the block as it was a square, and determine its position according to its longest side (this is especially important for the I-block). Therefore, we'll modify the netfx.dll file as following:

1000
1110
0000
0000

0010
1110
0000
0000

1100
1100
0000
0000

0000
1111
0000
0000

0100
1110
0000
0000

1100
0110
0000
0000

0110
1100
0000
0000

1000
0000
0000
0000

1010
0100
1010
0000

0100
1110
0100
0000

1110
0100
0100
0000

1100
0000
0000
0000

Let's open the Block class and add a public edge field to it:

public int edge;

We'll add a private GetEdge() method to the class, in which we'll assume that the block has the edge value of 1, that is the lowest possible value (we certainly won't have an empty block in our game).

private void GetEdge()
{
    edge = 1;
}

We'll get the edge value in the method by going through all the block's tiles. If there's a non-empty tile that is further from the top left corner than is the current edge value, we'll update the edge value to match the used tile coordinates.

for (int j = 0; j < 4; j++)
    for (int i = 0; i < 4; i++)
    {
        // found a tile that is further from the top left corner than is the current edge value
        if ((Tiles[i, j] > 0) && (((i + 1) > edge) || ((j + 1) > edge)))
        {
            if (i > j)
                edge = i + 1;
            else
                edge = j + 1;
        }
    }

We'll call the method in the constructor, right after generating the sprites:

GetEdge();

We've stored the edge. Now let's edit the rotation method, which is going to be a piece of cake (we'll only work with the part of the array specified by edge):

public void Rotate()
{
    // temp array
    int[,] a = CopyTiles(Tiles);
    // rotate the array by swapping the coordinates, as with a matrix
    for (int y = 0; y < edge; y++)
        for (int x = 0; x < edge; x++)
            Tiles[x, y] = a[y, (edge - 1) - x];
}

The InsertBlock() method in the GameBoard class will also be affected by the block size:

public Vector2 InsertBlock(Block block)
{
    return new Vector2((width / 2) - (block.edge / 2), 0);
}

Let's try the game now. The rotation is much more precise now.

The Ghost Block

Some Tetris versions show the spot the block will fall directly on if the down arrow cursor key would be pressed. Let's add this element in our game and call it ghost. Another suitable name would be crosshair if you like.

Determining the Position

Let's go to GameBoard.cs, where we'll add a CalculateGhostCoordinates() method. It'll take the block as a parameter and will return the calculated position as Vector2. The returned position will be the position where the block would fall in case we pressed the down arrow cursor key. To calculate the position, we simply have to keep moving the block position down until it collides. The returned position will be 1 tile higher.

public Vector2 CalculateGhostCoordinates(Block block)
{
    Vector2 position = block.position;
    // move the block position down until it collides
    while (!Collision(block, position))
        position.Y++;
    // move the position back by one tile before the collision
    return new Vector2(block.position.X, position.Y - 1);
}

Since computing this position is now twice in our project (which isn't a very good programming habit), we'll have to replace the down arrow cursor key logic in the Update() method in LevelComponent. Instead of that logic, we'll call this method. The modified code will look like this:

if (robotrisGame.NewKey(Keys.Down))
{
    block.position = gameBoard.CalculateGhostCoordinates(block);
    timeSinceLastFall += timeBetweenFalls;
}

Let's stay in LevelComponent for a while and create a new private field for the ghost block position:

private Vector2 ghostPosition;

This field will be set in Update(), during the Playing state:

ghostPosition = gameBoard.CalculateGhostPosition(block);

Rendering

We can now calculate the position. Now we need to render the ghost at that position. The ghost will look like the falling block. There's no need to create another object for it (although it'd be possible). Instead we'll just render the falling block again at different coordinates, and with modified transparency.

Let's go to Block.cs and edit the Draw() method. We'll make it private and put _ (underscore) before its name. We'll also add two more parameters. The first one will be position of the float type, and will represent the block render position. This way we can render the block elsewhere than it actually is. We've already done something similar in the Collision() method. The second parameter named alpha will specify the block transparency. In the method body we just have to multiply the color by alpha. C# will understand we mean the position parameter of the method, not the class field that has the same name.

The method code will look like this:

private void _Draw(Vector2 border, BetterSpriteBatch spriteBatch, Texture2D[] sprites, Vector2 position, float alpha)
{
    for (int j = 0; j < 4; j++)
        for (int i = 0; i < 4; i++)
            if (Tiles[i, j] > 0)
                spriteBatch.Draw(sprites[Tiles[i, j] - 1],
                    new Vector2(border.X + (i + position.X) * sprites[0].Width,
                        border.Y + (j + position.Y) * sprites[0].Height), Color.White * alfa);
}

Implementing block and ghost rendering methods will now be a piece of cake. The Draw() method will simply call _Draw() and pass it the position field and the value of 1 as the alpha:

public void Draw(Vector2 leftBorder, BetterSpriteBatch spriteBatch, Texture2D[] sprites)
{
    _Draw(leftBorder, spriteBatch, sprites, position, 1);
}

The DrawGhost() will set all parameters of the _Draw() method:

public void DrawGhost(Vector2 leftBorder, BetterSpriteBatch spriteBatch, Texture2D[] sprites, Vector2 position, float alpha)
{
    _Draw(leftBorder, spriteBatch, sprites, position, alpha);
}

If you now think that a Draw() method calling DrawGhost() would be enough, you're right. But from a design point of view, however, this doesn't seem logical.

Let's move onto the Draw() method of LevelComponent. We'll render the ghost block right after the falling block and rendering the next block:

block.DrawGhost(gameBoardPosition, robotrisGame.spriteBatch, tileSprites, ghostPosition, 0.3f);

Now let's run it:

Sample Tetris game in MonoGame

The game is much more pleasant to play.

Pulsating

Let's add one more effect at the end. We'll let the ghost's transparency pulsate, as we did with the clouds color. Again, we'll create the pulse direction field of the int type, storing values 1 or -1. The transparency value itself will then be stored in a float field. Let's add both fields to the class:

private int ghostDirection;
private float ghostAlpha;

In Initialize() we'll set the default values:

ghostAlpha = 0.0f;
ghostDirection = 1;

In Update(), during the Playing state, we'll update the pulse behavior:

ghostAlpha += 0.02f * ghostDirection;
if (ghostAlpha > 0.5)
    ghostDirection = -1;
if (ghostAlpha < 0.2)
    ghostDirection = 1;

The ghostAlpha value now alternates between 0.2 and 0.5.

Go to Draw() and change the ghost block and the next block rendering as follows:

nextBlock.Draw(new Vector2(930, 200 + (40 * ghostAlpha)), robotrisGame.spriteBatch, tileSprites);
block.DrawGhost(gameBoardPosition, robotrisGame.spriteBatch, tileSprites, ghostPosition, ghostAlpha);

Now run the game and enjoy it because it's complete :) We used the pulsating effect even to make the next block levitate.

In the next lesson, Tetris in MonoGame: Game Scene Management, we'll take a look at game scene management and start making a game menu.


 

Download

Downloaded 0x (15.99 MB)
Application includes source codes

 

 

Article has been written for you by David Capka
Avatar
Do you like this article?
No one has rated this quite yet, be the first one!
The author is a programmer, who likes web technologies and being the lead/chief article writer at ICT.social. He shares his knowledge with the community and is always looking to improve. He believes that anyone can do what they set their mind to.
Unicorn College The author learned IT at the Unicorn College - a prestigious college providing education on IT and economics.
Previous article
Tetris in MonoGame: Score and Level Completing
All articles in this section
Tetris From Scratch
Thumbnail
Next article
Tetris in MonoGame: Game Scene Management
Activities (2)

 

 

Comments

To maintain the quality of discussion, we only allow registered members to comment. Sign in. If you're new, Sign up, it's free.

No one has commented yet - be the first!