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:
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.
Did you have a problem with anything? Download the sample application below and compare it with your project, you will find the error easily.
Download
By downloading the following file, you agree to the license terms
Downloaded 20x (15.99 MB)
Application includes source codes